Add courtlistener intergration, liquid glass redesign, UI improvements, version control, various fixes

This commit is contained in:
willchen96 2026-06-06 15:48:47 +08:00
parent d39f5806e5
commit 44e868eb42
106 changed files with 16350 additions and 7753 deletions

View file

@ -11,6 +11,7 @@ import { tabularRouter } from "./routes/tabular";
import { workflowsRouter } from "./routes/workflows";
import { userRouter } from "./routes/user";
import { downloadsRouter } from "./routes/downloads";
import { caseLawRouter } from "./routes/caseLaw";
const app = express();
const PORT = process.env.PORT ?? 3001;
@ -71,12 +72,22 @@ const uploadLimiter = makeLimiter({
message: "Too many upload requests. Please try again later.",
});
function jsonLimitForPath(path: string): string {
return "50mb";
}
app.disable("x-powered-by");
app.set("trust proxy", envInt("TRUST_PROXY_HOPS", 1));
app.use(
helmet({
contentSecurityPolicy: false,
contentSecurityPolicy: {
directives: {
defaultSrc: ["'none'"],
baseUri: ["'none'"],
frameAncestors: ["'none'"],
},
},
crossOriginEmbedderPolicy: false,
hsts: isProduction
? {
@ -97,8 +108,6 @@ app.use(
app.use(generalLimiter);
app.use(express.json({ limit: "50mb" }));
app.post("/chat", chatLimiter);
app.post("/projects/:projectId/chat", chatLimiter);
app.post("/tabular-review/:reviewId/chat", chatLimiter);
@ -109,6 +118,10 @@ app.post("/single-documents", uploadLimiter);
app.post("/single-documents/:documentId/versions", uploadLimiter);
app.post("/projects/:projectId/documents", uploadLimiter);
app.use((req, res, next) =>
express.json({ limit: jsonLimitForPath(req.path) })(req, res, next),
);
app.use("/chat", chatRouter);
app.use("/projects", projectsRouter);
app.use("/projects/:projectId/chat", projectChatRouter);
@ -118,6 +131,7 @@ app.use("/workflows", workflowsRouter);
app.use("/user", userRouter);
app.use("/users", userRouter);
app.use("/download", downloadsRouter);
app.use("/case-law", caseLawRouter);
app.get("/health", (_req, res) => res.json({ ok: true }));

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,8 @@ interface DocRow {
}
interface VersionPathRow extends DocRow {
/** API/client alias for document_versions.filename of the active version. */
filename?: string | null;
/** Set from document_versions.storage_path of the active version. */
storage_path?: string | null;
/** Set from document_versions.pdf_storage_path of the active version. */
@ -16,6 +18,10 @@ interface VersionPathRow extends DocRow {
current_version_id?: string | null;
/** Set from document_versions.version_number of the active version. */
active_version_number?: number | null;
/** Active-version file metadata. */
file_type?: string | null;
size_bytes?: number | null;
page_count?: number | null;
}
export interface ActiveVersion {
@ -23,8 +29,11 @@ export interface ActiveVersion {
storage_path: string;
pdf_storage_path: string | null;
version_number: number | null;
display_name: string | null;
filename: string | null;
source: string | null;
file_type: string | null;
size_bytes: number | null;
page_count: number | null;
}
/**
@ -54,7 +63,7 @@ export async function loadActiveVersion(
const { data: v } = await db
.from("document_versions")
.select(
"id, document_id, storage_path, pdf_storage_path, version_number, display_name, source",
"id, document_id, storage_path, pdf_storage_path, version_number, filename, source, file_type, size_bytes, page_count",
)
.eq("id", targetVersionId)
.single();
@ -64,8 +73,11 @@ export async function loadActiveVersion(
storage_path: v.storage_path as string,
pdf_storage_path: (v.pdf_storage_path as string | null) ?? null,
version_number: (v.version_number as number | null) ?? null,
display_name: (v.display_name as string | null) ?? null,
filename: (v.filename as string | null) ?? null,
source: (v.source as string | null) ?? null,
file_type: (v.file_type as string | null) ?? null,
size_bytes: (v.size_bytes as number | null) ?? null,
page_count: (v.page_count as number | null) ?? null,
};
}
@ -85,14 +97,20 @@ export async function attachActiveVersionPaths<T extends VersionPathRow>(
.filter((id): id is string => typeof id === "string");
if (versionIds.length === 0) {
for (const d of docs) {
d.filename = "Untitled document";
d.storage_path = null;
d.pdf_storage_path = null;
d.file_type = null;
d.size_bytes = null;
d.page_count = null;
}
return docs;
}
const { data: rows } = await db
.from("document_versions")
.select("id, storage_path, pdf_storage_path, version_number")
.select(
"id, storage_path, pdf_storage_path, version_number, filename, file_type, size_bytes, page_count",
)
.in("id", versionIds);
const byId = new Map<
string,
@ -100,6 +118,10 @@ export async function attachActiveVersionPaths<T extends VersionPathRow>(
storage_path: string | null;
pdf_storage_path: string | null;
version_number: number | null;
filename: string | null;
file_type: string | null;
size_bytes: number | null;
page_count: number | null;
}
>();
for (const r of (rows ?? []) as {
@ -107,11 +129,19 @@ export async function attachActiveVersionPaths<T extends VersionPathRow>(
storage_path: string | null;
pdf_storage_path: string | null;
version_number: number | null;
filename: string | null;
file_type: string | null;
size_bytes: number | null;
page_count: number | null;
}[]) {
byId.set(r.id, {
storage_path: r.storage_path ?? null,
pdf_storage_path: r.pdf_storage_path ?? null,
version_number: r.version_number ?? null,
filename: r.filename ?? null,
file_type: r.file_type ?? null,
size_bytes: r.size_bytes ?? null,
page_count: r.page_count ?? null,
});
}
for (const d of docs) {
@ -119,6 +149,10 @@ export async function attachActiveVersionPaths<T extends VersionPathRow>(
d.storage_path = v?.storage_path ?? null;
d.pdf_storage_path = v?.pdf_storage_path ?? null;
d.active_version_number = v?.version_number ?? null;
d.filename = v?.filename?.trim() || "Untitled document";
d.file_type = v?.file_type ?? null;
d.size_bytes = v?.size_bytes ?? null;
d.page_count = v?.page_count ?? null;
}
return docs;
}

View file

@ -0,0 +1,197 @@
export type CourtlistenerToolEvent =
| {
type: "courtlistener_search_case_law";
query: string;
result_count: number;
error?: string;
}
| {
type: "courtlistener_get_cases";
cluster_ids: number[];
case_count: number;
opinion_count: number;
cases?: {
cluster_id: number;
case_name: string | null;
citation: string | null;
dateFiled?: string | null;
url?: string | null;
}[];
error?: string;
}
| {
type: "courtlistener_find_in_case";
cluster_id: number | null;
query: string;
total_matches: number;
case_name?: string | null;
citation?: string | null;
searches?: {
cluster_id: number | null;
query: string;
total_matches: number;
case_name?: string | null;
citation?: string | null;
error?: string;
}[];
error?: string;
}
| {
type: "courtlistener_read_case";
cluster_id: number | null;
case_name?: string | null;
citation?: string | null;
opinion_count: number;
error?: string;
}
| {
type: "courtlistener_verify_citations";
citation_count: number;
match_count: number;
error?: string;
};
export type CaseCitationEvent = {
type: "case_citation";
cluster_id: number | null;
case_name: string | null;
citation: string | null;
url: string;
pdfUrl?: string | null;
dateFiled?: string | null;
judges?: string | null;
};
export const COURTLISTENER_TOOL_NAMES = {
searchCaseLaw: "courtlistener_search_case_law",
getCases: "courtlistener_get_cases",
findInCase: "courtlistener_find_in_case",
readCase: "courtlistener_read_case",
verifyCitations: "courtlistener_verify_citations",
} as const;
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.
- 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.
- 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.
- Do not cite a case based only on memory, search-result snippets, reporter metadata, citationLinks, or verification results. Those sources may help choose candidates, but final case citations must be grounded in supplied opinion text/passages.
- Every case citation in final prose must be rendered as a clickable case-law panel link using the markdown link returned in citationLinks, e.g. [Case Name, Citation](us-case-12345). Do not write plain-text case citations without the link.
- Use numbered [N] markers for case citations in the final prose and include each cited case in the final <CITATIONS> block.
- Each case entry in the <CITATIONS> block must include quote(s) copied exactly from the supplied opinion text/passages for that case, e.g. {"ref": N, "cluster_id": 123, "quotes": [{"opinion_id": 456, "quote": "exact verbatim opinion text"}]}. Do not include top-level "quote", "doc_id", "page", "case_name", or "citation" for case entries.
- If a case is useful but you do not have its opinion text or relevant passages, either fetch the opinions before citing it or say that you could not read the opinion and do not cite or characterize the case beyond basic metadata.`;
export const COURTLISTENER_TOOLS = [
{
type: "function",
function: {
name: COURTLISTENER_TOOL_NAMES.getCases,
description:
"Fetch and cache one or more CourtListener case clusters and their opinions by cluster ID. This returns metadata/counts only, not full opinion text. After this, call courtlistener_find_in_case for targeted passages or courtlistener_read_case if broader full-case context is needed.",
parameters: {
type: "object",
properties: {
clusterIds: {
type: "array",
items: { type: "integer" },
description:
"CourtListener cluster IDs from courtlistener_verify_citations or other case metadata already present in the conversation.",
},
},
required: ["clusterIds"],
},
},
},
{
type: "function",
function: {
name: COURTLISTENER_TOOL_NAMES.findInCase,
description:
"Search within an already-fetched CourtListener case cluster for specific keyword(s) or phrases. Returns matches with surrounding opinion context. Call courtlistener_get_cases first; this tool does not fetch cases. Use no more than 3 calls to this tool in a single assistant turn.",
parameters: {
type: "object",
properties: {
clusterId: {
type: "integer",
description:
"CourtListener cluster ID previously fetched with courtlistener_get_cases.",
},
query: {
type: "string",
description:
"Short term to search for, 1-3 words long and likely to appear exactly as written in the opinion text. Matching is case-insensitive and collapses whitespace.",
},
max_results: {
type: "integer",
description:
"Maximum number of matches to return. Default 20.",
},
context_chars: {
type: "integer",
description:
"Characters of surrounding context to include on each side of each match. Default 160.",
},
},
required: ["clusterId", "query"],
},
},
},
{
type: "function",
function: {
name: COURTLISTENER_TOOL_NAMES.readCase,
description:
"Read selected opinion text from an already-fetched CourtListener case cluster in this turn's cache. Use after courtlistener_find_in_case if snippets are insufficient. If the case has multiple opinions, pass only the opinionId/opinionIds needed. Call courtlistener_get_cases first; this tool does not fetch cases.",
parameters: {
type: "object",
properties: {
clusterId: {
type: "integer",
description:
"CourtListener cluster ID previously fetched with courtlistener_get_cases.",
},
opinionId: {
type: "integer",
description:
"Specific opinion ID to read. Use when one opinion is enough.",
},
opinionIds: {
type: "array",
items: { type: "integer" },
description:
"Specific opinion IDs to read. Use the smallest set needed; do not read all opinions unless the question requires it.",
},
},
required: ["clusterId"],
},
},
},
{
type: "function",
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.",
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.",
},
},
},
},
},
];

View file

@ -7,6 +7,7 @@ import type {
NormalizedToolResult,
} from "./types";
import { toClaudeTools } from "./tools";
import { logRawLlmStream } from "./rawStreamLog";
type ContentBlock =
| { type: "text"; text: string }
@ -41,6 +42,65 @@ function toNativeMessages(
return messages.map((m) => ({ role: m.role, content: m.content }));
}
function claudeErrorMessage(error: unknown): string {
const parsedObject = claudeStreamFailureMessage(error);
if (parsedObject) return parsedObject;
if (error instanceof Error && error.message) {
const parsed = parseClaudeErrorPayload(error.message);
if (parsed) return parsed;
return error.message.startsWith("Claude error:")
? error.message
: `Claude error: ${error.message}`;
}
const parsed = parseClaudeErrorPayload(String(error));
if (parsed) return parsed;
return `Claude error: ${String(error)}`;
}
function parseClaudeErrorPayload(value: string): string | null {
const trimmed = value.trim();
const jsonStart = trimmed.indexOf("{");
if (jsonStart < 0) return null;
const jsonEnd = trimmed.lastIndexOf("}");
if (jsonEnd <= jsonStart) return null;
const payload = trimmed.slice(jsonStart, jsonEnd + 1);
try {
const parsed = JSON.parse(payload) as unknown;
return claudeStreamFailureMessage(parsed);
} catch {
return null;
}
}
function claudeStreamFailureMessage(event: unknown): string | null {
if (!event || typeof event !== "object") return null;
const record = event as Record<string, unknown>;
const error = record.error;
if (record.type !== "error" || !error || typeof error !== "object") {
return null;
}
const err = error as Record<string, unknown>;
const type =
typeof err.type === "string" && err.type.trim()
? err.type.trim()
: null;
const message =
typeof err.message === "string" && err.message.trim()
? err.message.trim()
: "Claude stream failed.";
return type ? `Claude error (${type}): ${message}` : `Claude error: ${message}`;
}
function abortError(): Error {
const err = new Error("Stream aborted.");
err.name = "AbortError";
return err;
}
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw abortError();
}
export async function streamClaude(
params: StreamChatParams,
): Promise<StreamChatResult> {
@ -61,6 +121,7 @@ export async function streamClaude(
let fullText = "";
for (let iter = 0; iter < maxIter; iter++) {
throwIfAborted(params.abortSignal);
const stream = anthropic.messages.stream({
model,
system: systemPrompt,
@ -82,6 +143,35 @@ export async function streamClaude(
});
let sawThinking = false;
let streamFailureMessage: string | null = null;
const abortStream = () => stream.abort();
params.abortSignal?.addEventListener("abort", abortStream, {
once: true,
});
stream.on("streamEvent", (event) => {
logRawLlmStream({
provider: "claude",
model,
iteration: iter,
label: "streamEvent",
payload: event,
});
const failureMessage = claudeStreamFailureMessage(event);
if (failureMessage) {
streamFailureMessage = failureMessage;
stream.abort();
}
});
stream.on("error", (error) => {
logRawLlmStream({
provider: "claude",
model,
iteration: iter,
label: "error",
payload: error,
});
});
stream.on("text", (delta) => {
callbacks.onContentDelta?.(delta);
@ -93,8 +183,18 @@ export async function streamClaude(
});
}
const final = await stream.finalMessage();
let final: Awaited<ReturnType<typeof stream.finalMessage>>;
try {
final = await stream.finalMessage();
} catch (error) {
if (params.abortSignal?.aborted) throw abortError();
if (streamFailureMessage) throw new Error(streamFailureMessage);
throw new Error(claudeErrorMessage(error));
} finally {
params.abortSignal?.removeEventListener("abort", abortStream);
}
if (sawThinking) callbacks.onReasoningBlockEnd?.();
throwIfAborted(params.abortSignal);
const stopReason = final.stop_reason;
const assistantBlocks = final.content as ContentBlock[];
@ -126,6 +226,7 @@ export async function streamClaude(
}
const results = await runTools(toolCalls);
throwIfAborted(params.abortSignal);
// Record the assistant turn (preserving the original content blocks,
// which Claude requires on the follow-up) and the user turn that
@ -152,12 +253,17 @@ export async function completeClaudeText(params: {
apiKeys?: { claude?: string | null };
}): Promise<string> {
const anthropic = client(params.apiKeys?.claude);
const resp = await anthropic.messages.create({
model: params.model,
max_tokens: params.maxTokens ?? 512,
system: params.systemPrompt,
messages: [{ role: "user", content: params.user }],
});
let resp: Awaited<ReturnType<typeof anthropic.messages.create>>;
try {
resp = await anthropic.messages.create({
model: params.model,
max_tokens: params.maxTokens ?? 512,
system: params.systemPrompt,
messages: [{ role: "user", content: params.user }],
});
} catch (error) {
throw new Error(claudeErrorMessage(error));
}
const text = resp.content
.filter((b): b is Anthropic.TextBlock => b.type === "text")
.map((b) => b.text)

View file

@ -5,6 +5,7 @@ import type {
NormalizedToolCall,
} from "./types";
import { toGeminiTools } from "./tools";
import { logRawLlmStream } from "./rawStreamLog";
type GeminiPart = {
text?: string;
@ -49,6 +50,113 @@ function toNativeContents(messages: StreamChatParams["messages"]): GeminiContent
}));
}
function geminiErrorMessage(error: unknown): string {
const parsedObject = geminiStreamFailureMessage(error);
if (parsedObject) return parsedObject;
if (typeof error === "string") {
const parsed = parseGeminiErrorPayload(error);
if (parsed) return parsed;
return error.startsWith("Gemini error:")
? error
: `Gemini error: ${error}`;
}
if (error instanceof Error && error.message) {
const parsed = parseGeminiErrorPayload(error.message);
if (parsed) return parsed;
return error.message.startsWith("Gemini error:")
? error.message
: `Gemini error: ${error.message}`;
}
return `Gemini error: ${String(error)}`;
}
function parseGeminiErrorPayload(value: string): string | null {
const trimmed = value.trim();
if (!trimmed.startsWith("{")) return null;
try {
const parsed = JSON.parse(trimmed) as unknown;
return geminiStreamFailureMessage(parsed);
} catch {
return null;
}
}
function geminiStreamFailureMessage(chunk: unknown): string | null {
if (!chunk || typeof chunk !== "object") return null;
const record = chunk as Record<string, unknown>;
const error = record.error;
if (error && typeof error === "object") {
const err = error as Record<string, unknown>;
const nested =
typeof err.message === "string"
? parseGeminiErrorPayload(err.message)
: null;
if (nested) return nested;
const message =
typeof err.message === "string" && err.message.trim()
? err.message.trim()
: "Gemini stream failed.";
const code =
typeof err.code === "string" && err.code.trim()
? err.code.trim()
: typeof err.code === "number" && Number.isFinite(err.code)
? String(err.code)
: typeof err.status === "string" && err.status.trim()
? err.status.trim()
: null;
return code ? `Gemini error (${code}): ${message}` : `Gemini error: ${message}`;
}
const promptFeedback = record.promptFeedback;
if (promptFeedback && typeof promptFeedback === "object") {
const feedback = promptFeedback as Record<string, unknown>;
const blockReason =
typeof feedback.blockReason === "string"
? feedback.blockReason
: null;
if (blockReason) {
const detail =
typeof feedback.blockReasonMessage === "string" &&
feedback.blockReasonMessage.trim()
? feedback.blockReasonMessage.trim()
: "The Gemini response was blocked.";
return `Gemini error (${blockReason}): ${detail}`;
}
}
const candidates = Array.isArray(record.candidates)
? (record.candidates as Record<string, unknown>[])
: [];
const finishReason =
typeof candidates[0]?.finishReason === "string"
? candidates[0].finishReason
: null;
const errorFinishReasons = new Set([
"SAFETY",
"RECITATION",
"BLOCKLIST",
"PROHIBITED_CONTENT",
"SPII",
"MALFORMED_FUNCTION_CALL",
"OTHER",
]);
if (finishReason && errorFinishReasons.has(finishReason)) {
return `Gemini error (${finishReason}): The Gemini stream ended with an error finish reason.`;
}
return null;
}
function abortError(): Error {
const err = new Error("Stream aborted.");
err.name = "AbortError";
return err;
}
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw abortError();
}
export async function streamGemini(
params: StreamChatParams,
): Promise<StreamChatResult> {
@ -61,61 +169,103 @@ export async function streamGemini(
let fullText = "";
for (let iter = 0; iter < maxIter; iter++) {
const stream = await ai.models.generateContentStream({
model,
contents: contents as never,
config: {
systemInstruction: systemPrompt,
tools: functionDeclarations.length
? [{ functionDeclarations } as never]
: undefined,
// When enabled, ask Gemini to surface thought summaries.
// When disabled, explicitly zero the thinking budget so the
// model skips thinking entirely (saves tokens and latency
// for bulk extraction jobs).
thinkingConfig: enableThinking
? { includeThoughts: true }
: { thinkingBudget: 0 },
},
});
throwIfAborted(params.abortSignal);
let stream: AsyncIterable<unknown>;
try {
stream = await ai.models.generateContentStream({
model,
contents: contents as never,
config: {
systemInstruction: systemPrompt,
tools: functionDeclarations.length
? [{ functionDeclarations } as never]
: undefined,
// When enabled, ask Gemini to surface thought summaries.
// When disabled, explicitly zero the thinking budget so the
// model skips thinking entirely (saves tokens and latency
// for bulk extraction jobs).
thinkingConfig: enableThinking
? { includeThoughts: true }
: { thinkingBudget: 0 },
},
});
} catch (error) {
throw new Error(geminiErrorMessage(error));
}
// Per-iteration accumulators.
const textParts: string[] = [];
const callParts: GeminiPart[] = [];
const toolCalls: NormalizedToolCall[] = [];
let sawThinking = false;
const iterator = stream[Symbol.asyncIterator]();
let rejectAbort: ((reason?: unknown) => void) | null = null;
const abortPromise = new Promise<never>((_, reject) => {
rejectAbort = reject;
});
const onAbort = () => rejectAbort?.(abortError());
params.abortSignal?.addEventListener("abort", onAbort, {
once: true,
});
for await (const chunk of stream) {
const parts =
(chunk as { candidates?: { content?: { parts?: GeminiPart[] } }[] })
.candidates?.[0]?.content?.parts ?? [];
try {
while (true) {
throwIfAborted(params.abortSignal);
const { value: chunk, done } = await Promise.race([
iterator.next(),
abortPromise,
]);
if (done) break;
logRawLlmStream({
provider: "gemini",
model,
iteration: iter,
label: "chunk",
payload: chunk,
});
const failureMessage = geminiStreamFailureMessage(chunk);
if (failureMessage) throw new Error(failureMessage);
for (const part of parts) {
if (part.text) {
if (part.thought) {
sawThinking = true;
callbacks.onReasoningDelta?.(part.text);
} else {
textParts.push(part.text);
callbacks.onContentDelta?.(part.text);
const parts =
(chunk as { candidates?: { content?: { parts?: GeminiPart[] } }[] })
.candidates?.[0]?.content?.parts ?? [];
for (const part of parts) {
if (part.text) {
if (part.thought) {
sawThinking = true;
callbacks.onReasoningDelta?.(part.text);
} else {
textParts.push(part.text);
callbacks.onContentDelta?.(part.text);
}
}
if (part.functionCall) {
// Preserve the whole part (including thoughtSignature)
// so it can be echoed verbatim in the replay turn.
callParts.push(part);
const call: NormalizedToolCall = {
id: part.functionCall.id ?? `${part.functionCall.name}-${toolCalls.length}`,
name: part.functionCall.name,
input: part.functionCall.args ?? {},
};
callbacks.onToolCallStart?.(call);
toolCalls.push(call);
}
}
if (part.functionCall) {
// Preserve the whole part (including thoughtSignature)
// so it can be echoed verbatim in the replay turn.
callParts.push(part);
const call: NormalizedToolCall = {
id: part.functionCall.id ?? `${part.functionCall.name}-${toolCalls.length}`,
name: part.functionCall.name,
input: part.functionCall.args ?? {},
};
callbacks.onToolCallStart?.(call);
toolCalls.push(call);
}
}
} catch (error) {
if (params.abortSignal?.aborted) throw abortError();
throw new Error(geminiErrorMessage(error));
} finally {
params.abortSignal?.removeEventListener("abort", onAbort);
if (params.abortSignal?.aborted) {
await iterator.return?.();
}
}
if (sawThinking) callbacks.onReasoningBlockEnd?.();
throwIfAborted(params.abortSignal);
fullText += textParts.join("");
@ -124,6 +274,7 @@ export async function streamGemini(
}
const results = await runTools(toolCalls);
throwIfAborted(params.abortSignal);
// Append the model's turn (text + functionCall parts, in that order)
// and the matching functionResponse turn.
@ -159,12 +310,17 @@ export async function completeGeminiText(params: {
apiKeys?: { gemini?: string | null };
}): Promise<string> {
const ai = client(params.apiKeys?.gemini);
const resp = await ai.models.generateContent({
model: params.model,
contents: [{ role: "user", parts: [{ text: params.user }] }],
config: params.systemPrompt
? { systemInstruction: params.systemPrompt }
: undefined,
});
let resp: Awaited<ReturnType<typeof ai.models.generateContent>>;
try {
resp = await ai.models.generateContent({
model: params.model,
contents: [{ role: "user", parts: [{ text: params.user }] }],
config: params.systemPrompt
? { systemInstruction: params.systemPrompt }
: undefined,
});
} catch (error) {
throw new Error(geminiErrorMessage(error));
}
return resp.text ?? "";
}

View file

@ -9,18 +9,18 @@ export const GEMINI_MAIN_MODELS = [
"gemini-3.1-pro-preview",
"gemini-3-flash-preview",
] as const;
export const OPENAI_MAIN_MODELS = ["gpt-5.5", "gpt-5.4-mini"] as const;
export const OPENAI_MAIN_MODELS = ["gpt-5.5", "gpt-5.4"] as const;
// Mid-tier (used for tabular review) — user picks one in account settings.
export const CLAUDE_MID_MODELS = ["claude-sonnet-4-6"] as const;
export const GEMINI_MID_MODELS = ["gemini-3-flash-preview"] as const;
export const OPENAI_MID_MODELS = ["gpt-5.4-mini"] as const;
export const OPENAI_MID_MODELS = ["gpt-5.4"] as const;
// Low-tier (used for title generation, lightweight extractions) — user picks
// one in account settings.
export const CLAUDE_LOW_MODELS = ["claude-haiku-4-5"] as const;
export const GEMINI_LOW_MODELS = ["gemini-3.1-flash-lite-preview"] as const;
export const OPENAI_LOW_MODELS = ["gpt-5.4-nano"] as const;
export const OPENAI_LOW_MODELS = ["gpt-5.4-lite"] as const;
export const DEFAULT_MAIN_MODEL = "gemini-3-flash-preview";
export const DEFAULT_TITLE_MODEL = "gemini-3.1-flash-lite-preview";

View file

@ -6,6 +6,7 @@ import type {
StreamChatParams,
StreamChatResult,
} from "./types";
import { logRawLlmStream } from "./rawStreamLog";
const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses";
const MAX_OUTPUT_TOKENS = 16384;
@ -31,7 +32,13 @@ type ResponseFunctionCallItem = {
type ResponseStreamEvent = {
type?: string;
delta?: string;
response?: { id?: string; output_text?: string };
response?: {
id?: string;
output_text?: string;
status?: string;
error?: { code?: string; message?: string } | null;
};
error?: { code?: string; message?: string } | null;
item?: ResponseFunctionCallItem;
};
@ -104,6 +111,35 @@ function parseFunctionCall(item: ResponseFunctionCallItem): NormalizedToolCall {
};
}
function openAIStreamFailureMessage(event: ResponseStreamEvent): string | null {
const error = event.response?.error ?? event.error ?? null;
const failed =
event.type === "response.failed" ||
event.response?.status === "failed" ||
!!error;
if (!failed) return null;
const message =
typeof error?.message === "string" && error.message.trim()
? error.message.trim()
: "OpenAI response failed.";
const code =
typeof error?.code === "string" && error.code.trim()
? error.code.trim()
: null;
return code ? `OpenAI error (${code}): ${message}` : message;
}
function abortError(): Error {
const err = new Error("Stream aborted.");
err.name = "AbortError";
return err;
}
function throwIfAborted(signal?: AbortSignal) {
if (signal?.aborted) throw abortError();
}
async function createResponse(params: {
model: string;
input: ResponseInputItem[];
@ -114,6 +150,7 @@ async function createResponse(params: {
previousResponseId?: string;
reasoningSummary?: boolean;
apiKey: string;
signal?: AbortSignal;
}): Promise<Response> {
const response = await fetch(OPENAI_RESPONSES_URL, {
method: "POST",
@ -133,6 +170,7 @@ async function createResponse(params: {
? { summary: "auto" }
: undefined,
}),
signal: params.signal,
});
if (!response.ok) {
@ -168,6 +206,7 @@ export async function streamOpenAI(
const hasTools = responseTools.length > 0;
for (let iter = 0; iter < maxIter; iter++) {
throwIfAborted(params.abortSignal);
const response = await createResponse({
model,
instructions: iter === 0 ? systemPrompt : undefined,
@ -177,6 +216,7 @@ export async function streamOpenAI(
previousResponseId,
reasoningSummary: !!enableThinking,
apiKey: key,
signal: params.abortSignal,
});
if (!response.body) throw new Error("OpenAI response had no body");
@ -189,14 +229,36 @@ export async function streamOpenAI(
let sawReasoning = false;
while (true) {
throwIfAborted(params.abortSignal);
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const decoded = decoder.decode(value, { stream: true });
logRawLlmStream({
provider: "openai",
model,
iteration: iter,
label: "sse_chunk",
payload: decoded,
});
buffer += decoded;
const extracted = extractSseJson(buffer);
buffer = extracted.rest;
for (const event of extracted.events as ResponseStreamEvent[]) {
logRawLlmStream({
provider: "openai",
model,
iteration: iter,
label: "sse_event",
payload: event,
});
const failureMessage = openAIStreamFailureMessage(event);
if (failureMessage) {
throw new Error(failureMessage);
}
if (event.response?.id) {
previousResponseId = event.response.id;
}
@ -244,6 +306,7 @@ export async function streamOpenAI(
}
if (sawReasoning) callbacks.onReasoningBlockEnd?.();
throwIfAborted(params.abortSignal);
if (!toolCalls.length || !runTools) {
if (pendingText) {
@ -254,6 +317,7 @@ export async function streamOpenAI(
}
const results = await runTools(toolCalls);
throwIfAborted(params.abortSignal);
input = results.map((result) => ({
type: "function_call_output",
call_id: result.tool_use_id,

View file

@ -0,0 +1,19 @@
export function logRawLlmStream(args: {
provider: string;
model: string;
iteration: number;
label: string;
payload: unknown;
}) {
if (
process.env.NODE_ENV === "production" &&
process.env.LOG_RAW_LLM_STREAM !== "true"
) {
return;
}
console.log(
`[raw-llm-stream:${args.provider}:${args.model}:iter-${args.iteration}] ${args.label}`,
);
console.dir(args.payload, { depth: null, maxArrayLength: null });
}

View file

@ -40,6 +40,8 @@ export type UserApiKeys = {
claude?: string | null;
gemini?: string | null;
openai?: string | null;
openrouter?: string | null;
courtlistener?: string | null;
};
export type StreamChatParams = {
@ -58,6 +60,7 @@ export type StreamChatParams = {
* one-shot completions should leave this off to save tokens and latency.
*/
enableThinking?: boolean;
abortSignal?: AbortSignal;
};
export type StreamChatResult = {

View file

@ -12,11 +12,14 @@
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
ListObjectsV2Command,
} from "@aws-sdk/client-s3";
import * as S3Commands from "@aws-sdk/client-s3";
import { getSignedUrl as awsGetSignedUrl } from "@aws-sdk/s3-request-presigner";
const GetObjectCommand = (S3Commands as any).GetObjectCommand;
let cachedClient: S3Client | undefined;
function getClient(): S3Client {
@ -79,9 +82,9 @@ export async function downloadFile(key: string): Promise<ArrayBuffer | null> {
if (!storageEnabled) return null;
try {
const client = getClient();
const response = await client.send(
const response = (await client.send(
new GetObjectCommand({ Bucket: BUCKET, Key: key }),
);
)) as any;
if (!response.Body) return null;
const bytes = await response.Body.transformToByteArray();
return bytes.buffer as ArrayBuffer;
@ -90,6 +93,27 @@ export async function downloadFile(key: string): Promise<ArrayBuffer | null> {
}
}
export async function listFiles(prefix: string): Promise<string[]> {
if (!storageEnabled) return [];
const client = getClient();
const keys: string[] = [];
let ContinuationToken: string | undefined;
do {
const response = await client.send(
new ListObjectsV2Command({
Bucket: BUCKET,
Prefix: prefix,
ContinuationToken,
}),
);
for (const item of response.Contents ?? []) {
if (item.Key) keys.push(item.Key);
}
ContinuationToken = response.NextContinuationToken;
} while (ContinuationToken);
return keys;
}
// ---------------------------------------------------------------------------
// Delete
// ---------------------------------------------------------------------------
@ -123,7 +147,7 @@ export async function getSignedUrl(
Bucket: BUCKET,
Key: key,
ResponseContentDisposition: responseContentDisposition,
});
}) as any;
return await awsGetSignedUrl(client, command, { expiresIn });
} catch {
return null;

View file

@ -3,7 +3,12 @@ import { createServerSupabase } from "./supabase";
import type { UserApiKeys } from "./llm";
type Db = ReturnType<typeof createServerSupabase>;
export type ApiKeyProvider = "claude" | "gemini" | "openai";
export type ApiKeyProvider =
| "claude"
| "gemini"
| "openai"
| "openrouter"
| "courtlistener";
export type ApiKeySource = "user" | "env" | null;
export type ApiKeyStatus = Record<ApiKeyProvider, boolean> & {
sources: Record<ApiKeyProvider, ApiKeySource>;
@ -16,7 +21,13 @@ type EncryptedKeyRow = {
auth_tag: string;
};
const PROVIDERS: ApiKeyProvider[] = ["claude", "gemini", "openai"];
const PROVIDERS: ApiKeyProvider[] = [
"claude",
"gemini",
"openai",
"openrouter",
"courtlistener",
];
function envApiKey(provider: ApiKeyProvider): string | null {
if (provider === "claude") {
@ -29,6 +40,12 @@ function envApiKey(provider: ApiKeyProvider): string | 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;
}
@ -96,10 +113,14 @@ export async function getUserApiKeyStatus(
claude: false,
gemini: false,
openai: false,
openrouter: false,
courtlistener: false,
sources: {
claude: null,
gemini: null,
openai: null,
openrouter: null,
courtlistener: null,
},
};
@ -135,6 +156,8 @@ export async function getUserApiKeys(
claude: envApiKey("claude"),
gemini: envApiKey("gemini"),
openai: envApiKey("openai"),
openrouter: envApiKey("openrouter"),
courtlistener: envApiKey("courtlistener"),
};
const { data, error } = await db

View file

@ -16,7 +16,7 @@ export type UserModelSettings = {
// Title generation is a lightweight task — always routed to the cheapest model
// of whichever provider the user has keys for: Gemini Flash Lite if Gemini is
// available, otherwise OpenAI nano, otherwise Claude Haiku. With no user keys
// available, otherwise OpenAI lite, otherwise Claude Haiku. With no user keys
// set, defaults to Gemini (the dev-mode env fallback).
function resolveTitleModel(apiKeys: UserApiKeys): string {
if (apiKeys.gemini?.trim()) return DEFAULT_TITLE_MODEL;
@ -32,13 +32,13 @@ export async function getUserModelSettings(
const client = db ?? createServerSupabase();
const { data } = await client
.from("user_profiles")
.select("tabular_model")
.select("title_model, tabular_model")
.eq("user_id", userId)
.single();
const api_keys = await getStoredUserApiKeys(userId, client);
return {
title_model: resolveTitleModel(api_keys),
title_model: resolveModel(data?.title_model, resolveTitleModel(api_keys)),
tabular_model: resolveModel(data?.tabular_model, DEFAULT_TABULAR_MODEL),
api_keys,
};

View file

@ -0,0 +1,84 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { getCourtlistenerCaseOpinions } from "../lib/courtlistener";
import { createServerSupabase } from "../lib/supabase";
import { getUserModelSettings } from "../lib/userSettings";
export const caseLawRouter = Router();
caseLawRouter.use(requireAuth);
const isDev = process.env.NODE_ENV !== "production";
const devLog = (...args: Parameters<typeof console.log>) => {
if (isDev) console.log(...args);
};
const sidepanelOpinionFetches = new Map<string, Promise<unknown>>();
function cleanClusterId(value: unknown): number | null {
const numeric =
typeof value === "number"
? value
: typeof value === "string"
? Number.parseInt(value, 10)
: NaN;
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : null;
}
caseLawRouter.post("/case-opinions", async (req, res) => {
const body =
req.body && typeof req.body === "object" && !Array.isArray(req.body)
? (req.body as Record<string, unknown>)
: {};
const clusterId = cleanClusterId(body.clusterId ?? body.cluster_id);
if (!clusterId) {
return res.status(400).json({
detail: "cluster_id is required",
});
}
try {
const userId = String(res.locals.userId ?? "");
const settings = await getUserModelSettings(userId);
devLog("[case-law/case-opinions] loading sidepanel opinions", {
clusterId,
});
const db = createServerSupabase();
const fetchKey = String(clusterId);
let fetchPromise = sidepanelOpinionFetches.get(fetchKey);
if (fetchPromise) {
devLog("[case-law/case-opinions] joining in-flight fetch", {
clusterId,
});
} else {
fetchPromise = getCourtlistenerCaseOpinions({
clusterId,
db,
includeFullText: true,
maxChars: 50000,
apiToken: settings.api_keys.courtlistener,
}).finally(() => {
sidepanelOpinionFetches.delete(fetchKey);
});
sidepanelOpinionFetches.set(fetchKey, fetchPromise);
}
const fetched = await fetchPromise;
const fetchedRecord =
fetched && typeof fetched === "object" && !Array.isArray(fetched)
? (fetched as Record<string, unknown>)
: {};
const opinions = Array.isArray(fetchedRecord.opinions)
? fetchedRecord.opinions
: [];
devLog("[case-law/case-opinions] returning sidepanel opinions", {
clusterId,
opinionCount: opinions.length,
});
return res.json({ opinions });
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to fetch case opinions";
return res.status(502).json({ detail: message });
}
});

View file

@ -6,8 +6,11 @@ import {
buildMessages,
enrichWithPriorEvents,
buildWorkflowStore,
AssistantStreamError,
extractAnnotations,
isAbortError,
runLLMStream,
stripTransientAssistantEvents,
type ChatMessage,
} from "../lib/chatTools";
import { completeText } from "../lib/llm";
@ -22,6 +25,14 @@ const devLog = (...args: Parameters<typeof console.log>) => {
if (isDev) console.log(...args);
};
const TITLE_FALLBACK = "Misc. Query";
function normalizeGeneratedTitle(raw: string): string {
const title = raw.trim().replace(/^["'`]+|["'`.,:;!?]+$/g, "").trim();
if (!title) return TITLE_FALLBACK;
return title.slice(0, 80);
}
type AccessibleChat = {
id: string;
title: string | null;
@ -225,11 +236,12 @@ chatRouter.get("/:chatId", requireAuth, async (req, res) => {
res.json({ chat, messages: hydrated });
});
// Stored message annotations/events capture the `status` at the time the
// assistant produced the edit (always "pending"). If the user later accepts
// or rejects, `document_edits.status` is updated but the stored message
// annotation is not. On chat load we merge the current DB status in so
// EditCards render with the real state.
// Stored doc_edited events capture the `status` at the time the assistant
// produced the edit (always "pending"). If the user later accepts or rejects,
// `document_edits.status` is updated but the stored event is not. On chat load
// we merge the current DB status in so EditCards render with the real state.
// Legacy rows may also have duplicate edit_data in top-level annotations, so
// keep patching that path until old data no longer matters.
async function hydrateEditStatuses(
messages: Record<string, unknown>[],
db: ReturnType<typeof createServerSupabase>,
@ -401,11 +413,11 @@ chatRouter.post("/:chatId/generate-title", requireAuth, async (req, res) => {
);
const titleText = await completeText({
model: title_model,
user: `Generate a concise title (36 words) for a chat in an AI Legal Platform that starts with this message. The title should describe the topic or document — do NOT include words like "Legal Assistant", "AI", "Chat", or any similar prefix. Return only the title, no quotes or punctuation.\n\nMessage: ${message.slice(0, 500)}`,
user: `Generate a concise title (36 words) for a chat in an AI Legal Platform that starts with this message. The title should describe the topic or document — do NOT include words like "Legal Assistant", "AI", "Chat", or any similar prefix. If there is not enough information to generate a title, return exactly "${TITLE_FALLBACK}". Return only the title, no quotes or punctuation.\n\nMessage: ${message.slice(0, 500)}`,
maxTokens: 64,
apiKeys: api_keys,
});
const title = titleText.trim() || message.slice(0, 60);
const title = normalizeGeneratedTitle(titleText);
await db
.from("chats")
@ -555,13 +567,18 @@ chatRouter.post("/", requireAuth, async (req, res) => {
res.flushHeaders();
const write = (line: string) => res.write(line);
const streamAbort = new AbortController();
let streamFinished = false;
res.on("close", () => {
if (!streamFinished) streamAbort.abort();
});
const apiKeys = await getUserApiKeys(userId, db);
try {
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);
const { fullText, events } = await runLLMStream({
const { fullText, events, annotations } = await runLLMStream({
apiMessages,
docStore,
docIndex,
@ -571,6 +588,7 @@ chatRouter.post("/", requireAuth, async (req, res) => {
workflowStore,
model,
apiKeys,
signal: streamAbort.signal,
projectId: resolvedProjectId,
});
@ -579,11 +597,11 @@ chatRouter.post("/", requireAuth, async (req, res) => {
eventCount: events?.length ?? 0,
});
const annotations = extractAnnotations(fullText, docIndex, events);
const persistedEvents = stripTransientAssistantEvents(events);
await db.from("chat_messages").insert({
chat_id: chatId,
role: "assistant",
content: events.length ? events : null,
content: persistedEvents.length ? persistedEvents : null,
annotations: annotations.length ? annotations : null,
});
@ -594,16 +612,45 @@ chatRouter.post("/", requireAuth, async (req, res) => {
.eq("id", chatId);
}
} catch (err) {
if (isAbortError(err)) {
devLog("[chat/stream] client aborted stream", { chatId });
return;
}
console.error("[chat/stream] error:", err);
const message =
err instanceof Error && err.message ? err.message : "Stream error";
const errorEvents = err instanceof AssistantStreamError
? stripTransientAssistantEvents(err.events)
: [{ type: "error" as const, message }];
const errorFullText =
err instanceof AssistantStreamError ? err.fullText : "";
try {
const annotations = extractAnnotations(
errorFullText,
docIndex,
errorEvents,
);
const { error: saveError } = await db.from("chat_messages").insert({
chat_id: chatId,
role: "assistant",
content: errorEvents.length ? errorEvents : null,
annotations: annotations.length ? annotations : null,
});
if (saveError)
console.error("[chat/stream] failed to save error", saveError);
} catch (saveErr) {
console.error("[chat/stream] failed to save error", saveErr);
}
try {
write(
`data: ${JSON.stringify({ type: "error", message: "Stream error" })}\n\n`,
`data: ${JSON.stringify({ type: "error", message })}\n\n`,
);
write("data: [DONE]\n\n");
} catch {
/* ignore */
}
} finally {
streamFinished = true;
res.end();
}
});

View file

@ -26,6 +26,30 @@ import { singleFileUpload } from "../lib/upload";
export const documentsRouter = Router();
const ALLOWED_TYPES = new Set(["pdf", "docx", "doc"]);
const isDev = process.env.NODE_ENV !== "production";
const devLog = (...args: Parameters<typeof console.log>) => {
if (isDev) console.log(...args);
};
async function deleteDocumentAndVersionFiles(
db: ReturnType<typeof createServerSupabase>,
documentId: string,
) {
// Storage lives on document_versions — fan out and delete each version's
// bytes (source + PDF rendition) before dropping the document row.
const { data: versions } = await db
.from("document_versions")
.select("storage_path, pdf_storage_path")
.eq("document_id", documentId);
await Promise.all(
(versions ?? []).flatMap((v) =>
[v.storage_path, v.pdf_storage_path]
.filter((p): p is string => typeof p === "string" && p.length > 0)
.map((p) => deleteFile(p).catch(() => {})),
),
);
return db.from("documents").delete().eq("id", documentId);
}
// GET /single-documents
documentsRouter.get("/", requireAuth, async (req, res) => {
@ -74,20 +98,7 @@ documentsRouter.delete("/:documentId", requireAuth, async (req, res) => {
if (error || !doc)
return void res.status(404).json({ detail: "Document not found" });
// Storage now lives on document_versions — fan out and delete each
// version's bytes (DOCX + PDF rendition) before dropping rows.
const { data: versions } = await db
.from("document_versions")
.select("storage_path, pdf_storage_path")
.eq("document_id", documentId);
await Promise.all(
(versions ?? []).flatMap((v) =>
[v.storage_path, v.pdf_storage_path]
.filter((p): p is string => typeof p === "string" && p.length > 0)
.map((p) => deleteFile(p).catch(() => {})),
),
);
await db.from("documents").delete().eq("id", documentId);
await deleteDocumentAndVersionFiles(db, documentId);
res.status(204).send();
});
@ -104,7 +115,7 @@ documentsRouter.get("/:documentId/display", requireAuth, async (req, res) => {
const { data: doc } = await db
.from("documents")
.select("id, filename, file_type, user_id, project_id")
.select("id, user_id, project_id")
.eq("id", documentId)
.single();
if (!doc)
@ -117,8 +128,13 @@ documentsRouter.get("/:documentId/display", requireAuth, async (req, res) => {
if (!active)
return void res.status(404).json({ detail: "No file available" });
const fileType = (doc.file_type as string) ?? "";
const fileType = active.file_type ?? "";
const isDocx = fileType === "docx" || fileType === "doc";
const displayFilename = downloadFilenameForVersion(
active.filename,
active.version_number,
active.source === "assistant_edit",
);
// For DOCX, prefer the per-version PDF rendition if one exists.
const servePath =
@ -135,7 +151,7 @@ documentsRouter.get("/:documentId/display", requireAuth, async (req, res) => {
res.setHeader("Content-Type", "application/pdf");
res.setHeader(
"Content-Disposition",
buildContentDisposition("inline", doc.filename as string),
buildContentDisposition("inline", displayFilename),
);
res.send(Buffer.from(raw));
} else {
@ -146,7 +162,7 @@ documentsRouter.get("/:documentId/display", requireAuth, async (req, res) => {
);
res.setHeader(
"Content-Disposition",
buildContentDisposition("inline", doc.filename as string),
buildContentDisposition("inline", displayFilename),
);
res.send(Buffer.from(raw));
}
@ -164,7 +180,7 @@ documentsRouter.post("/download-zip", requireAuth, async (req, res) => {
const db = createServerSupabase();
const { data: rawDocs, error } = await db
.from("documents")
.select("id, filename, file_type, current_version_id, user_id, project_id")
.select("id, current_version_id, user_id, project_id")
.in("id", document_ids);
if (error) return void res.status(500).json({ detail: error.message });
@ -182,7 +198,7 @@ documentsRouter.post("/download-zip", requireAuth, async (req, res) => {
);
const docs = accessChecks
.filter((x) => x.access.ok)
.map((x) => x.doc as { id: string; filename: string });
.map((x) => x.doc as { id: string });
if (!docs || docs.length === 0)
return void res.status(404).json({ detail: "No documents found" });
@ -195,7 +211,14 @@ documentsRouter.post("/download-zip", requireAuth, async (req, res) => {
if (!active) return;
const raw = await downloadFile(active.storage_path);
if (!raw) return;
zip.file(doc.filename, Buffer.from(raw));
zip.file(
downloadFilenameForVersion(
active.filename,
active.version_number,
active.source === "assistant_edit",
),
Buffer.from(raw),
);
}),
);
@ -217,7 +240,7 @@ documentsRouter.get("/:documentId/url", requireAuth, async (req, res) => {
const { data: doc, error } = await db
.from("documents")
.select("id, filename, user_id, project_id")
.select("id, user_id, project_id")
.eq("id", documentId)
.single();
if (error || !doc)
@ -230,10 +253,10 @@ documentsRouter.get("/:documentId/url", requireAuth, async (req, res) => {
if (!active)
return void res.status(404).json({ detail: "No file available" });
const downloadFilename = resolveDownloadFilename(
doc.filename as string,
active.display_name,
const downloadFilename = downloadFilenameForVersion(
active.filename,
active.version_number,
active.source === "assistant_edit",
);
const url = await getSignedUrl(
active.storage_path,
@ -268,7 +291,7 @@ documentsRouter.get("/:documentId/docx", requireAuth, async (req, res) => {
const { data: doc, error } = await db
.from("documents")
.select("id, filename, user_id, project_id")
.select("id, user_id, project_id")
.eq("id", documentId)
.single();
if (error || !doc)
@ -293,51 +316,29 @@ documentsRouter.get("/:documentId/docx", requireAuth, async (req, res) => {
"Content-Disposition",
buildContentDisposition(
"inline",
resolveDownloadFilename(
doc.filename as string,
active.display_name,
downloadFilenameForVersion(
active.filename,
active.version_number,
active.source === "assistant_edit",
),
),
);
res.send(Buffer.from(raw));
});
// Compose a download-friendly filename that carries the edit version
// marker: "Purchase Agreement.docx" → "Purchase Agreement [Edited V2].docx".
// Preserves the original extension (fallback: .docx).
function versionedFilename(filename: string, version: number | null): string {
if (!version || version < 1) return filename;
const dot = filename.lastIndexOf(".");
const stem = dot > 0 ? filename.slice(0, dot) : filename;
const ext = dot > 0 ? filename.slice(dot) : ".docx";
return `${stem} [Edited V${version}]${ext}`;
}
// Produce the filename a download should present to the user for a given
// (document, version) pair. Prefers the version's display_name (appending
// the original extension if the user didn't include one), falling back to
// the versionedFilename heuristic.
function resolveDownloadFilename(
originalFilename: string,
displayName: string | null | undefined,
// Produce the filename a download should present to the user. Version
// filenames are expected to include the real extension.
function downloadFilenameForVersion(
filename: string | null | undefined,
versionNumber: number | null,
edited = false,
): string {
const dot = originalFilename.lastIndexOf(".");
const origExt = dot > 0 ? originalFilename.slice(dot) : "";
if (displayName && displayName.trim()) {
const trimmed = displayName.trim();
const trimmedDot = trimmed.lastIndexOf(".");
const hasExt =
trimmedDot > 0 &&
trimmed
.slice(trimmedDot)
.toLowerCase()
.match(/^\.[a-z0-9]{1,6}$/);
if (hasExt) return trimmed;
return origExt ? `${trimmed}${origExt}` : trimmed;
}
return versionedFilename(originalFilename, versionNumber);
const resolved = filename?.trim() || "Untitled document.docx";
if (!edited || !versionNumber || versionNumber < 1) return resolved;
const dot = resolved.lastIndexOf(".");
const stem = dot > 0 ? resolved.slice(0, dot) : resolved;
const ext = dot > 0 ? resolved.slice(dot) : "";
return `${stem} [Edited V${versionNumber}]${ext}`;
}
// GET /single-documents/:documentId/versions
@ -362,7 +363,9 @@ documentsRouter.get("/:documentId/versions", requireAuth, async (req, res) => {
const { data: rows } = await db
.from("document_versions")
.select("id, version_number, source, created_at, display_name")
.select(
"id, version_number, source, created_at, filename, file_type, size_bytes, page_count",
)
.eq("document_id", documentId)
.order("created_at", { ascending: true });
@ -372,10 +375,204 @@ documentsRouter.get("/:documentId/versions", requireAuth, async (req, res) => {
});
});
// POST /single-documents/:documentId/versions/from-document
// Create a new version of documentId from another existing document's active
// bytes. This keeps signed storage URLs out of the browser fetch path.
documentsRouter.post(
"/:documentId/versions/from-document",
requireAuth,
async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { documentId } = req.params;
const sourceDocumentId =
typeof req.body?.source_document_id === "string"
? req.body.source_document_id
: "";
const db = createServerSupabase();
if (!sourceDocumentId) {
return void res
.status(400)
.json({ detail: "source_document_id is required" });
}
if (sourceDocumentId === documentId) {
return void res
.status(400)
.json({ detail: "Source and target documents must be different." });
}
const { data: targetDoc } = await db
.from("documents")
.select("id, user_id, project_id")
.eq("id", documentId)
.single();
if (!targetDoc)
return void res.status(404).json({ detail: "Document not found" });
const targetAccess = await ensureDocAccess(targetDoc, userId, userEmail, db);
if (!targetAccess.ok)
return void res.status(404).json({ detail: "Document not found" });
const { data: sourceDoc } = await db
.from("documents")
.select("id, user_id, project_id")
.eq("id", sourceDocumentId)
.single();
if (!sourceDoc)
return void res.status(404).json({ detail: "Source document not found" });
const sourceAccess = await ensureDocAccess(sourceDoc, userId, userEmail, db);
if (!sourceAccess.ok)
return void res.status(404).json({ detail: "Source document not found" });
const targetActive = await loadActiveVersion(documentId, db);
const targetType = targetActive?.file_type ?? "";
const active = await loadActiveVersion(sourceDocumentId, db);
if (!active)
return void res
.status(404)
.json({ detail: "Source document has no active version." });
const sourceType = active.file_type ?? "";
if (targetType && sourceType && targetType !== sourceType) {
return void res.status(400).json({
detail: `Source document type (${sourceType}) does not match document type (${targetType}).`,
});
}
const bytes = await downloadFile(active.storage_path);
if (!bytes)
return void res
.status(404)
.json({ detail: "Source document bytes not available." });
const filename =
typeof req.body?.filename === "string" && req.body.filename.trim()
? req.body.filename.trim().slice(0, 200)
: active.filename?.trim() || "Untitled document";
const suffix =
sourceType ||
(filename.includes(".") ? filename.split(".").pop()!.toLowerCase() : "");
const versionSlug = crypto.randomUUID().replace(/-/g, "");
const key = versionStorageKey(userId, documentId, versionSlug, filename);
const contentType =
suffix === "pdf"
? "application/pdf"
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
try {
await uploadFile(key, bytes, contentType);
} catch (e) {
console.error("[versions/copy] storage write failed", e);
return void res
.status(500)
.json({ detail: "Failed to create new version." });
}
let pdfStoragePath: string | null = null;
if (suffix === "pdf") {
pdfStoragePath = key;
} else if (active.pdf_storage_path) {
if (active.pdf_storage_path === active.storage_path) {
pdfStoragePath = key;
} else {
const pdfBytes = await downloadFile(active.pdf_storage_path);
if (pdfBytes) {
const pdfKey = `converted-pdfs/${userId}/${documentId}/${versionSlug}.pdf`;
await uploadFile(pdfKey, pdfBytes, "application/pdf");
pdfStoragePath = pdfKey;
}
}
} else if (suffix === "docx" || suffix === "doc") {
try {
const pdfBuf = await docxToPdf(Buffer.from(bytes));
const pdfKey = `converted-pdfs/${userId}/${documentId}/${versionSlug}.pdf`;
await uploadFile(
pdfKey,
pdfBuf.buffer.slice(
pdfBuf.byteOffset,
pdfBuf.byteOffset + pdfBuf.byteLength,
) as ArrayBuffer,
"application/pdf",
);
pdfStoragePath = pdfKey;
} catch (err) {
console.error(
`[versions/copy] DOCX→PDF conversion failed for ${filename}:`,
err,
);
}
}
const { data: maxRow } = await db
.from("document_versions")
.select("version_number")
.eq("document_id", documentId)
.in("source", ["upload", "user_upload", "assistant_edit"])
.order("version_number", { ascending: false, nullsFirst: false })
.limit(1)
.maybeSingle();
const nextVersionNumber =
((maxRow?.version_number as number | null) ?? 1) + 1;
const { data: versionRow, error: verErr } = await db
.from("document_versions")
.insert({
document_id: documentId,
storage_path: key,
pdf_storage_path: pdfStoragePath,
source: "user_upload",
version_number: nextVersionNumber,
filename: filename,
file_type: sourceType || null,
size_bytes: active.size_bytes ?? bytes.byteLength,
page_count: active.page_count,
})
.select("id, version_number, source, created_at, filename")
.single();
if (verErr || !versionRow) {
console.error("[versions/copy] insert failed", verErr);
return void res
.status(500)
.json({ detail: "Failed to record new version." });
}
const { error: updateDocErr } = await db
.from("documents")
.update({
current_version_id: versionRow.id,
})
.eq("id", documentId);
if (updateDocErr) {
console.error("[versions/copy] current version update failed", updateDocErr);
return void res
.status(500)
.json({ detail: "Failed to update document current version." });
}
if (
sourceDoc.project_id &&
targetDoc.project_id &&
sourceDoc.project_id === targetDoc.project_id
) {
const { error: deleteErr } = await deleteDocumentAndVersionFiles(
db,
sourceDocumentId,
);
if (deleteErr) {
console.error("[versions/copy] source document delete failed", deleteErr);
return void res
.status(500)
.json({ detail: "Failed to delete source document." });
}
}
res.status(201).json(versionRow);
},
);
// POST /single-documents/:documentId/versions
// Upload a brand-new version of an existing document. The uploaded file
// becomes the new current_version_id. display_name defaults to the
// uploaded filename; client may override via the `display_name` form field.
// becomes the new current_version_id. filename defaults to the
// uploaded filename; client may override via the `filename` form field.
documentsRouter.post(
"/:documentId/versions",
requireAuth,
@ -392,7 +589,7 @@ documentsRouter.post(
const { data: doc } = await db
.from("documents")
.select("id, filename, file_type, user_id, project_id")
.select("id, user_id, project_id, current_version_id")
.eq("id", documentId)
.single();
if (!doc)
@ -406,9 +603,17 @@ documentsRouter.post(
const suffix = file.originalname.includes(".")
? file.originalname.split(".").pop()!.toLowerCase()
: "";
if (doc.file_type && suffix && doc.file_type !== suffix) {
if (!ALLOWED_TYPES.has(suffix)) {
return void res.status(400).json({
detail: `Uploaded file type (${suffix}) does not match document type (${doc.file_type}).`,
detail: `Unsupported file type: ${suffix}. Allowed: pdf, docx, doc`,
});
}
const currentActive = await loadActiveVersion(documentId, db);
const expectedType = currentActive?.file_type ?? "";
if (expectedType && expectedType !== suffix) {
return void res.status(400).json({
detail: `Uploaded file type (${suffix}) does not match document type (${expectedType}).`,
});
}
@ -469,6 +674,12 @@ documentsRouter.post(
pdfStoragePath = key;
}
const rawBuf = file.buffer.buffer.slice(
file.buffer.byteOffset,
file.buffer.byteOffset + file.buffer.byteLength,
) as ArrayBuffer;
const pageCount = suffix === "pdf" ? await countPdfPages(rawBuf) : null;
// Per-document sequential version_number — the upload is V1 and
// user_upload + assistant_edit count forward from there.
const { data: maxRow } = await db
@ -482,10 +693,10 @@ documentsRouter.post(
const nextVersionNumber =
((maxRow?.version_number as number | null) ?? 1) + 1;
const defaultDisplayName =
typeof req.body?.display_name === "string" &&
req.body.display_name.trim()
? req.body.display_name.trim().slice(0, 200)
const requestedFilename =
typeof req.body?.filename === "string" &&
req.body.filename.trim()
? req.body.filename.trim().slice(0, 200)
: file.originalname;
const { data: versionRow, error: verErr } = await db
@ -496,9 +707,12 @@ documentsRouter.post(
pdf_storage_path: pdfStoragePath,
source: "user_upload",
version_number: nextVersionNumber,
display_name: defaultDisplayName,
filename: requestedFilename,
file_type: suffix,
size_bytes: file.buffer.byteLength,
page_count: pageCount,
})
.select("id, version_number, source, created_at, display_name")
.select("id, version_number, source, created_at, filename")
.single();
if (verErr || !versionRow) {
console.error("[versions/upload] insert failed", verErr);
@ -507,30 +721,11 @@ documentsRouter.post(
.json({ detail: "Failed to record new version." });
}
// Also propagate the user-provided display_name to the parent document's
// filename so the document's display name stays in sync across the UI.
// Preserve a sensible extension: if the display_name has none, append
// the uploaded file's extension (fallback: the existing doc's extension).
const documentsUpdate: Record<string, unknown> = {
current_version_id: versionRow.id,
};
const providedDisplayName =
typeof req.body?.display_name === "string" &&
req.body.display_name.trim()
? req.body.display_name.trim().slice(0, 200)
: null;
if (providedDisplayName) {
const hasExt = /\.[a-z0-9]{1,6}$/i.test(providedDisplayName);
const existingExt = (doc.filename as string | null)?.match(
/\.[a-z0-9]{1,6}$/i,
)?.[0];
const uploadedExt = suffix ? `.${suffix}` : "";
const ext = hasExt ? "" : uploadedExt || existingExt || "";
documentsUpdate.filename = `${providedDisplayName}${ext}`;
}
await db
.from("documents")
.update(documentsUpdate)
.update({
current_version_id: versionRow.id,
})
.eq("id", documentId);
res.status(201).json(versionRow);
@ -538,8 +733,7 @@ documentsRouter.post(
);
// PATCH /single-documents/:documentId/versions/:versionId
// Rename a version's display_name. Pass `{ "display_name": "…" }`; an empty
// or missing value clears the override so the UI falls back to V{n}.
// Rename a version's filename. Pass `{ "filename": "…" }`.
documentsRouter.patch(
"/:documentId/versions/:versionId",
requireAuth,
@ -560,16 +754,18 @@ documentsRouter.patch(
if (!access.ok)
return void res.status(404).json({ detail: "Document not found" });
const raw = req.body?.display_name;
const displayName =
const raw = req.body?.filename;
const filename =
typeof raw === "string" && raw.trim() ? raw.trim().slice(0, 200) : null;
const { data: updated, error } = await db
.from("document_versions")
.update({ display_name: displayName })
.update({ filename })
.eq("id", versionId)
.eq("document_id", documentId)
.select("id, version_number, source, created_at, display_name")
.select(
"id, version_number, source, created_at, filename, file_type, size_bytes, page_count",
)
.single();
if (error || !updated) {
return void res.status(404).json({ detail: "Version not found" });
@ -578,6 +774,104 @@ documentsRouter.patch(
},
);
// DELETE /single-documents/:documentId/versions/:versionId
// Delete one version. The last remaining version cannot be deleted; if the
// deleted version is current, the newest remaining version becomes current.
documentsRouter.delete(
"/:documentId/versions/:versionId",
requireAuth,
async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { documentId, versionId } = req.params;
const db = createServerSupabase();
const { data: doc } = await db
.from("documents")
.select("id, user_id, project_id, current_version_id")
.eq("id", documentId)
.single();
if (!doc)
return void res.status(404).json({ detail: "Document not found" });
const access = await ensureDocAccess(doc, userId, userEmail, db);
if (!access.ok || !access.isOwner)
return void res.status(404).json({ detail: "Document not found" });
const { data: versions, error: versionsErr } = await db
.from("document_versions")
.select("id, storage_path, pdf_storage_path, version_number, created_at")
.eq("document_id", documentId);
if (versionsErr) {
return void res.status(500).json({ detail: versionsErr.message });
}
const rows = (versions ?? []) as {
id: string;
storage_path: string | null;
pdf_storage_path: string | null;
version_number: number | null;
created_at: string | null;
}[];
const target = rows.find((row) => row.id === versionId);
if (!target)
return void res.status(404).json({ detail: "Version not found" });
if (rows.length <= 1) {
return void res
.status(400)
.json({ detail: "Cannot delete the only document version." });
}
const remaining = rows
.filter((row) => row.id !== versionId)
.sort((a, b) => {
const versionDelta =
(b.version_number ?? -1) - (a.version_number ?? -1);
if (versionDelta !== 0) return versionDelta;
return (
new Date(b.created_at ?? 0).getTime() -
new Date(a.created_at ?? 0).getTime()
);
});
const nextCurrentVersionId =
doc.current_version_id === versionId
? (remaining[0]?.id ?? null)
: doc.current_version_id;
if (doc.current_version_id === versionId) {
const { error: updateErr } = await db
.from("documents")
.update({
current_version_id: nextCurrentVersionId,
updated_at: new Date().toISOString(),
})
.eq("id", documentId);
if (updateErr) {
return void res.status(500).json({ detail: updateErr.message });
}
}
const { error: deleteErr } = await db
.from("document_versions")
.delete()
.eq("id", versionId)
.eq("document_id", documentId);
if (deleteErr) {
return void res.status(500).json({ detail: deleteErr.message });
}
await Promise.all(
[target.storage_path, target.pdf_storage_path]
.filter((path): path is string => !!path)
.map((path) => deleteFile(path).catch(() => {})),
);
res.json({
deleted_version_id: versionId,
current_version_id: nextCurrentVersionId,
});
},
);
// GET /single-documents/:documentId/tracked-change-ids
// Returns the ordered list of { kind, w_id } for every w:ins / w:del in
// the current (or specified) version's document.xml. The frontend uses
@ -632,7 +926,7 @@ async function handleEditResolution(
const { documentId, editId } = req.params;
const db = createServerSupabase();
console.log(`[edit-resolution] incoming ${mode}`, {
devLog(`[edit-resolution] incoming ${mode}`, {
userId,
documentId,
editId,
@ -644,31 +938,31 @@ async function handleEditResolution(
.eq("id", editId)
.eq("document_id", documentId)
.single();
console.log(`[edit-resolution] fetched edit row`, { edit, editErr });
devLog(`[edit-resolution] fetched edit row`, { edit, editErr });
if (!edit) {
console.log(`[edit-resolution] edit not found, returning 404`);
devLog(`[edit-resolution] edit not found, returning 404`);
return void res.status(404).json({ detail: "Edit not found" });
}
// Idempotent: if the edit is already resolved, return the current doc
// state so stale UI (e.g. an old chat reloaded in a new session) can
// reconcile without throwing.
if (edit.status !== "pending") {
console.log(`[edit-resolution] edit already resolved`, {
devLog(`[edit-resolution] edit already resolved`, {
editId,
status: edit.status,
});
const { data: doc } = await db
.from("documents")
.select("current_version_id, filename, user_id, project_id")
.select("current_version_id, user_id, project_id")
.eq("id", documentId)
.single();
if (!doc) {
console.log(`[edit-resolution] doc not found for resolved edit`);
devLog(`[edit-resolution] doc not found for resolved edit`);
return void res.status(404).json({ detail: "Document not found" });
}
const accessResolved = await ensureDocAccess(doc, userId, userEmail, db);
if (!accessResolved.ok) {
console.log(`[edit-resolution] doc access denied for resolved edit`);
devLog(`[edit-resolution] doc access denied for resolved edit`);
return void res.status(404).json({ detail: "Document not found" });
}
const activeForResolved = await loadActiveVersion(documentId, db);
@ -680,12 +974,16 @@ async function handleEditResolution(
download_url: activeForResolved
? buildDownloadUrl(
activeForResolved.storage_path,
(doc.filename as string) ?? "document.docx",
downloadFilenameForVersion(
activeForResolved.filename,
activeForResolved.version_number,
activeForResolved.source === "assistant_edit",
),
)
: null,
remaining_pending: 0,
};
console.log(`[edit-resolution] returning already-resolved payload`, payload);
devLog(`[edit-resolution] returning already-resolved payload`, payload);
return void res.status(200).json(payload);
}
@ -694,7 +992,7 @@ async function handleEditResolution(
.select("id, current_version_id, user_id, project_id")
.eq("id", documentId)
.single();
console.log(`[edit-resolution] fetched doc`, { doc, docErr });
devLog(`[edit-resolution] fetched doc`, { doc, docErr });
if (!doc)
return void res.status(404).json({ detail: "Document not found" });
const access = await ensureDocAccess(doc, userId, userEmail, db);
@ -703,7 +1001,7 @@ async function handleEditResolution(
const active = await loadActiveVersion(documentId, db);
const latestPath = active?.storage_path ?? null;
console.log(`[edit-resolution] resolved latestPath`, {
devLog(`[edit-resolution] resolved latestPath`, {
latestPath,
current_version_id: doc.current_version_id,
});
@ -711,7 +1009,7 @@ async function handleEditResolution(
return void res.status(404).json({ detail: "No file to edit" });
const raw = await downloadFile(latestPath);
console.log(`[edit-resolution] downloaded bytes`, {
devLog(`[edit-resolution] downloaded bytes`, {
byteLength: raw?.byteLength ?? 0,
});
if (!raw)
@ -725,7 +1023,7 @@ async function handleEditResolution(
wIds,
mode,
);
console.log(`[edit-resolution] resolveTrackedChange result`, {
devLog(`[edit-resolution] resolveTrackedChange result`, {
mode,
change_id: edit.change_id,
wIds,
@ -733,7 +1031,7 @@ async function handleEditResolution(
resolvedByteLength: resolvedBytes?.byteLength ?? 0,
});
if (!found) {
console.log(
devLog(
`[edit-resolution] change_id not found in docx — updating status only`,
);
// Still update DB status so the UI reflects the decision — the change
@ -742,22 +1040,21 @@ async function handleEditResolution(
.from("document_edits")
.update({ status: mode === "accept" ? "accepted" : "rejected", resolved_at: new Date().toISOString() })
.eq("id", editId);
console.log(`[edit-resolution] status-only update`, { updErr });
const { data: filenameRow } = await db
.from("documents")
.select("filename")
.eq("id", documentId)
.single();
devLog(`[edit-resolution] status-only update`, { updErr });
const payload = {
ok: true,
version_id: doc.current_version_id,
download_url: buildDownloadUrl(
latestPath,
(filenameRow?.filename as string) ?? "document.docx",
downloadFilenameForVersion(
active?.filename,
active?.version_number ?? null,
active?.source === "assistant_edit",
),
),
remaining_pending: 0,
};
console.log(`[edit-resolution] returning not-found payload`, payload);
devLog(`[edit-resolution] returning not-found payload`, payload);
return void res.status(200).json(payload);
}
@ -770,7 +1067,7 @@ async function handleEditResolution(
resolvedBytes.byteOffset,
resolvedBytes.byteOffset + resolvedBytes.byteLength,
) as ArrayBuffer;
console.log(`[edit-resolution] overwriting bytes in place`, {
devLog(`[edit-resolution] overwriting bytes in place`, {
latestPath,
byteLength: ab.byteLength,
});
@ -787,7 +1084,7 @@ async function handleEditResolution(
resolved_at: new Date().toISOString(),
})
.eq("id", editId);
console.log(`[edit-resolution] updated document_edits status`, {
devLog(`[edit-resolution] updated document_edits status`, {
editId,
newStatus: mode === "accept" ? "accepted" : "rejected",
statusErr,
@ -798,23 +1095,22 @@ async function handleEditResolution(
.select("id", { count: "exact", head: true })
.eq("document_id", documentId)
.eq("status", "pending");
console.log(`[edit-resolution] remaining pending count`, { remainingPending });
devLog(`[edit-resolution] remaining pending count`, { remainingPending });
const { data: filenameRow } = await db
.from("documents")
.select("filename")
.eq("id", documentId)
.single();
const payload = {
ok: true,
version_id: doc.current_version_id,
download_url: buildDownloadUrl(
latestPath,
(filenameRow?.filename as string) ?? "document.docx",
downloadFilenameForVersion(
active?.filename,
active?.version_number ?? null,
active?.source === "assistant_edit",
),
),
remaining_pending: remainingPending ?? 0,
};
console.log(`[edit-resolution] returning success payload`, payload);
devLog(`[edit-resolution] returning success payload`, payload);
res.json(payload);
}
@ -857,13 +1153,19 @@ async function handleDocumentUpload(
.insert({
project_id: projectId,
user_id: userId,
filename,
file_type: suffix,
size_bytes: content.byteLength,
status: "processing",
})
.select("*")
.single();
if (insertErr || !doc)
console.error("[single-documents/upload] failed to create document row", {
userId,
projectId,
filename,
suffix,
error: insertErr,
});
if (insertErr || !doc)
return void res
.status(500)
@ -889,7 +1191,6 @@ async function handleDocumentUpload(
content.byteOffset,
content.byteOffset + content.byteLength,
) as ArrayBuffer;
const tree = await extractStructureTree(rawBuf, suffix, filename);
const pageCount = suffix === "pdf" ? await countPdfPages(rawBuf) : null;
// Convert DOCX/DOC → PDF for display. PDFs are their own rendition.
@ -928,7 +1229,10 @@ async function handleDocumentUpload(
pdf_storage_path: pdfStoragePath,
source: "upload",
version_number: 1,
display_name: filename,
filename: filename,
file_type: suffix,
size_bytes: content.byteLength,
page_count: pageCount,
})
.select("id")
.single();
@ -942,9 +1246,6 @@ async function handleDocumentUpload(
.from("documents")
.update({
current_version_id: versionRow.id,
size_bytes: content.byteLength,
page_count: pageCount,
structure_tree: tree ?? null,
status: "ready",
updated_at: new Date().toISOString(),
})
@ -957,7 +1258,16 @@ async function handleDocumentUpload(
.single();
// Surface storage paths to the caller for backward compatibility.
const responseDoc = updated
? { ...updated, storage_path: key, pdf_storage_path: pdfStoragePath }
? {
...updated,
filename,
storage_path: key,
pdf_storage_path: pdfStoragePath,
file_type: suffix,
size_bytes: content.byteLength,
page_count: pageCount,
active_version_number: 1,
}
: updated;
return void res.status(201).json(responseDoc);
} catch (e) {
@ -983,62 +1293,3 @@ async function countPdfPages(buf: ArrayBuffer): Promise<number | null> {
return null;
}
}
async function extractStructureTree(
content: ArrayBuffer,
fileType: string,
_filename: string,
): Promise<unknown[] | null> {
try {
if (fileType === "pdf") {
const pdfjsLib = await import(
"pdfjs-dist/legacy/build/pdf.mjs" as string
);
const pdf = await (
pdfjsLib as unknown as {
getDocument: (opts: unknown) => {
promise: Promise<{
numPages: number;
getOutline: () => Promise<{ title?: string }[]>;
}>;
};
}
).getDocument({ data: new Uint8Array(content) }).promise;
if (pdf.numPages <= 5) return null;
const outline = await pdf.getOutline();
if (outline?.length)
return outline.map((item, i) => ({
id: `h1-${i}`,
title: item.title ?? `Item ${i + 1}`,
level: 1,
page_number: null,
children: [],
}));
return Array.from({ length: pdf.numPages }, (_, i) => ({
id: `page-${i + 1}`,
title: `Page ${i + 1}`,
level: 1,
page_number: i + 1,
children: [],
}));
} else {
const mammoth = await import("mammoth");
const result = await mammoth.extractRawText({
buffer: Buffer.from(content),
});
const lines = result.value.split("\n").filter((l) => l.trim());
const nodes = lines
.slice(0, 30)
.map((line, i) => ({
id: `h1-${i}`,
title: line.slice(0, 100),
level: 1,
page_number: null,
children: [],
}));
return nodes.length ? nodes : null;
}
} catch {
return null;
}
}

View file

@ -6,8 +6,11 @@ import {
buildMessages,
buildWorkflowStore,
enrichWithPriorEvents,
AssistantStreamError,
extractAnnotations,
isAbortError,
runLLMStream,
stripTransientAssistantEvents,
PROJECT_EXTRA_TOOLS,
type ChatMessage,
} from "../lib/chatTools";
@ -151,13 +154,18 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
res.flushHeaders();
const write = (line: string) => res.write(line);
const streamAbort = new AbortController();
let streamFinished = false;
res.on("close", () => {
if (!streamFinished) streamAbort.abort();
});
const apiKeys = await getUserApiKeys(userId, db);
try {
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);
const { fullText, events } = await runLLMStream({
const { events, annotations } = await runLLMStream({
apiMessages,
docStore,
docIndex,
@ -168,14 +176,15 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
workflowStore,
model,
apiKeys,
signal: streamAbort.signal,
projectId,
});
const annotations = extractAnnotations(fullText, docIndex, events);
const persistedEvents = stripTransientAssistantEvents(events);
await db.from("chat_messages").insert({
chat_id: chatId,
role: "assistant",
content: events.length ? events : null,
content: persistedEvents.length ? persistedEvents : null,
annotations: annotations.length ? annotations : null,
});
@ -186,16 +195,47 @@ projectChatRouter.post("/", requireAuth, async (req, res) => {
.eq("id", chatId);
}
} catch (err) {
if (isAbortError(err)) {
console.log("[project-chat/stream] client aborted stream", {
chatId,
});
return;
}
console.error("[project-chat/stream] error:", err);
const message =
err instanceof Error && err.message ? err.message : "Stream error";
const errorEvents = err instanceof AssistantStreamError
? stripTransientAssistantEvents(err.events)
: [{ type: "error" as const, message }];
const errorFullText =
err instanceof AssistantStreamError ? err.fullText : "";
try {
const annotations = extractAnnotations(
errorFullText,
docIndex,
errorEvents,
);
const { error: saveError } = await db.from("chat_messages").insert({
chat_id: chatId,
role: "assistant",
content: errorEvents.length ? errorEvents : null,
annotations: annotations.length ? annotations : null,
});
if (saveError)
console.error("[project-chat/stream] failed to save error", saveError);
} catch (saveErr) {
console.error("[project-chat/stream] failed to save error", saveErr);
}
try {
write(
`data: ${JSON.stringify({ type: "error", message: "Stream error" })}\n\n`,
`data: ${JSON.stringify({ type: "error", message })}\n\n`,
);
write("data: [DONE]\n\n");
} catch {
/* ignore */
}
} finally {
streamFinished = true;
res.end();
}
});

View file

@ -6,7 +6,12 @@ import {
attachActiveVersionPaths,
attachLatestVersionNumbers,
} from "../lib/documentVersions";
import { downloadFile, uploadFile, storageKey } from "../lib/storage";
import {
deleteFile,
downloadFile,
uploadFile,
storageKey,
} from "../lib/storage";
import { docxToPdf, convertedPdfKey } from "../lib/convert";
import { checkProjectAccess } from "../lib/access";
import { singleFileUpload } from "../lib/upload";
@ -367,6 +372,10 @@ projectsRouter.post(
.single();
if (!doc)
return void res.status(404).json({ detail: "Document not found" });
await attachActiveVersionPaths(
db,
[doc as { id: string; current_version_id?: string | null }],
);
// Already in this project — idempotent
if (doc.project_id === projectId) return void res.json(doc);
@ -381,22 +390,49 @@ projectsRouter.post(
.single();
if (error || !updated)
return void res.status(500).json({ detail: "Failed to update document" });
await attachActiveVersionPaths(
db,
[updated as { id: string; current_version_id?: string | null }],
);
return void res.json(updated);
} else {
// Belongs to another project → duplicate record AND copy the
// underlying storage objects so each project's copy is fully
// independent (edits/version bumps on one don't leak into the
// other).
if (!doc.current_version_id) {
return void res
.status(404)
.json({ detail: "Source document has no active version" });
}
const { data: srcV } = await db
.from("document_versions")
.select(
"storage_path, pdf_storage_path, version_number, filename, source, file_type, size_bytes, page_count",
)
.eq("id", doc.current_version_id)
.single();
if (!srcV?.storage_path) {
return void res
.status(404)
.json({ detail: "Source document has no active version" });
}
const activeVersionFilename =
(srcV.filename as string | null)?.trim() || "Untitled document";
const srcBytes = await downloadFile(srcV.storage_path);
if (!srcBytes) {
return void res
.status(500)
.json({ detail: "Failed to read source document bytes" });
}
const { data: copy, error } = await db
.from("documents")
.insert({
project_id: projectId,
user_id: userId,
filename: doc.filename,
file_type: doc.file_type,
size_bytes: doc.size_bytes,
page_count: doc.page_count,
structure_tree: doc.structure_tree,
status: doc.status,
})
.select("*")
@ -404,69 +440,90 @@ projectsRouter.post(
if (error || !copy)
return void res.status(500).json({ detail: "Failed to copy document" });
let copyVersionRowId: string | null = null;
if (doc.current_version_id) {
const { data: srcV } = await db
.from("document_versions")
.select(
"storage_path, pdf_storage_path, version_number, display_name, source",
)
.eq("id", doc.current_version_id)
.single();
if (srcV?.storage_path) {
const srcBytes = await downloadFile(srcV.storage_path);
if (!srcBytes) {
return void res
.status(500)
.json({ detail: "Failed to read source document bytes" });
}
const newKey = storageKey(userId, copy.id as string, doc.filename);
const contentType =
doc.file_type === "pdf"
? "application/pdf"
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
await uploadFile(newKey, srcBytes, contentType);
const newKey = storageKey(
userId,
copy.id as string,
activeVersionFilename,
);
let newPdfPath: string | null = null;
try {
const contentType =
((srcV.file_type as string | null) ?? doc.file_type) === "pdf"
? "application/pdf"
: "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
await uploadFile(newKey, srcBytes, contentType);
// PDFs share one object for source + display rendition. DOCX
// store the converted PDF at a separate `converted-pdfs/` key —
// copy that too if it exists so the copy renders without going
// back through libreoffice.
let newPdfPath: string | null = null;
if (srcV.pdf_storage_path) {
if (srcV.pdf_storage_path === srcV.storage_path) {
newPdfPath = newKey;
} else {
const pdfBytes = await downloadFile(srcV.pdf_storage_path);
if (pdfBytes) {
const newPdfKey = convertedPdfKey(userId, copy.id as string);
await uploadFile(newPdfKey, pdfBytes, "application/pdf");
newPdfPath = newPdfKey;
}
// PDFs share one object for source + display rendition. DOCX
// store the converted PDF at a separate `converted-pdfs/` key —
// copy that too if it exists so the copy renders without going
// back through libreoffice.
if (srcV.pdf_storage_path) {
if (srcV.pdf_storage_path === srcV.storage_path) {
newPdfPath = newKey;
} else {
const pdfBytes = await downloadFile(srcV.pdf_storage_path);
if (pdfBytes) {
const newPdfKey = convertedPdfKey(userId, copy.id as string);
await uploadFile(newPdfKey, pdfBytes, "application/pdf");
newPdfPath = newPdfKey;
}
}
const { data: newV } = await db
.from("document_versions")
.insert({
document_id: copy.id,
storage_path: newKey,
pdf_storage_path: newPdfPath,
source: (srcV.source as string | null) ?? "upload",
version_number: srcV.version_number ?? 1,
display_name: srcV.display_name ?? doc.filename,
})
.select("id")
.single();
copyVersionRowId = (newV?.id as string | null) ?? null;
if (copyVersionRowId) {
await db
.from("documents")
.update({ current_version_id: copyVersionRowId })
.eq("id", copy.id);
}
}
const { data: newV, error: newVError } = await db
.from("document_versions")
.insert({
document_id: copy.id,
storage_path: newKey,
pdf_storage_path: newPdfPath,
source: (srcV.source as string | null) ?? "upload",
version_number: srcV.version_number ?? 1,
filename: activeVersionFilename,
file_type: (srcV.file_type as string | null) ?? doc.file_type,
size_bytes:
(srcV.size_bytes as number | null) ?? doc.size_bytes ?? null,
page_count:
(srcV.page_count as number | null) ?? doc.page_count ?? null,
})
.select("id")
.single();
const copyVersionRowId = (newV?.id as string | null) ?? null;
if (newVError || !copyVersionRowId) {
throw new Error(
`Failed to create copied document version: ${newVError?.message ?? "unknown"}`,
);
}
const { data: updatedCopy, error: updateCopyError } = await db
.from("documents")
.update({
current_version_id: copyVersionRowId,
})
.eq("id", copy.id)
.select("*")
.single();
if (updateCopyError || !updatedCopy) {
throw new Error(
`Failed to activate copied document version: ${updateCopyError?.message ?? "unknown"}`,
);
}
await attachActiveVersionPaths(
db,
[updatedCopy as { id: string; current_version_id?: string | null }],
);
return void res.status(201).json(updatedCopy);
} catch (err) {
console.error("[projects/documents/copy] failed", err);
await Promise.all([
deleteFile(newKey).catch(() => {}),
newPdfPath && newPdfPath !== newKey
? deleteFile(newPdfPath).catch(() => {})
: Promise.resolve(),
db.from("documents").delete().eq("id", copy.id),
]);
return void res.status(500).json({ detail: "Failed to copy document" });
}
return void res.status(201).json(copy);
}
},
);
@ -484,20 +541,33 @@ projectsRouter.patch("/:projectId/documents/:documentId", requireAuth, async (re
const { data: doc } = await db
.from("documents")
.select("id, filename, current_version_id")
.select("id, current_version_id")
.eq("id", documentId)
.eq("project_id", projectId)
.single();
if (!doc)
return void res.status(404).json({ detail: "Document not found" });
const filename = normalizeDocumentFilename(req.body?.filename, doc.filename as string);
const active = doc.current_version_id
? await db
.from("document_versions")
.select("filename")
.eq("id", doc.current_version_id)
.eq("document_id", documentId)
.single()
: null;
const currentName =
typeof active?.data?.filename === "string" &&
active.data.filename.trim()
? active.data.filename.trim()
: "Untitled document";
const filename = normalizeDocumentFilename(req.body?.filename, currentName);
if (!filename)
return void res.status(400).json({ detail: "filename is required" });
const { data: updated, error } = await db
.from("documents")
.update({ filename, updated_at: new Date().toISOString() })
.update({ updated_at: new Date().toISOString() })
.eq("id", documentId)
.eq("project_id", projectId)
.select("*")
@ -508,12 +578,15 @@ projectsRouter.patch("/:projectId/documents/:documentId", requireAuth, async (re
if (doc.current_version_id) {
await db
.from("document_versions")
.update({ display_name: filename })
.update({ filename })
.eq("id", doc.current_version_id)
.eq("document_id", documentId);
}
res.json(updated);
res.json({
...updated,
filename,
});
});
// POST /projects/:projectId/documents
@ -714,9 +787,6 @@ export async function handleDocumentUpload(
.insert({
project_id: projectId,
user_id: userId,
filename,
file_type: suffix,
size_bytes: content.byteLength,
status: "processing",
})
.select("*")
@ -747,7 +817,6 @@ export async function handleDocumentUpload(
content.byteOffset,
content.byteOffset + content.byteLength,
) as ArrayBuffer;
const tree = await extractStructureTree(rawBuf, suffix, filename);
const pageCount = suffix === "pdf" ? await countPdfPages(rawBuf) : null;
// Convert DOCX/DOC → PDF for display. PDFs are their own rendition.
@ -785,7 +854,10 @@ export async function handleDocumentUpload(
pdf_storage_path: pdfStoragePath,
source: "upload",
version_number: 1,
display_name: filename,
filename,
file_type: suffix,
size_bytes: content.byteLength,
page_count: pageCount,
})
.select("id")
.single();
@ -799,9 +871,6 @@ export async function handleDocumentUpload(
.from("documents")
.update({
current_version_id: versionRow.id,
size_bytes: content.byteLength,
page_count: pageCount,
structure_tree: tree ?? null,
status: "ready",
updated_at: new Date().toISOString(),
})
@ -813,10 +882,15 @@ export async function handleDocumentUpload(
.eq("id", docId)
.single();
const responseDoc = updated
? {
? {
...updated,
filename,
storage_path: key,
pdf_storage_path: pdfStoragePath,
file_type: suffix,
size_bytes: content.byteLength,
page_count: pageCount,
active_version_number: 1,
}
: updated;
return void res.status(201).json(responseDoc);
@ -843,63 +917,3 @@ async function countPdfPages(buf: ArrayBuffer): Promise<number | null> {
return null;
}
}
async function extractStructureTree(
content: ArrayBuffer,
fileType: string,
filename: string,
): Promise<unknown[] | null> {
try {
if (fileType === "pdf") {
const pdfjsLib = await import(
"pdfjs-dist/legacy/build/pdf.mjs" as string
);
const pdf = await (
pdfjsLib as unknown as {
getDocument: (opts: unknown) => {
promise: Promise<{
numPages: number;
getOutline: () => Promise<{ title?: string }[]>;
}>;
};
}
).getDocument({ data: new Uint8Array(content) }).promise;
if (pdf.numPages <= 5) return null;
const outline = await pdf.getOutline();
if (outline?.length) {
return outline.map((item, i) => ({
id: `h1-${i}`,
title: item.title ?? `Item ${i + 1}`,
level: 1,
page_number: null,
children: [],
}));
}
return Array.from({ length: pdf.numPages }, (_, i) => ({
id: `page-${i + 1}`,
title: `Page ${i + 1}`,
level: 1,
page_number: i + 1,
children: [],
}));
} else {
const mammoth = await import("mammoth");
const result = await mammoth.extractRawText({
buffer: Buffer.from(content),
});
const lines = result.value.split("\n").filter((l) => l.trim());
const nodes = lines
.slice(0, 30)
.map((line, i) => ({
id: `h1-${i}`,
title: line.slice(0, 100),
level: 1,
page_number: null,
children: [],
}));
return nodes.length ? nodes : null;
}
} catch {
return null;
}
}

View file

@ -2,10 +2,16 @@ import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { createServerSupabase } from "../lib/supabase";
import { downloadFile } from "../lib/storage";
import { loadActiveVersion } from "../lib/documentVersions";
import {
attachActiveVersionPaths,
loadActiveVersion,
} from "../lib/documentVersions";
import { normalizeDocxZipPaths } from "../lib/convert";
import {
AssistantStreamError,
isAbortError,
runLLMStream,
stripTransientAssistantEvents,
TABULAR_TOOLS,
type ChatMessage,
type TabularCellStore,
@ -370,6 +376,11 @@ tabularRouter.get("/:reviewId", requireAuth, async (req, res) => {
docIds.length > 0
? await db.from("documents").select("*").in("id", docIds)
: { data: [] as Record<string, unknown>[] };
const docs = (docsResult.data ?? []) as unknown as {
id: string;
current_version_id?: string | null;
}[];
await attachActiveVersionPaths(db, docs);
res.json({
review: { ...review, is_owner: access.isOwner },
@ -377,7 +388,7 @@ tabularRouter.get("/:reviewId", requireAuth, async (req, res) => {
...cell,
content: parseCellContent(cell.content),
})),
documents: docsResult.data ?? [],
documents: docs,
});
});
@ -471,8 +482,19 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => {
if (req.body.title != null) updates.title = req.body.title;
if (req.body.columns_config != null)
updates.columns_config = req.body.columns_config;
if (req.body.project_id !== undefined)
updates.project_id = req.body.project_id;
const projectIdUpdateProvided = req.body.project_id !== undefined;
const projectIdUpdate =
req.body.project_id === null
? null
: typeof req.body.project_id === "string" &&
req.body.project_id.trim()
? req.body.project_id.trim()
: undefined;
if (projectIdUpdateProvided && projectIdUpdate === undefined) {
return void res.status(400).json({
detail: "project_id must be a non-empty string or null",
});
}
// shared_with edits are owner-only — gated below after we know who's
// making the call. Normalize lowercase + dedupe + drop empties.
let sharedWithUpdate: string[] | undefined;
@ -519,6 +541,27 @@ tabularRouter.patch("/:reviewId", requireAuth, async (req, res) => {
.json({ detail: "Only the review owner can change sharing" });
updates.shared_with = sharedWithUpdate;
}
if (projectIdUpdateProvided) {
if (!access.isOwner) {
return void res.status(403).json({
detail: "Only the review owner can move a review",
});
}
if (projectIdUpdate) {
const projectAccess = await checkProjectAccess(
projectIdUpdate,
userId,
userEmail,
db,
);
if (!projectAccess.ok) {
return void res
.status(404)
.json({ detail: "Target project not found" });
}
}
updates.project_id = projectIdUpdate;
}
const { data: updatedReview, error: updateError } = await db
.from("tabular_reviews")
@ -744,7 +787,7 @@ tabularRouter.post(
return void res.status(404).json({ detail: "Document not found" });
const { data: doc } = await db
.from("documents")
.select("id, filename, file_type")
.select("id, current_version_id")
.eq("id", document_id)
.single();
if (!doc)
@ -776,7 +819,7 @@ tabularRouter.post(
if (buf) {
try {
markdown =
(doc.file_type as string) === "pdf"
docActive.file_type === "pdf"
? await extractPdfMarkdown(buf)
: await extractDocxMarkdown(buf);
} catch (err) {
@ -790,7 +833,7 @@ tabularRouter.post(
const result = await queryTabularCell(
tabular_model,
doc.filename as string,
docActive?.filename?.trim() || "Untitled document",
markdown,
column.prompt,
column.format,
@ -866,18 +909,25 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => {
filteredIds.length > 0
? await db
.from("documents")
.select("id, filename, file_type, page_count")
.select("id, current_version_id")
.in("id", filteredIds)
: { data: [] as Record<string, unknown>[] };
docs = data ?? [];
} else if (review.project_id) {
const { data } = await db
.from("documents")
.select("id, filename, file_type, page_count")
.select("id, current_version_id")
.eq("project_id", review.project_id)
.order("created_at", { ascending: true });
docs = data ?? [];
}
await attachActiveVersionPaths(
db,
docs as {
id: string;
current_version_id?: string | null;
}[],
);
const { tabular_model, api_keys } = await getUserModelSettings(userId, db);
const missingKey = missingModelApiKey(tabular_model, api_keys);
@ -900,16 +950,22 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => {
await Promise.all(
docs.map(async (doc) => {
const docId = doc.id as string;
const filename = doc.filename as string;
let markdown = "";
const active = await loadActiveVersion(docId, db);
if (active) {
const buf = await downloadFile(active.storage_path);
const filename =
(typeof doc.filename === "string" && doc.filename.trim()
? doc.filename.trim()
: "Untitled document");
const storagePath =
typeof doc.storage_path === "string" ? doc.storage_path : "";
const fileType =
typeof doc.file_type === "string" ? doc.file_type : "";
if (storagePath) {
const buf = await downloadFile(storagePath);
if (buf) {
try {
markdown =
(doc.file_type as string) === "pdf"
fileType === "pdf"
? await extractPdfMarkdown(buf)
: await extractDocxMarkdown(buf);
} catch (err) {
@ -1253,14 +1309,29 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
const docIds = [
...new Set((cells ?? []).map((c: any) => c.document_id as string)),
];
let docs: { id: string; filename: string }[] = [];
let docs: {
id: string;
filename: string;
current_version_id?: string | null;
}[] = [];
if (docIds.length > 0) {
const { data } = await db
.from("documents")
.select("id, filename")
.select("id, current_version_id")
.in("id", docIds)
.order("created_at", { ascending: true });
docs = (data ?? []) as { id: string; filename: string }[];
const attachedDocs = (data ?? []) as {
id: string;
current_version_id?: string | null;
filename?: string | null;
}[];
await attachActiveVersionPaths(db, attachedDocs);
docs = attachedDocs.map((doc) => ({
...doc,
filename:
(typeof doc.filename === "string" && doc.filename.trim()) ||
"Untitled document",
}));
}
const sortedColumns = (
@ -1339,6 +1410,11 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders();
const write = (line: string) => res.write(line);
const streamAbort = new AbortController();
let streamFinished = false;
res.on("close", () => {
if (!streamFinished) streamAbort.abort();
});
if (chatId) {
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);
@ -1353,20 +1429,23 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
db,
write,
extraTools: TABULAR_TOOLS,
includeResearchTools: false,
tabularStore,
buildCitations: (text) =>
extractTabularAnnotations(text, tabularStore),
model: tabular_model,
apiKeys: api_keys,
signal: streamAbort.signal,
});
const persistedEvents = stripTransientAssistantEvents(events);
const annotations = extractTabularAnnotations(fullText, tabularStore);
if (chatId) {
await db.from("tabular_review_chat_messages").insert({
chat_id: chatId,
role: "assistant",
content: events.length ? events : null,
content: persistedEvents.length ? persistedEvents : null,
annotations: annotations.length ? annotations : null,
});
await db
@ -1398,16 +1477,48 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
}
}
} catch (err) {
if (isAbortError(err)) {
console.log("[tabular/chat] client aborted stream", { chatId });
return;
}
console.error("[tabular/chat] error", err);
const message =
err instanceof Error && err.message ? err.message : "Stream error";
const errorEvents = err instanceof AssistantStreamError
? stripTransientAssistantEvents(err.events)
: [{ type: "error" as const, message }];
const errorFullText =
err instanceof AssistantStreamError ? err.fullText : "";
if (chatId) {
try {
const annotations = extractTabularAnnotations(
errorFullText,
tabularStore,
);
const { error: saveError } = await db
.from("tabular_review_chat_messages")
.insert({
chat_id: chatId,
role: "assistant",
content: errorEvents.length ? errorEvents : null,
annotations: annotations.length ? annotations : null,
});
if (saveError)
console.error("[tabular/chat] failed to save error", saveError);
} catch (saveErr) {
console.error("[tabular/chat] failed to save error", saveErr);
}
}
try {
write(
`data: ${JSON.stringify({ type: "error", message: String(err) })}\n\n`,
`data: ${JSON.stringify({ type: "error", message })}\n\n`,
);
write("data: [DONE]\n\n");
} catch {
/* ignore */
}
} finally {
streamFinished = true;
res.end();
}
});

View file

@ -1,7 +1,13 @@
import { Router } from "express";
import { requireAuth } from "../middleware/auth";
import { createServerSupabase } from "../lib/supabase";
import { DEFAULT_TABULAR_MODEL, resolveModel } from "../lib/llm";
import {
DEFAULT_TABULAR_MODEL,
DEFAULT_TITLE_MODEL,
CLAUDE_LOW_MODELS,
OPENAI_LOW_MODELS,
resolveModel,
} from "../lib/llm";
import {
type ApiKeyStatus,
getUserApiKeyStatus,
@ -20,14 +26,85 @@ type UserProfileRow = {
message_credits_used: number;
credits_reset_date: string;
tier: string;
title_model: string | null;
tabular_model: string;
};
function errorMessage(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
if (error && typeof error === "object") {
const record = error as {
message?: unknown;
details?: unknown;
hint?: unknown;
code?: unknown;
};
return [record.message, record.details, record.hint, record.code]
.filter((value): value is string => typeof value === "string" && !!value)
.join(" ")
|| JSON.stringify(error);
}
return String(error);
}
const PROFILE_SELECT =
"display_name, organisation, message_credits_used, credits_reset_date, tier, title_model, tabular_model";
const LEGACY_PROFILE_SELECT =
"display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model";
function isMissingProfileModelColumn(error: unknown): boolean {
const record =
error && typeof error === "object"
? (error as { code?: unknown; message?: unknown })
: {};
const message = typeof record.message === "string" ? record.message : "";
return (
record.code === "42703" ||
message.includes("title_model")
);
}
async function selectProfile(
db: ReturnType<typeof createServerSupabase>,
userId: string,
mode: "maybe" | "single",
) {
const query = db
.from("user_profiles")
.select(PROFILE_SELECT)
.eq("user_id", userId);
const result = mode === "single" ? await query.single() : await query.maybeSingle();
if (!result.error || !isMissingProfileModelColumn(result.error)) {
return result;
}
const legacyQuery = db
.from("user_profiles")
.select(LEGACY_PROFILE_SELECT)
.eq("user_id", userId);
const legacy =
mode === "single" ? await legacyQuery.single() : await legacyQuery.maybeSingle();
if (legacy.data && typeof legacy.data === "object") {
const row = legacy.data as Record<string, unknown>;
Object.assign(row, {
title_model: null,
});
}
return legacy;
}
function serializeProfile(
row: UserProfileRow,
apiKeyStatus?: ApiKeyStatus,
) {
const creditsUsed = row.message_credits_used ?? 0;
const titleFallback = apiKeyStatus?.gemini
? DEFAULT_TITLE_MODEL
: apiKeyStatus?.openai
? OPENAI_LOW_MODELS[0]
: apiKeyStatus?.claude
? CLAUDE_LOW_MODELS[0]
: DEFAULT_TITLE_MODEL;
return {
displayName: row.display_name,
organisation: row.organisation,
@ -35,6 +112,7 @@ function serializeProfile(
creditsResetDate: row.credits_reset_date,
creditsRemaining: Math.max(MONTHLY_CREDIT_LIMIT - creditsUsed, 0),
tier: row.tier || "Free",
titleModel: resolveModel(row.title_model, titleFallback),
tabularModel: resolveModel(row.tabular_model, DEFAULT_TABULAR_MODEL),
...(apiKeyStatus ? { apiKeyStatus } : {}),
};
@ -46,6 +124,7 @@ function validateProfilePayload(body: unknown):
update: {
display_name?: string | null;
organisation?: string | null;
title_model?: string;
tabular_model?: string;
updated_at: string;
};
@ -59,6 +138,7 @@ function validateProfilePayload(body: unknown):
const allowedFields = new Set([
"displayName",
"organisation",
"titleModel",
"tabularModel",
]);
const invalidField = Object.keys(raw).find((key) => !allowedFields.has(key));
@ -69,6 +149,7 @@ function validateProfilePayload(body: unknown):
const update: {
display_name?: string | null;
organisation?: string | null;
title_model?: string;
tabular_model?: string;
updated_at: string;
} = { updated_at: new Date().toISOString() };
@ -98,6 +179,17 @@ function validateProfilePayload(body: unknown):
update.tabular_model = resolved;
}
if ("titleModel" in raw) {
if (typeof raw.titleModel !== "string") {
return { ok: false, detail: "titleModel must be a string" };
}
const resolved = resolveModel(raw.titleModel, "");
if (!resolved) {
return { ok: false, detail: "Unsupported titleModel" };
}
update.title_model = resolved;
}
return { ok: true, update };
}
@ -117,15 +209,9 @@ async function ensureProfileRow(
async function loadProfile(
db: ReturnType<typeof createServerSupabase>,
userId: string,
options: { repairMissing?: boolean } = {},
options: { repairMissing?: boolean; apiKeyStatus?: ApiKeyStatus } = {},
) {
let { data, error } = await db
.from("user_profiles")
.select(
"display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model",
)
.eq("user_id", userId)
.maybeSingle();
let { data, error } = await selectProfile(db, userId, "maybe");
if (error) return { data: null, error };
if (!data) {
@ -136,13 +222,7 @@ async function loadProfile(
const ensureError = await ensureProfileRow(db, userId);
if (ensureError) return { data: null, error: ensureError };
const created = await db
.from("user_profiles")
.select(
"display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model",
)
.eq("user_id", userId)
.single();
const created = await selectProfile(db, userId, "single");
if (created.error) return { data: null, error: created.error };
data = created.data;
}
@ -151,24 +231,26 @@ async function loadProfile(
if (row.credits_reset_date && new Date() > new Date(row.credits_reset_date)) {
const creditsResetDate = new Date();
creditsResetDate.setDate(creditsResetDate.getDate() + 30);
const { data: resetData, error: resetError } = await db
const { error: resetError } = await db
.from("user_profiles")
.update({
message_credits_used: 0,
credits_reset_date: creditsResetDate.toISOString(),
updated_at: new Date().toISOString(),
})
.eq("user_id", userId)
.select(
"display_name, organisation, message_credits_used, credits_reset_date, tier, tabular_model",
)
.single();
.eq("user_id", userId);
if (resetError) return { data: null, error: resetError };
const { data: resetData, error: resetLoadError } = await selectProfile(
db,
userId,
"single",
);
if (resetLoadError) return { data: null, error: resetLoadError };
row = resetData as UserProfileRow;
}
return { data: serializeProfile(row), error: null };
return { data: serializeProfile(row, options.apiKeyStatus), error: null };
}
// POST /user/profile
@ -184,11 +266,12 @@ userRouter.post("/profile", requireAuth, async (_req, res) => {
userRouter.get("/profile", requireAuth, async (_req, res) => {
const userId = res.locals.userId as string;
const db = createServerSupabase();
const apiKeyStatus = await getUserApiKeyStatus(userId, db);
const { data, error } = await loadProfile(db, userId, {
repairMissing: true,
apiKeyStatus,
});
if (error) return void res.status(500).json({ detail: error.message });
const apiKeyStatus = await getUserApiKeyStatus(userId, db);
res.json({ ...data, apiKeyStatus });
});
@ -210,9 +293,9 @@ userRouter.patch("/profile", requireAuth, async (req, res) => {
if (updateError)
return void res.status(500).json({ detail: updateError.message });
const { data, error } = await loadProfile(db, userId);
if (error) return void res.status(500).json({ detail: error.message });
const apiKeyStatus = await getUserApiKeyStatus(userId, db);
const { data, error } = await loadProfile(db, userId, { apiKeyStatus });
if (error) return void res.status(500).json({ detail: error.message });
res.json({ ...data, apiKeyStatus });
});
@ -245,11 +328,12 @@ userRouter.put("/api-keys/:provider", requireAuth, async (req, res) => {
const status = await getUserApiKeyStatus(userId, db);
res.json(status);
} catch (err) {
const detail = errorMessage(err);
console.error("[user/api-keys] save failed", {
provider,
error: err instanceof Error ? err.message : String(err),
error: detail,
});
res.status(500).json({ detail: "Failed to save API key" });
res.status(500).json({ detail });
}
});