Sync deployment and project page fixes

This commit is contained in:
willchen96 2026-05-13 02:32:26 +08:00
parent 91d0c2a089
commit f39f175273
13 changed files with 1444 additions and 1315 deletions

View file

@ -28,9 +28,64 @@ type GeminiContent = {
parts: GeminiPart[];
};
const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]);
const MAX_GEMINI_ATTEMPTS = 3;
function apiKey(override?: string | null): string {
const key = override?.trim() || process.env.GEMINI_API_KEY?.trim() || "";
if (!key) {
throw new Error(
"Gemini API key is not configured. Set GEMINI_API_KEY or add a user Gemini key.",
);
}
return key;
}
function client(override?: string | null): GoogleGenAI {
const apiKey = override?.trim() || process.env.GEMINI_API_KEY || "";
return new GoogleGenAI({ apiKey });
return new GoogleGenAI({ apiKey: apiKey(override) });
}
function geminiStatus(err: unknown): number | null {
const status = (err as { status?: unknown })?.status;
return typeof status === "number" ? status : null;
}
function isRetryableGeminiError(err: unknown): boolean {
const status = geminiStatus(err);
if (status != null && RETRYABLE_STATUSES.has(status)) return true;
const message =
err instanceof Error ? err.message : typeof err === "string" ? err : "";
return /UNAVAILABLE|Service Unavailable|high demand|try again later/i.test(
message,
);
}
function retryDelayMs(attempt: number): number {
return 400 * 2 ** attempt;
}
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function withGeminiRetries<T>(operation: () => Promise<T>): Promise<T> {
let lastError: unknown;
for (let attempt = 0; attempt < MAX_GEMINI_ATTEMPTS; attempt++) {
try {
return await operation();
} catch (err) {
lastError = err;
const isLastAttempt = attempt === MAX_GEMINI_ATTEMPTS - 1;
if (isLastAttempt || !isRetryableGeminiError(err)) throw err;
console.warn("[gemini] transient error; retrying", {
attempt: attempt + 1,
status: geminiStatus(err),
});
await sleep(retryDelayMs(attempt));
}
}
throw lastError;
}
function toNativeContents(messages: StreamChatParams["messages"]): GeminiContent[] {
@ -52,23 +107,25 @@ 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 },
},
});
const stream = await withGeminiRetries(() =>
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 },
},
}),
);
// Per-iteration accumulators.
const textParts: string[] = [];
@ -150,12 +207,14 @@ 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,
});
const resp = await withGeminiRetries(() =>
ai.models.generateContent({
model: params.model,
contents: [{ role: "user", parts: [{ text: params.user }] }],
config: params.systemPrompt
? { systemInstruction: params.systemPrompt }
: undefined,
}),
);
return resp.text ?? "";
}

View file

@ -14,6 +14,15 @@ import { singleFileUpload } from "../lib/upload";
export const projectsRouter = Router();
const ALLOWED_TYPES = new Set(["pdf", "docx", "doc"]);
function normalizeDocumentFilename(nextName: unknown, currentName: string) {
if (typeof nextName !== "string") return null;
const trimmed = nextName.trim().slice(0, 200);
if (!trimmed) return null;
if (/\.[a-z0-9]{1,6}$/i.test(trimmed)) return trimmed;
const ext = currentName.match(/\.[a-z0-9]{1,6}$/i)?.[0] ?? "";
return `${trimmed}${ext}`;
}
// GET /projects
projectsRouter.get("/", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
@ -437,6 +446,51 @@ projectsRouter.post(
},
);
// PATCH /projects/:projectId/documents/:documentId — rename a project document
projectsRouter.patch("/:projectId/documents/:documentId", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
const userEmail = res.locals.userEmail as string | undefined;
const { projectId, documentId } = req.params;
const db = createServerSupabase();
const access = await checkProjectAccess(projectId, userId, userEmail, db);
if (!access.ok)
return void res.status(404).json({ detail: "Project not found" });
const { data: doc } = await db
.from("documents")
.select("id, filename, 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);
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() })
.eq("id", documentId)
.eq("project_id", projectId)
.select("*")
.single();
if (error || !updated)
return void res.status(404).json({ detail: "Document not found" });
if (doc.current_version_id) {
await db
.from("document_versions")
.update({ display_name: filename })
.eq("id", doc.current_version_id)
.eq("document_id", documentId);
}
res.json(updated);
});
// POST /projects/:projectId/documents
projectsRouter.post(
"/:projectId/documents",

View file

@ -10,8 +10,14 @@ import {
type ChatMessage,
type TabularCellStore,
} from "../lib/chatTools";
import { completeText, streamChatWithTools } from "../lib/llm";
import { getUserApiKeys, getUserModelSettings } from "../lib/userSettings";
import {
completeText,
providerForModel,
streamChatWithTools,
type Provider,
type UserApiKeys,
} from "../lib/llm";
import { getUserModelSettings } from "../lib/userSettings";
import {
checkProjectAccess,
ensureReviewAccess,
@ -46,6 +52,22 @@ function formatPromptSuffix(format?: string, tags?: string[]): string {
export const tabularRouter = Router();
function providerLabel(provider: Provider): string {
if (provider === "claude") return "Anthropic";
if (provider === "openai") return "OpenAI";
return "Gemini";
}
function missingModelApiKey(model: string, apiKeys: UserApiKeys) {
const provider = providerForModel(model);
if (apiKeys[provider]?.trim()) return null;
return {
provider,
model,
detail: `${providerLabel(provider)} API key is required to use ${model}. Add an API key or select a different tabular review model.`,
};
}
// GET /tabular-review
tabularRouter.get("/", requireAuth, async (req, res) => {
const userId = res.locals.userId as string;
@ -105,7 +127,7 @@ tabularRouter.get("/", requireAuth, async (req, res) => {
? db
.from("tabular_reviews")
.select("*")
.contains("shared_with", JSON.stringify([userEmail]))
.filter("shared_with", "cs", JSON.stringify([userEmail]))
.neq("user_id", userId)
.order("created_at", { ascending: false })
: Promise.resolve({
@ -697,6 +719,18 @@ tabularRouter.post(
return void res.status(404).json({ detail: "Document not found" });
const docActive = await loadActiveVersion(document_id, db);
const { tabular_model, api_keys } = await getUserModelSettings(
userId,
db,
);
const missingKey = missingModelApiKey(tabular_model, api_keys);
if (missingKey) {
return void res.status(422).json({
code: "missing_api_key",
...missingKey,
});
}
await db
.from("tabular_cells")
.update({ status: "generating", content: null })
@ -722,11 +756,7 @@ tabularRouter.post(
}
}
const { tabular_model, api_keys } = await getUserModelSettings(
userId,
db,
);
const result = await queryGemini(
const result = await queryTabularCell(
tabular_model,
doc.filename as string,
markdown,
@ -818,6 +848,13 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => {
}
const { tabular_model, api_keys } = await getUserModelSettings(userId, db);
const missingKey = missingModelApiKey(tabular_model, api_keys);
if (missingKey) {
return void res.status(422).json({
code: "missing_api_key",
...missingKey,
});
}
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
@ -883,7 +920,7 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => {
// Single LLM call for all columns, streaming one JSON line per column
const receivedColumns = new Set<number>();
try {
await queryGeminiAllColumns(
await queryTabularAllColumns(
tabular_model,
filename,
markdown,
@ -907,7 +944,7 @@ tabularRouter.post("/:reviewId/generate", requireAuth, async (req, res) => {
);
} catch (err) {
console.error(
`[tabular/generate] queryGeminiAllColumns error doc=${docId}`,
`[tabular/generate] queryTabularAllColumns error doc=${docId}`,
err,
);
}
@ -1209,6 +1246,15 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
),
};
const { tabular_model, api_keys } = await getUserModelSettings(userId, db);
const missingKey = missingModelApiKey(tabular_model, api_keys);
if (missingKey) {
return void res.status(422).json({
code: "missing_api_key",
...missingKey,
});
}
// Create or verify chat record
let chatId = existingChatId ?? null;
let chatTitle: string | null = null;
@ -1266,8 +1312,6 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
write(`data: ${JSON.stringify({ type: "chat_id", chatId })}\n\n`);
}
const apiKeys = await getUserApiKeys(userId, db);
try {
const { fullText, events } = await runLLMStream({
apiMessages,
@ -1280,7 +1324,8 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
tabularStore,
buildCitations: (text) =>
extractTabularAnnotations(text, tabularStore),
apiKeys,
model: tabular_model,
apiKeys: api_keys,
});
const annotations = extractTabularAnnotations(fullText, tabularStore);
@ -1308,7 +1353,7 @@ tabularRouter.post("/:reviewId/chat", requireAuth, async (req, res) => {
reviewTitle: clientReviewTitle ?? review.title ?? null,
projectName: clientProjectName ?? null,
},
apiKeys,
api_keys,
);
if (title) {
await db
@ -1379,7 +1424,7 @@ function parseCellContent(
return null;
}
async function queryGemini(
async function queryTabularCell(
model: string,
filename: string,
documentText: string,
@ -1408,7 +1453,7 @@ The "summary" field must contain only the extracted value with inline citations
apiKeys,
});
} catch (err) {
console.error("[queryGemini] completion failed", err);
console.error("[queryTabularCell] completion failed", err);
return null;
}
try {
@ -1534,7 +1579,7 @@ type Column = {
tags?: string[];
};
async function queryGeminiAllColumns(
async function queryTabularAllColumns(
model: string,
filename: string,
documentText: string,
@ -1619,7 +1664,7 @@ Rules:
},
});
} catch (err) {
console.error("[queryGeminiAllColumns] stream failed", err);
console.error("[queryTabularAllColumns] stream failed", err);
}
if (contentBuffer.trim()) pending.push(processLine(contentBuffer));

View file

@ -1,16 +1,7 @@
import { Router } from "express";
import { createClient } from "@supabase/supabase-js";
import { requireAuth } from "../middleware/auth";
import { createServerSupabase } from "../lib/supabase";
function getAdminClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL ?? "",
process.env.SUPABASE_SECRET_KEY ?? "",
{ auth: { autoRefreshToken: false, persistSession: false } },
);
}
export const workflowsRouter = Router();
type Db = ReturnType<typeof createServerSupabase>;
@ -113,7 +104,7 @@ workflowsRouter.get("/", requireAuth, async (req, res) => {
: { data: [] };
// Fetch sharer emails via admin client
const admin = getAdminClient();
const admin = createServerSupabase();
const { data: authData } = await admin.auth.admin.listUsers({ perPage: 1000 });
const authUsers = authData?.users ?? [];