mirror of
https://github.com/willchen96/mike.git
synced 2026-06-20 21:18:07 +02:00
Sync deployment and project page fixes
This commit is contained in:
parent
91d0c2a089
commit
f39f175273
13 changed files with 1444 additions and 1315 deletions
|
|
@ -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 ?? "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 ?? [];
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue