feat: add OpenAI model support and harden OSS security defaults

This commit is contained in:
willchen96 2026-05-09 14:55:51 +08:00
parent adc2cf2370
commit bef75b082d
24 changed files with 1301 additions and 364 deletions

1
backend/.gitignore vendored
View file

@ -2,4 +2,5 @@ node_modules
dist
.env*
*.log
logs/
.DS_Store

View file

@ -62,7 +62,7 @@ create trigger on_auth_user_created
create table if not exists public.user_api_keys (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
provider text not null check (provider in ('claude', 'gemini')),
provider text not null check (provider in ('claude', 'gemini', 'openai')),
encrypted_key text not null,
iv text not null,
auth_tag text not null,
@ -1044,3 +1044,30 @@ create policy "Tabular chat owners can delete messages"
and c.user_id = public.current_user_id_text()
)
);
-- ---------------------------------------------------------------------------
-- Direct client grant hardening
-- ---------------------------------------------------------------------------
--
-- The frontend uses Supabase directly only for authentication. Application
-- data access goes through the backend API with the service role after the
-- backend verifies the user's JWT. Keep RLS enabled and policies defined
-- above as defense in depth, but do not grant the browser anon/authenticated
-- roles direct table privileges for backend-owned data.
revoke all on public.user_profiles from anon, authenticated;
revoke all on public.projects from anon, authenticated;
revoke all on public.project_subfolders from anon, authenticated;
revoke all on public.documents from anon, authenticated;
revoke all on public.document_versions from anon, authenticated;
revoke all on public.document_edits from anon, authenticated;
revoke all on public.workflows from anon, authenticated;
revoke all on public.hidden_workflows from anon, authenticated;
revoke all on public.workflow_shares from anon, authenticated;
revoke all on public.chats from anon, authenticated;
revoke all on public.chat_messages from anon, authenticated;
revoke all on public.tabular_reviews from anon, authenticated;
revoke all on public.tabular_cells from anon, authenticated;
revoke all on public.tabular_review_chats from anon, authenticated;
revoke all on public.tabular_review_chat_messages from anon, authenticated;
revoke all on public.user_api_keys from anon, authenticated;

View file

@ -113,9 +113,10 @@ After calling generate_docx, you MUST call read_document on the returned doc_id
Your prose response MUST include a short description of the generated document: what it is, its structure (key sections/clauses), and if the draft was informed by any provided source documents which sources you drew from and how. Keep it concise (typically 38 sentences or a short bulleted list). Refer to the document by filename, never by a download link.
When the description makes factual claims about the contents of the newly generated document, cite the generated document with [N] markers and a <CITATIONS> block exactly as specified in the DOCUMENT CITATION INSTRUCTIONS above. If you also make factual claims about provided source documents, cite those source documents separately. In every citation entry, use the exact chat-local doc_id label for the cited document. Omit the <CITATIONS> block if the description makes no such claims.
Heading hierarchy: always use Heading 1 before introducing Heading 2, Heading 2 before Heading 3, and so on. Never skip levels (e.g. do not jump from Heading 1 to Heading 3).
Numbering: all numbering MUST start from 1, never 0. This applies at every level of the hierarchy use 1., 1.1, 1.1.1, 1.1.1.1, etc. Never produce 0., 0.1, 1.0, 1.0.1, or any other sequence that begins a level with 0.
Numbering: all numbering MUST start from 1, never 0. This applies at every level of the hierarchy. Legal clause numbering is applied automatically by the document generator: top-level operative headings render as 1., 2., 3.; the first numbered body clause under a top-level heading renders as 1.1; nested body clauses under that render as (a), (b), (c); deeper nested clauses render as (i), (ii), (iii), then (A), (B), (C). Do NOT use 1.1.1 for legal body clauses when (a) is the expected next level. Never produce 0., 0.1, 1.0, 1.0.1, or any other sequence that begins a level with 0.
Never duplicate the numbering prefix in heading text. The heading's own numbering is applied automatically by the document generator, so the heading text must contain the title only do NOT prepend "1.", "1.1", "2.", etc. into the heading text itself. For example, a Heading 1 titled "Introduction" must be passed as "Introduction", never as "1. Introduction" (which would render as "1. 1. Introduction"). The same rule applies at every level.
Contracts: when generating a contract or agreement, always include a signatures block at the very end of the document on its own page. Set pageBreak: true on that final section so it starts on a fresh page, and include a signature line for each party typically the party name followed by lines for "By:", "Name:", "Title:", and "Date:". Do not number the signatures heading; put the signature block in the section's content rather than as a numbered heading.
Do not repeat the document title as the first section heading. The document generator already renders the title as a centered title paragraph. Put any opening preamble text directly in the first section's content, without a duplicate heading such as "Agreement", "Contract", "Mutual Non-Disclosure Agreement", or another shortened form of the title.
Contracts: when generating a contract or agreement, always include a signatures block at the very end of the document on its own page. Set pageBreak: true on that final section so it starts on a fresh page, and include a signature line for each party typically the party name followed by lines for "By:", "Name:", "Title:", and "Date:". The entire signature block must be plain unnumbered text: do NOT number the signatures heading, do NOT number or letter the introductory signature sentence, party names, "By:", "Name:", "Title:", or "Date:" lines, and do NOT place the signature block inside a numbered clause. Put the signature block in the section's content rather than as a numbered heading.
Contract preambles: the preamble of a contract (the opening recitals, parties block, "WHEREAS" clauses, and any introductory narrative before the first operative clause) must NOT be numbered. Render these as unnumbered content (plain paragraphs or an unnumbered heading), and begin numbering only at the first operative clause/section.
DOCUMENT EDITING:
@ -459,8 +460,19 @@ type ParsedCitation = {
function normalizeCitation(raw: unknown): ParsedCitation | null {
if (!raw || typeof raw !== "object") return null;
const c = raw as Record<string, unknown>;
if (typeof c.ref !== "number" || typeof c.doc_id !== "string") return null;
if (typeof c.quote !== "string" || !c.quote) return null;
const markerRef =
typeof c.marker === "string"
? Number(c.marker.match(/^\[(\d+)\]$/)?.[1])
: NaN;
const ref =
typeof c.ref === "number"
? c.ref
: Number.isFinite(markerRef)
? markerRef
: null;
if (typeof ref !== "number" || typeof c.doc_id !== "string") return null;
const quote = typeof c.quote === "string" ? c.quote : c.text;
if (typeof quote !== "string" || !quote) return null;
let page: number | string;
if (typeof c.page === "number") {
page = c.page;
@ -468,10 +480,10 @@ function normalizeCitation(raw: unknown): ParsedCitation | null {
page = c.page;
} else {
const n = parseInt(String(c.page ?? ""), 10);
if (!Number.isFinite(n)) return null;
page = n;
if (!Number.isFinite(n)) page = 1;
else page = n;
}
return { ref: c.ref, doc_id: c.doc_id, page, quote: c.quote };
return { ref, doc_id: c.doc_id, page, quote };
}
// ---------------------------------------------------------------------------
@ -506,6 +518,16 @@ export function resolveDocLabel(
return null;
}
function citationReminder(docLabel: string, filename: string): string {
return [
`[Citation requirement for ${docLabel} ("${filename}")]:`,
`If your final answer makes any factual claim from this document, include inline [N] markers and append a final <CITATIONS> JSON block.`,
`Every citation entry for this document MUST use "doc_id": "${docLabel}".`,
`Use this exact citation object shape: {"ref": 1, "doc_id": "${docLabel}", "page": 1, "quote": "exact verbatim text from the document"}.`,
`Do not use "marker" or "text" keys in the citation block; use "ref" and "quote".`,
].join("\n");
}
/**
* Append a tool-activity summary to the most recent assistant message so
* the model can see what it just did (read / create / edit / workflow
@ -725,6 +747,8 @@ export async function generateDocx(
BorderStyle,
TextRun,
AlignmentType,
LevelFormat,
LevelSuffix,
PageOrientation,
PageBreak,
} = await import("docx");
@ -766,42 +790,236 @@ export async function generateDocx(
HeadingLevel.HEADING_3,
HeadingLevel.HEADING_4,
];
const counters = [0, 0, 0, 0];
const LEGAL_NUMBERING_REF = "legal-clause-numbering";
const legalNumbering = (level: number) => ({
reference: LEGAL_NUMBERING_REF,
level: Math.max(0, Math.min(level, 4)),
});
const legalNumberingLevels = [
{
level: 0,
format: LevelFormat.DECIMAL,
text: "%1.",
alignment: AlignmentType.START,
suffix: LevelSuffix.TAB,
isLegalNumberingStyle: true,
style: {
paragraph: { indent: { left: 720, hanging: 720 } },
run: {
bold: true,
color: "000000",
font: FONT,
size: SIZE,
},
},
},
{
level: 1,
format: LevelFormat.DECIMAL,
text: "%1.%2",
alignment: AlignmentType.START,
suffix: LevelSuffix.TAB,
isLegalNumberingStyle: true,
style: {
paragraph: { indent: { left: 720, hanging: 720 } },
run: { color: "000000", font: FONT, size: SIZE },
},
},
{
level: 2,
format: LevelFormat.LOWER_LETTER,
text: "(%3)",
alignment: AlignmentType.START,
suffix: LevelSuffix.TAB,
style: {
paragraph: { indent: { left: 1440, hanging: 720 } },
run: { color: "000000", font: FONT, size: SIZE },
},
},
{
level: 3,
format: LevelFormat.LOWER_ROMAN,
text: "(%4)",
alignment: AlignmentType.START,
suffix: LevelSuffix.TAB,
style: {
paragraph: { indent: { left: 1440, hanging: 720 } },
run: { color: "000000", font: FONT, size: SIZE },
},
},
{
level: 4,
format: LevelFormat.UPPER_LETTER,
text: "(%5)",
alignment: AlignmentType.START,
suffix: LevelSuffix.TAB,
style: {
paragraph: { indent: { left: 2520, hanging: 720 } },
run: { color: "000000", font: FONT, size: SIZE },
},
},
];
const normalizeTable = (
table: unknown,
): { headers: string[]; rows: string[][] } | null => {
if (!table || typeof table !== "object") return null;
const raw = table as { headers?: unknown; rows?: unknown };
const headers = Array.isArray(raw.headers)
? raw.headers
.map((header) =>
typeof header === "string" ? header.trim() : "",
)
.filter(Boolean)
: [];
if (headers.length === 0) return null;
for (const section of sections as {
const rawRows = Array.isArray(raw.rows) ? raw.rows : [];
const rows = rawRows
.filter((row): row is unknown[] => Array.isArray(row))
.map((row) =>
headers.map((_, i) =>
typeof row[i] === "string" ? row[i] : "",
),
);
return { headers, rows };
};
const stripManualNumbering = (
value: string,
): { text: string; levelFromPrefix: number | null } => {
const match = value
.trim()
.match(/^(\d+(?:\.\d+)*)(?:[.)])?\s+(.+)$/);
if (!match) return { text: value.trim(), levelFromPrefix: null };
return {
text: match[2].trim(),
levelFromPrefix: match[1].split(".").length - 1,
};
};
const parseManualListMarker = (
value: string,
): { text: string; levelOffset: number | null } => {
const trimmed = value.trim();
const match = trimmed.match(/^(\(([a-z]+)\)|([a-z]+)[.)])\s+(.+)$/i);
if (!match) return { text: trimmed, levelOffset: null };
const marker = (match[2] ?? match[3] ?? "").toLowerCase();
const isRoman =
marker === "i" ||
(marker.length > 1 &&
/^(?:m{0,4}(?:cm|cd|d?c{0,3})(?:xc|xl|l?x{0,3})(?:ix|iv|v?i{0,3}))$/i.test(
marker,
));
return { text: match[4].trim(), levelOffset: isRoman ? 3 : 2 };
};
const normalizeHeadingText = (value: string) =>
value
.trim()
.replace(/[^a-zA-Z0-9]+/g, " ")
.trim()
.toLowerCase();
const isTitleLikeFirstHeading = (
heading: string,
sectionIndex: number,
) => {
if (sectionIndex !== 0) return false;
const normalized = normalizeHeadingText(heading);
const titleNormalized = normalizeHeadingText(title);
if (!normalized || !titleNormalized) return false;
if (normalized === titleNormalized) return true;
return (
titleNormalized.includes(normalized) &&
/\b(agreement|contract|deed|terms|policy|notice|nda|disclosure)\b/.test(
normalized,
)
);
};
const isUnnumberedHeading = (heading: string, sectionIndex: number) => {
const normalized = normalizeHeadingText(heading);
if (!normalized) return true;
if (normalized === "signatures" || normalized === "signature") {
return true;
}
if (isTitleLikeFirstHeading(heading, sectionIndex)) {
return true;
}
if (
sectionIndex === 0 &&
/^(agreement|contract|mutual non disclosure agreement|non disclosure agreement|employment agreement|service level agreement)$/.test(
normalized,
)
) {
return true;
}
return false;
};
const isSignatureLine = (value: string) =>
/^(?:by|name|title|date):\s*/i.test(value.trim());
const looksLikeSignatureBlock = (value: string) => {
const lines = value
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) return false;
const signatureLineCount = lines.filter(isSignatureLine).length;
return signatureLineCount >= 2;
};
let currentClauseLevel: number | null = null;
for (const [sectionIndex, section] of (sections as {
heading?: string;
content?: string;
level?: number;
pageBreak?: boolean;
table?: { headers: string[]; rows: string[][] };
}[]) {
}[]).entries()) {
if (section.pageBreak) {
children.push(new Paragraph({ children: [new PageBreak()] }));
}
if (section.heading) {
const idx = Math.min((section.level ?? 1) - 1, 3);
counters[idx]++;
for (let i = idx + 1; i < 4; i++) counters[i] = 0;
const prefix = counters.slice(0, idx + 1).join(".");
const headingText = `${prefix}. ${idx === 0 ? section.heading.toUpperCase() : section.heading}`;
children.push(
new Paragraph({
heading: headingLevels[idx],
spacing: { after: 160 },
children: [
new TextRun({
text: headingText,
color: "000000",
font: FONT,
size: SIZE,
bold: true,
}),
],
}),
const stripped = stripManualNumbering(section.heading);
const isUnnumbered = isUnnumberedHeading(
stripped.text,
sectionIndex,
);
const skipHeading = isTitleLikeFirstHeading(
stripped.text,
sectionIndex,
);
const idx = Math.min(
stripped.levelFromPrefix ?? (section.level ?? 1) - 1,
3,
);
currentClauseLevel = isUnnumbered || skipHeading ? null : idx;
const headingText =
idx === 0 && !isUnnumbered
? stripped.text.toUpperCase()
: stripped.text;
if (!skipHeading) {
children.push(
new Paragraph({
heading: headingLevels[idx],
numbering: isUnnumbered
? undefined
: legalNumbering(idx),
spacing: { after: 160 },
children: [
new TextRun({
text: headingText,
color: "000000",
font: FONT,
size: SIZE,
bold: true,
}),
],
}),
);
}
}
if (section.table) {
const { headers, rows } = section.table;
const normalizedTable = normalizeTable(section.table);
if (normalizedTable) {
const { headers, rows } = normalizedTable;
const colCount = headers.length;
const tableRows: InstanceType<typeof TableRow>[] = [];
// Header row
@ -834,19 +1052,7 @@ export async function generateDocx(
// LLMs occasionally emit malformed rows (extra fragments from
// stray delimiters, or short rows); padding/truncating here
// keeps the rendered table aligned to the headers.
for (const rawRow of rows) {
const row = Array.isArray(rawRow) ? rawRow : [];
const normalized: string[] = [];
for (let i = 0; i < colCount; i++) {
normalized.push(
typeof row[i] === "string" ? row[i] : "",
);
}
if (row.length !== colCount) {
console.warn(
`[generate_docx] row length ${row.length} != headers ${colCount}; normalized`,
);
}
for (const normalized of rows) {
tableRows.push(
new TableRow({
children: normalized.map(
@ -878,38 +1084,55 @@ export async function generateDocx(
children.push(new Paragraph({ text: "" }));
}
if (section.content) {
let numberedBodyParagraphs = 0;
const contentIsSignatureBlock =
section.heading &&
normalizeHeadingText(section.heading).includes("signature")
? true
: looksLikeSignatureBlock(section.content);
for (const line of section.content.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const bulletMatch = trimmed.match(/^[-•*]\s+(.+)/);
if (bulletMatch) {
children.push(
new Paragraph({
bullet: { level: 0 },
spacing: { after: 120 },
children: [
new TextRun({
text: bulletMatch[1],
font: FONT,
size: SIZE,
}),
],
}),
);
} else {
children.push(
new Paragraph({
spacing: { after: 120 },
children: [
new TextRun({
text: trimmed,
font: FONT,
size: SIZE,
}),
],
}),
);
}
const rawText = bulletMatch
? bulletMatch[1].trim()
: trimmed;
const manualList = parseManualListMarker(rawText);
const numeric = stripManualNumbering(rawText);
const text = bulletMatch
? rawText
: manualList.levelOffset !== null
? manualList.text
: numeric.text;
const inferredLevel =
currentClauseLevel === null || contentIsSignatureBlock
? undefined
: bulletMatch
? currentClauseLevel + 2
: manualList.levelOffset !== null
? currentClauseLevel + manualList.levelOffset
: numeric.levelFromPrefix !== null
? numeric.levelFromPrefix
: numberedBodyParagraphs === 0
? currentClauseLevel + 1
: currentClauseLevel + 2;
if (currentClauseLevel !== null) numberedBodyParagraphs++;
children.push(
new Paragraph({
numbering:
inferredLevel === undefined
? undefined
: legalNumbering(inferredLevel),
spacing: { after: 120 },
children: [
new TextRun({
text,
font: FONT,
size: SIZE,
}),
],
}),
);
}
}
}
@ -919,9 +1142,30 @@ export async function generateDocx(
: {};
const doc = new Document({
numbering: {
config: [
{
reference: LEGAL_NUMBERING_REF,
levels: legalNumberingLevels,
},
],
},
sections: [{ properties: pageSetup, children }],
});
const buf = await Packer.toBuffer(doc);
const zip = await import("jszip");
const packageZip = await zip.default.loadAsync(buf);
for (const requiredPath of [
"[Content_Types].xml",
"word/document.xml",
"word/_rels/document.xml.rels",
]) {
if (!packageZip.file(requiredPath)) {
return {
error: `Generated DOCX is missing required package part: ${requiredPath}`,
};
}
}
const docId = crypto.randomUUID().replace(/-/g, "");
const safeTitle =
title
@ -1644,7 +1888,13 @@ export async function runToolCalls(
const filename = docStore.get(docId)?.filename;
const documentId = docIndex?.[docId]?.document_id;
if (filename) docsRead.push({ filename, document_id: documentId });
toolResults.push({ role: "tool", tool_call_id: tc.id, content });
toolResults.push({
role: "tool",
tool_call_id: tc.id,
content: filename
? `${citationReminder(docId, filename)}\n\n${content}`
: content,
});
} else if (tc.function.name === "find_in_document") {
const rawDocId = args.doc_id as string;
const docId =
@ -1714,7 +1964,9 @@ export async function runToolCalls(
db,
);
const filename = docStore.get(docId)?.filename ?? docId;
parts.push(`--- ${filename} (${docId}) ---\n${content}`);
parts.push(
`--- ${filename} (${docId}) ---\n${citationReminder(docId, filename)}\n\n${content}`,
);
if (docStore.get(docId)) {
const documentId = docIndex?.[docId]?.document_id;
docsRead.push({ filename, document_id: documentId });
@ -2346,12 +2598,15 @@ export async function runToolCalls(
// model can pass it as `doc_id` to edit_document / read_document
// / find_in_document in the same turn. Without this the model
// only sees the DB UUID, which isn't valid as a doc_id anchor.
const { download_url, storage_path, ...safeToolResult } =
result as Record<string, unknown>;
const toolResultPayload = newDocLabel
? {
...(result as Record<string, unknown>),
...safeToolResult,
doc_id: newDocLabel,
next_required_action: `Before writing your final response, call read_document with doc_id "${newDocLabel}". Describe and cite the generated document using doc_id "${newDocLabel}", not the source/template document.`,
}
: result;
: safeToolResult;
toolResults.push({
role: "tool",
tool_call_id: tc.id,

View file

@ -1,5 +1,6 @@
import { streamClaude, completeClaudeText } from "./claude";
import { streamGemini, completeGeminiText } from "./gemini";
import { streamOpenAI, completeOpenAIText } from "./openai";
import { providerForModel } from "./models";
import type { StreamChatParams, StreamChatResult, UserApiKeys } from "./types";
@ -11,6 +12,7 @@ export async function streamChatWithTools(
): Promise<StreamChatResult> {
const provider = providerForModel(params.model);
if (provider === "claude") return streamClaude(params);
if (provider === "openai") return streamOpenAI(params);
return streamGemini(params);
}
@ -23,5 +25,6 @@ export async function completeText(params: {
}): Promise<string> {
const provider = providerForModel(params.model);
if (provider === "claude") return completeClaudeText(params);
if (provider === "openai") return completeOpenAIText(params);
return completeGeminiText(params);
}

View file

@ -9,15 +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;
// 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;
// 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 DEFAULT_MAIN_MODEL = "gemini-3-flash-preview";
export const DEFAULT_TITLE_MODEL = "gemini-3.1-flash-lite-preview";
@ -26,10 +29,13 @@ export const DEFAULT_TABULAR_MODEL = "gemini-3-flash-preview";
const ALL_MODELS = new Set<string>([
...CLAUDE_MAIN_MODELS,
...GEMINI_MAIN_MODELS,
...OPENAI_MAIN_MODELS,
...CLAUDE_MID_MODELS,
...GEMINI_MID_MODELS,
...OPENAI_MID_MODELS,
...CLAUDE_LOW_MODELS,
...GEMINI_LOW_MODELS,
...OPENAI_LOW_MODELS,
]);
// ---------------------------------------------------------------------------
@ -39,6 +45,7 @@ const ALL_MODELS = new Set<string>([
export function providerForModel(model: string): Provider {
if (model.startsWith("claude")) return "claude";
if (model.startsWith("gemini")) return "gemini";
if (model.startsWith("gpt-")) return "openai";
throw new Error(`Unknown model id: ${model}`);
}

View file

@ -0,0 +1,291 @@
import type {
LlmMessage,
NormalizedToolCall,
NormalizedToolResult,
OpenAIToolSchema,
StreamChatParams,
StreamChatResult,
} from "./types";
const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses";
const MAX_OUTPUT_TOKENS = 16384;
type ResponseInputItem =
| { role: "user" | "assistant"; content: string }
| { type: "function_call_output"; call_id: string; output: string };
type ResponseFunctionTool = {
type: "function";
name: string;
description?: string;
parameters: Record<string, unknown>;
};
type ResponseFunctionCallItem = {
type: "function_call";
call_id?: string;
name?: string;
arguments?: string;
};
type ResponseStreamEvent = {
type?: string;
delta?: string;
response?: { id?: string; output_text?: string };
item?: ResponseFunctionCallItem;
};
function apiKey(override?: string | null): string {
return override?.trim() || process.env.OPENAI_API_KEY?.trim() || "";
}
function toResponseTools(tools: OpenAIToolSchema[]): ResponseFunctionTool[] {
return tools.map((tool) => ({
type: "function",
name: tool.function.name,
description: tool.function.description,
parameters: tool.function.parameters,
}));
}
function toResponseInput(messages: LlmMessage[]): ResponseInputItem[] {
return messages.map((message) => ({
role: message.role,
content: message.content,
}));
}
function extractSseJson(buffer: string): { events: unknown[]; rest: string } {
const events: unknown[] = [];
const chunks = buffer.split(/\n\n/);
const rest = chunks.pop() ?? "";
for (const chunk of chunks) {
const dataLines = chunk
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trim());
for (const data of dataLines) {
if (!data || data === "[DONE]") continue;
try {
events.push(JSON.parse(data));
} catch {
// Incomplete events stay buffered until the next read.
}
}
}
return { events, rest };
}
function parseFunctionCall(item: ResponseFunctionCallItem): NormalizedToolCall {
let input: Record<string, unknown> = {};
try {
const parsed = JSON.parse(item.arguments || "{}");
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
input = parsed as Record<string, unknown>;
}
} catch {
input = {};
}
return {
id: item.call_id ?? item.name ?? "function_call",
name: item.name ?? "",
input,
};
}
async function createResponse(params: {
model: string;
input: ResponseInputItem[];
instructions?: string;
tools?: ResponseFunctionTool[];
stream?: boolean;
maxTokens?: number;
previousResponseId?: string;
reasoningSummary?: boolean;
apiKey: string;
}): Promise<Response> {
const response = await fetch(OPENAI_RESPONSES_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${params.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: params.model,
instructions: params.instructions || undefined,
input: params.input,
tools: params.tools?.length ? params.tools : undefined,
stream: params.stream,
max_output_tokens: params.maxTokens ?? MAX_OUTPUT_TOKENS,
previous_response_id: params.previousResponseId,
reasoning: params.reasoningSummary
? { summary: "auto" }
: undefined,
}),
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(
`OpenAI request failed (${response.status}): ${text || response.statusText}`,
);
}
return response;
}
export async function streamOpenAI(
params: StreamChatParams,
): Promise<StreamChatResult> {
const {
model,
systemPrompt,
tools = [],
callbacks = {},
runTools,
apiKeys,
enableThinking,
} = params;
const maxIter = params.maxIterations ?? 10;
const key = apiKey(apiKeys?.openai);
const responseTools = toResponseTools(tools);
let input = toResponseInput(params.messages);
let previousResponseId: string | undefined;
let fullText = "";
const hasTools = responseTools.length > 0;
for (let iter = 0; iter < maxIter; iter++) {
const response = await createResponse({
model,
instructions: iter === 0 ? systemPrompt : undefined,
input,
tools: responseTools,
stream: true,
previousResponseId,
reasoningSummary: !!enableThinking,
apiKey: key,
});
if (!response.body) throw new Error("OpenAI response had no body");
const reader = response.body.getReader();
const decoder = new TextDecoder();
const toolCalls: NormalizedToolCall[] = [];
const startedToolCallIds = new Set<string>();
let buffer = "";
let pendingText = "";
let sawReasoning = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const extracted = extractSseJson(buffer);
buffer = extracted.rest;
for (const event of extracted.events as ResponseStreamEvent[]) {
if (event.response?.id) {
previousResponseId = event.response.id;
}
if (
event.type === "response.reasoning_summary_text.delta" &&
typeof event.delta === "string"
) {
sawReasoning = true;
callbacks.onReasoningDelta?.(event.delta);
}
if (
event.type === "response.output_text.delta" &&
typeof event.delta === "string"
) {
if (hasTools) {
pendingText += event.delta;
} else {
fullText += event.delta;
callbacks.onContentDelta?.(event.delta);
}
}
if (
event.type === "response.output_item.added" &&
event.item?.type === "function_call"
) {
const call = parseFunctionCall(event.item);
startedToolCallIds.add(call.id);
callbacks.onToolCallStart?.(call);
}
if (
event.type === "response.output_item.done" &&
event.item?.type === "function_call"
) {
const call = parseFunctionCall(event.item);
if (!startedToolCallIds.has(call.id)) {
callbacks.onToolCallStart?.(call);
}
toolCalls.push(call);
}
}
}
if (sawReasoning) callbacks.onReasoningBlockEnd?.();
if (!toolCalls.length || !runTools) {
if (pendingText) {
fullText += pendingText;
callbacks.onContentDelta?.(pendingText);
}
break;
}
const results = await runTools(toolCalls);
input = results.map((result) => ({
type: "function_call_output",
call_id: result.tool_use_id,
output: result.content,
}));
}
return { fullText };
}
export async function completeOpenAIText(params: {
model: string;
systemPrompt?: string;
user: string;
maxTokens?: number;
apiKeys?: { openai?: string | null };
}): Promise<string> {
const response = await createResponse({
model: params.model,
instructions: params.systemPrompt,
input: [{ role: "user", content: params.user }],
maxTokens: params.maxTokens ?? 512,
apiKey: apiKey(params.apiKeys?.openai),
});
const json = (await response.json()) as {
output_text?: string;
output?: {
content?: { type?: string; text?: string }[];
}[];
};
if (typeof json.output_text === "string") return json.output_text;
return (
json.output
?.flatMap((item) => item.content ?? [])
.filter((content) => content.type === "output_text")
.map((content) => content.text ?? "")
.join("") ?? ""
);
}
export type { NormalizedToolResult };

View file

@ -2,7 +2,7 @@
// Callers always speak OpenAI-style tools + { role, content } messages; each
// provider translates internally.
export type Provider = "claude" | "gemini";
export type Provider = "claude" | "gemini" | "openai";
export type OpenAIToolSchema = {
type: "function";
@ -39,6 +39,7 @@ export type StreamCallbacks = {
export type UserApiKeys = {
claude?: string | null;
gemini?: string | null;
openai?: string | null;
};
export type StreamChatParams = {

View file

@ -3,7 +3,11 @@ import { createServerSupabase } from "./supabase";
import type { UserApiKeys } from "./llm";
type Db = ReturnType<typeof createServerSupabase>;
export type ApiKeyProvider = "claude" | "gemini";
export type ApiKeyProvider = "claude" | "gemini" | "openai";
export type ApiKeySource = "user" | "env" | null;
export type ApiKeyStatus = Record<ApiKeyProvider, boolean> & {
sources: Record<ApiKeyProvider, ApiKeySource>;
};
type EncryptedKeyRow = {
provider: ApiKeyProvider;
@ -12,7 +16,25 @@ type EncryptedKeyRow = {
auth_tag: string;
};
const PROVIDERS: ApiKeyProvider[] = ["claude", "gemini"];
const PROVIDERS: ApiKeyProvider[] = ["claude", "gemini", "openai"];
function envApiKey(provider: ApiKeyProvider): string | null {
if (provider === "claude") {
return (
process.env.ANTHROPIC_API_KEY?.trim() ||
process.env.CLAUDE_API_KEY?.trim() ||
null
);
}
if (provider === "openai") {
return process.env.OPENAI_API_KEY?.trim() || null;
}
return process.env.GEMINI_API_KEY?.trim() || null;
}
export function hasEnvApiKey(provider: ApiKeyProvider): boolean {
return !!envApiKey(provider);
}
function encryptionKey(): Buffer {
const secret =
@ -72,12 +94,25 @@ export function normalizeApiKeyProvider(value: string): ApiKeyProvider | null {
export async function getUserApiKeyStatus(
userId: string,
db: Db = createServerSupabase(),
): Promise<Record<ApiKeyProvider, boolean>> {
const status: Record<ApiKeyProvider, boolean> = {
): Promise<ApiKeyStatus> {
const status: ApiKeyStatus = {
claude: false,
gemini: false,
openai: false,
sources: {
claude: null,
gemini: null,
openai: null,
},
};
for (const provider of PROVIDERS) {
if (hasEnvApiKey(provider)) {
status[provider] = true;
status.sources[provider] = "env";
}
}
const { data, error } = await db
.from("user_api_keys")
.select("provider")
@ -86,7 +121,10 @@ export async function getUserApiKeyStatus(
for (const row of data ?? []) {
const provider = normalizeApiKeyProvider(String(row.provider));
if (provider) status[provider] = true;
if (provider && !status[provider]) {
status[provider] = true;
status.sources[provider] = "user";
}
}
return status;
@ -96,7 +134,11 @@ export async function getUserApiKeys(
userId: string,
db: Db = createServerSupabase(),
): Promise<UserApiKeys> {
const apiKeys: UserApiKeys = { claude: null, gemini: null };
const apiKeys: UserApiKeys = {
claude: envApiKey("claude"),
gemini: envApiKey("gemini"),
openai: envApiKey("openai"),
};
const { data, error } = await db
.from("user_api_keys")
@ -107,6 +149,7 @@ export async function getUserApiKeys(
for (const row of (data ?? []) as EncryptedKeyRow[]) {
const provider = normalizeApiKeyProvider(row.provider);
if (!provider) continue;
if (apiKeys[provider]?.trim()) continue;
apiKeys[provider] = decrypt(row);
}

View file

@ -3,6 +3,7 @@ import {
resolveModel,
DEFAULT_TITLE_MODEL,
DEFAULT_TABULAR_MODEL,
OPENAI_LOW_MODELS,
type UserApiKeys,
} from "./llm";
import { getUserApiKeys as getStoredUserApiKeys } from "./userApiKeys";
@ -15,10 +16,11 @@ 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 Claude Haiku. With no user keys set, defaults to Gemini
// (the dev-mode env fallback).
// available, otherwise OpenAI nano, 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;
if (apiKeys.openai?.trim()) return OPENAI_LOW_MODELS[0];
if (apiKeys.claude?.trim()) return "claude-haiku-4-5";
return DEFAULT_TITLE_MODEL;
}

View file

@ -17,6 +17,10 @@ import { checkProjectAccess } from "../lib/access";
export const chatRouter = Router();
type Db = ReturnType<typeof createServerSupabase>;
const isDev = process.env.NODE_ENV !== "production";
const devLog = (...args: Parameters<typeof console.log>) => {
if (isDev) console.log(...args);
};
type AccessibleChat = {
id: string;
@ -436,7 +440,7 @@ chatRouter.post("/", requireAuth, async (req, res) => {
const project_id = parsedProjectId.projectId;
const model = parsedModel.model;
console.log("[chat/stream] incoming request", {
devLog("[chat/stream] incoming request", {
userId,
chat_id,
project_id,
@ -497,7 +501,7 @@ chatRouter.post("/", requireAuth, async (req, res) => {
chatTitle = newChat.title;
}
console.log("[chat/stream] resolved chatId", chatId);
devLog("[chat/stream] resolved chatId", chatId);
const lastUser = [...messages].reverse().find((m) => m.role === "user");
if (lastUser) {
@ -530,7 +534,7 @@ chatRouter.post("/", requireAuth, async (req, res) => {
const workflowStore = await buildWorkflowStore(userId, userEmail, db);
console.log("[chat/stream] starting LLM stream", {
devLog("[chat/stream] starting LLM stream", {
apiMessageCount: apiMessages.length,
docCount: Object.keys(docIndex).length,
workflowCount: Object.keys(workflowStore).length,
@ -562,7 +566,7 @@ chatRouter.post("/", requireAuth, async (req, res) => {
projectId: resolvedProjectId,
});
console.log("[chat/stream] LLM stream finished", {
devLog("[chat/stream] LLM stream finished", {
fullTextLen: fullText?.length ?? 0,
eventCount: events?.length ?? 0,
});

View file

@ -3,7 +3,9 @@ import { requireAuth } from "../middleware/auth";
import { createServerSupabase } from "../lib/supabase";
import { DEFAULT_TABULAR_MODEL, resolveModel } from "../lib/llm";
import {
type ApiKeyStatus,
getUserApiKeyStatus,
hasEnvApiKey,
normalizeApiKeyProvider,
saveUserApiKey,
} from "../lib/userApiKeys";
@ -23,7 +25,7 @@ type UserProfileRow = {
function serializeProfile(
row: UserProfileRow,
apiKeyStatus?: { claude: boolean; gemini: boolean },
apiKeyStatus?: ApiKeyStatus,
) {
const creditsUsed = row.message_credits_used ?? 0;
return {
@ -233,6 +235,12 @@ userRouter.put("/api-keys/:provider", requireAuth, async (req, res) => {
typeof req.body?.api_key === "string" ? req.body.api_key : null;
const db = createServerSupabase();
try {
if (hasEnvApiKey(provider)) {
return void res.status(409).json({
detail:
"This provider is configured by the server environment and cannot be changed from the browser.",
});
}
await saveUserApiKey(userId, provider, apiKey, db);
const status = await getUserApiKeyStatus(userId, db);
res.json(status);

View file

@ -44,38 +44,58 @@ export default function AccountLayout({
}
return (
<div className="flex flex-col h-full md:overflow-y-auto px-6 py-6 md:py-10">
<div className="max-w-5xl w-full mx-auto">
<h1 className="text-4xl font-medium mb-8 font-eb-garamond">
<div className="flex h-full flex-col overflow-y-auto">
<header className="mx-auto flex h-16 w-full max-w-5xl shrink-0 items-end px-6 pb-2 md:h-24 md:pb-4">
<h1 className="text-4xl font-medium font-eb-garamond">
Settings
</h1>
</header>
<div className="flex flex-col md:flex-row gap-6 md:gap-10">
<main className="mx-auto w-full max-w-5xl flex-1 px-6 pb-10 pt-4 md:pt-6">
<div className="grid grid-cols-1 gap-y-6 md:grid-cols-[224px_minmax(0,1fr)] md:gap-x-10">
<nav
aria-label="Settings"
className="md:w-56 shrink-0 flex md:flex-col gap-1 overflow-x-auto"
className="z-10 -ml-3 min-w-0 self-start md:sticky md:top-4"
>
{TABS.map((tab) => {
const active = pathname === tab.href;
return (
<button
key={tab.id}
onClick={() => router.push(tab.href)}
className={`text-left whitespace-nowrap px-3 py-2 rounded-md text-sm font-medium transition-colors ${
active
? "bg-gray-100 text-gray-900"
: "text-gray-500 hover:text-gray-900 hover:bg-gray-50"
}`}
>
{tab.label}
</button>
);
})}
<div className="-m-1 min-w-0 p-1">
<div className="-m-1 min-w-0 overflow-x-auto overflow-y-hidden p-1">
<ul className="mb-0 flex gap-1 md:flex-col">
{TABS.map((tab) => {
const active =
pathname === tab.href ||
(tab.href !== "/account" &&
pathname.startsWith(tab.href));
return (
<li key={tab.id}>
<button
type="button"
aria-current={
active
? "page"
: undefined
}
onClick={() =>
router.push(tab.href)
}
className={`flex h-9 w-full items-center rounded-lg px-3 text-left text-sm font-medium whitespace-nowrap transition-colors ${
active
? "bg-gray-100 text-gray-900"
: "text-gray-500 hover:bg-gray-50 hover:text-gray-900"
}`}
>
{tab.label}
</button>
</li>
);
})}
</ul>
</div>
</div>
</nav>
<div className="flex-1 min-w-0">{children}</div>
<div className="min-w-0 outline-none">{children}</div>
</div>
</div>
</main>
</div>
);
}

View file

@ -13,12 +13,32 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useUserProfile } from "@/contexts/UserProfileContext";
import type { ApiKeyState } from "@/app/lib/mikeApi";
import { MODELS } from "@/app/components/assistant/ModelToggle";
import {
isModelAvailable,
modelGroupToProvider,
providerLabel,
} from "@/app/lib/modelAvailability";
const API_KEY_FIELDS = [
{
provider: "claude",
label: "Anthropic (Claude) API Key",
placeholder: "sk-ant-…",
},
{
provider: "gemini",
label: "Google (Gemini) API Key",
placeholder: "AI…",
},
{
provider: "openai",
label: "OpenAI API Key",
placeholder: "sk-…",
},
] as const;
export default function ModelsAndApiKeysPage() {
const { profile, updateModelPreference, updateApiKey } = useUserProfile();
@ -36,15 +56,16 @@ export default function ModelsAndApiKeysPage() {
<label className="text-sm text-gray-600 block mb-2">
Tabular review model
</label>
<p className="text-xs text-gray-400 mb-2">
We recommend using a smaller model for tabular
reviews to reduce token costs.
</p>
<TabularModelDropdown
value={
profile?.tabularModel ??
"gemini-3-flash-preview"
}
apiKeys={{
claudeApiKey: profile?.claudeApiKey ?? null,
geminiApiKey: profile?.geminiApiKey ?? null,
}}
apiKeys={profile?.apiKeys}
onChange={(id) =>
updateModelPreference("tabularModel", id)
}
@ -66,29 +87,33 @@ export default function ModelsAndApiKeysPage() {
own instance of Mike.
</p>
<p className="text-xs text-gray-400 mb-4 max-w-xl">
Title generation automatically routes to the cheapest model
of whichever provider you&rsquo;ve configured (Gemini Flash
Lite if a Gemini key is set, otherwise Claude Haiku).
Title generation automatically routes to the cheapest
configured provider model.
</p>
<div className="space-y-4 max-w-xl">
<ApiKeyField
label="Anthropic (Claude) API Key"
placeholder="sk-ant-…"
hasSavedKey={!!profile?.claudeApiKey}
onSave={(value) =>
updateApiKey("claude", value.trim() || null)
}
onRemove={() => updateApiKey("claude", null)}
/>
<ApiKeyField
label="Google (Gemini) API Key"
placeholder="AI…"
hasSavedKey={!!profile?.geminiApiKey}
onSave={(value) =>
updateApiKey("gemini", value.trim() || null)
}
onRemove={() => updateApiKey("gemini", null)}
/>
{API_KEY_FIELDS.map((field) => (
<ApiKeyField
key={field.provider}
label={field.label}
placeholder={field.placeholder}
hasSavedKey={
!!profile?.apiKeys[field.provider].configured
}
isServerConfigured={
profile?.apiKeys[field.provider].source ===
"env"
}
onSave={(value) =>
updateApiKey(
field.provider,
value.trim() || null,
)
}
onRemove={() =>
updateApiKey(field.provider, null)
}
/>
))}
</div>
</div>
</div>
@ -102,12 +127,16 @@ function TabularModelDropdown({
}: {
value: string;
onChange: (id: string) => void;
apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null };
apiKeys?: ApiKeyState;
}) {
const [isOpen, setIsOpen] = useState(false);
const selected = MODELS.find((m) => m.id === value);
const selectedAvailable = isModelAvailable(value, apiKeys);
const groups: ("Anthropic" | "Google")[] = ["Anthropic", "Google"];
const selectedAvailable = apiKeys ? isModelAvailable(value, apiKeys) : true;
const groups: ("Anthropic" | "Google" | "OpenAI")[] = [
"Anthropic",
"Google",
"OpenAI",
];
return (
<DropdownMenu onOpenChange={setIsOpen}>
@ -145,10 +174,9 @@ function TabularModelDropdown({
</DropdownMenuLabel>
{items.map((m) => {
const provider = modelGroupToProvider(m.group);
const available = isModelAvailable(
m.id,
apiKeys,
);
const available = apiKeys
? isModelAvailable(m.id, apiKeys)
: true;
return (
<DropdownMenuItem
key={m.id}
@ -156,7 +184,7 @@ function TabularModelDropdown({
onSelect={() => onChange(m.id)}
title={
!available
? `Add a ${provider === "claude" ? "Claude" : "Gemini"} API key to use this model`
? `Add a ${providerLabel(provider)} API key to use this model`
: undefined
}
>
@ -186,12 +214,14 @@ function ApiKeyField({
label,
placeholder,
hasSavedKey,
isServerConfigured,
onSave,
onRemove,
}: {
label: string;
placeholder: string;
hasSavedKey: boolean;
isServerConfigured: boolean;
onSave: (value: string) => Promise<boolean>;
onRemove: () => Promise<boolean>;
}) {
@ -229,7 +259,20 @@ function ApiKeyField({
return (
<div>
<label className="text-sm text-gray-600 block mb-2">{label}</label>
{hasSavedKey && (
{isServerConfigured && (
<div className="mb-2 rounded-md border border-blue-100 bg-blue-50 px-3 py-2">
<p className="text-xs text-blue-800">
A server .env key is configured for this provider.
Browser API-key edits are disabled.
</p>
{hasSavedKey && (
<p className="mt-1 text-xs text-blue-800">
The server key will be used for this provider.
</p>
)}
</div>
)}
{hasSavedKey && !isServerConfigured && (
<p className="text-xs text-gray-500 mb-2">
A key is saved. Paste a new key to replace it.
</p>
@ -240,15 +283,23 @@ function ApiKeyField({
type={reveal ? "text" : "password"}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={hasSavedKey ? "Saved key hidden" : placeholder}
placeholder={
isServerConfigured
? "Server .env key configured"
: hasSavedKey
? "Saved key hidden"
: placeholder
}
className="pr-10"
autoComplete="off"
spellCheck={false}
disabled={isServerConfigured}
/>
<button
type="button"
onClick={() => setReveal((r) => !r)}
className="absolute inset-y-0 right-2 flex items-center text-gray-400 hover:text-gray-600"
disabled={isServerConfigured}
className="absolute inset-y-0 right-2 flex items-center text-gray-400 hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40"
aria-label={reveal ? "Hide key" : "Show key"}
>
{reveal ? (
@ -260,7 +311,7 @@ function ApiKeyField({
</div>
<Button
onClick={handleSave}
disabled={isSaving || !dirty || saved}
disabled={isServerConfigured || isSaving || !dirty || saved}
className="min-w-[80px] transition-all bg-black hover:bg-gray-900 text-white"
>
{isSaving ? (
@ -274,7 +325,7 @@ function ApiKeyField({
"Save"
)}
</Button>
{hasSavedKey && (
{hasSavedKey && !isServerConfigured && (
<Button
type="button"
variant="outline"

View file

@ -18,6 +18,19 @@ import { EditCard, applyOptimisticResolution } from "./EditCard";
import { PreResponseWrapper } from "../shared/PreResponseWrapper";
import { supabase } from "@/lib/supabase";
function toolCallLabel(name: string): string {
if (name === "generate_docx") return "Creating document...";
if (name === "edit_document") return "Editing document...";
if (name === "read_document") return "Reading document...";
if (name === "fetch_documents") return "Reading documents...";
if (name === "find_in_document") return "Searching document...";
if (name === "replicate_document") return "Copying document...";
if (name === "read_workflow") return "Loading workflow...";
if (name === "list_workflows") return "Loading workflows...";
if (name === "list_documents") return "Loading documents...";
return name ? `Running ${name}...` : "Working...";
}
/**
* Card rendered above the per-edit EditCards when a message produced
* multiple tracked-change proposals. Lets the user resolve every pending
@ -1237,9 +1250,8 @@ export function AssistantMessage({
<div className="absolute bottom-0 w-[1px] bg-gray-300 top-[13px] left-[2.5px] h-[calc(100%+11px)]" />
)}
<div className="w-1.5 h-1.5 rounded-full border border-gray-400 border-t-transparent animate-spin shrink-0" />
<span className="font-medium ml-2">Running</span>
<span className="ml-1">
{event.name ? `${event.name}...` : "tool..."}
<span className="font-medium ml-2">
{toolCallLabel(event.name)}
</span>
</div>
);

View file

@ -67,12 +67,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
} | null>(null);
const [model, setModel] = useSelectedModel();
const { profile } = useUserProfile();
const apiKeys = profile
? {
claudeApiKey: profile?.claudeApiKey ?? null,
geminiApiKey: profile?.geminiApiKey ?? null,
}
: undefined;
const apiKeys = profile?.apiKeys;
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [docSelectorOpen, setDocSelectorOpen] = useState(false);
const [workflowModalOpen, setWorkflowModalOpen] = useState(false);

View file

@ -11,11 +11,12 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { isModelAvailable } from "@/app/lib/modelAvailability";
import type { ApiKeyState } from "@/app/lib/mikeApi";
export interface ModelOption {
id: string;
label: string;
group: "Anthropic" | "Google";
group: "Anthropic" | "Google" | "OpenAI";
}
export const MODELS: ModelOption[] = [
@ -23,21 +24,20 @@ export const MODELS: ModelOption[] = [
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6", group: "Anthropic" },
{ id: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro", group: "Google" },
{ id: "gemini-3-flash-preview", label: "Gemini 3 Flash", group: "Google" },
{ id: "gpt-5.5", label: "GPT-5.5", group: "OpenAI" },
{ id: "gpt-5.4-mini", label: "GPT-5.4 Mini", group: "OpenAI" },
];
export const DEFAULT_MODEL_ID = "gemini-3-flash-preview";
export const ALLOWED_MODEL_IDS = new Set(MODELS.map((m) => m.id));
const GROUP_ORDER: ModelOption["group"][] = ["Anthropic", "Google"];
const GROUP_ORDER: ModelOption["group"][] = ["Anthropic", "Google", "OpenAI"];
interface Props {
value: string;
onChange: (id: string) => void;
apiKeys?: {
claudeApiKey: string | null;
geminiApiKey: string | null;
};
apiKeys?: ApiKeyState;
}
export function ModelToggle({ value, onChange, apiKeys }: Props) {

View file

@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { type CSSProperties, useEffect, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import {
Upload,
@ -54,7 +54,11 @@ import type {
} from "@/app/components/shared/types";
import { ToolbarTabs } from "@/app/components/shared/ToolbarTabs";
import { RenameableTitle } from "@/app/components/shared/RenameableTitle";
import { RowActions } from "@/app/components/shared/RowActions";
import {
closeRowActionMenus,
RowActionMenuItems,
RowActions,
} from "@/app/components/shared/RowActions";
import { AddDocumentsModal } from "@/app/components/shared/AddDocumentsModal";
import { PeopleModal } from "@/app/components/shared/PeopleModal";
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
@ -74,12 +78,35 @@ type Tab = "documents" | "assistant" | "reviews";
type ContextMenu = {
x: number;
y: number;
docId?: string | null;
folderId: string | null; // null = right-clicked on root/empty space
showFolderActions: boolean; // true when right-clicked on a specific folder row
};
const CHECK_W = "w-8 shrink-0";
const NAME_COL_W = "w-[300px] shrink-0";
const TREE_CONTROL_WIDTH_PX = 32;
const TREE_NAME_PADDING_PX = 8;
function treeControlWidth(depth: number) {
return TREE_CONTROL_WIDTH_PX * (Math.max(0, depth) + 1);
}
function treeControlCellStyle(depth: number): CSSProperties | undefined {
if (depth <= 0) return undefined;
const width = treeControlWidth(depth);
return {
justifyContent: "flex-start",
minWidth: width,
paddingLeft: TREE_NAME_PADDING_PX + depth * TREE_CONTROL_WIDTH_PX,
width,
};
}
function treeNameCellStyle(depth: number): CSSProperties | undefined {
if (depth <= 0) return undefined;
return { left: treeControlWidth(depth) };
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
@ -113,6 +140,7 @@ function DocVersionHistory({
filename,
loading,
versions,
depth = 0,
onDownloadVersion,
onOpenVersion,
onRenameVersion,
@ -121,6 +149,7 @@ function DocVersionHistory({
filename: string;
loading: boolean;
versions: MikeDocumentVersion[];
depth?: number;
onDownloadVersion: (
docId: string,
versionId: string,
@ -150,8 +179,8 @@ function DocVersionHistory({
if (loading && versions.length === 0) {
return (
<div className="flex items-center h-9 border-b border-gray-50 text-xs text-gray-500 bg-gray-50/60">
<div className={`sticky left-0 z-[60] ${CHECK_W} bg-gray-50/60 self-stretch`} />
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-gray-50/60 p-2`}>
<div className={`sticky left-0 z-[60] ${CHECK_W} bg-gray-50/60 self-stretch`} style={treeControlCellStyle(depth)} />
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-gray-50/60 p-2`} style={treeNameCellStyle(depth)}>
<div className="flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin text-gray-400" />
<span>Loading versions</span>
@ -163,8 +192,8 @@ function DocVersionHistory({
if (versions.length === 0) {
return (
<div className="flex items-center h-9 border-b border-gray-50 text-xs text-gray-400 bg-gray-50/60">
<div className={`sticky left-0 z-[60] ${CHECK_W} bg-gray-50/60 self-stretch`} />
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-gray-50/60 p-2`}>
<div className={`sticky left-0 z-[60] ${CHECK_W} bg-gray-50/60 self-stretch`} style={treeControlCellStyle(depth)} />
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-gray-50/60 p-2`} style={treeNameCellStyle(depth)}>
<div>
No version history.
</div>
@ -204,8 +233,8 @@ function DocVersionHistory({
}}
className="group flex items-center h-9 pr-8 border-b border-gray-50 bg-gray-50/60 text-xs text-gray-600 cursor-pointer hover:bg-gray-100/80 transition-colors"
>
<div className={`sticky left-0 z-[60] ${CHECK_W} bg-gray-50/60 group-hover:bg-gray-100/80 self-stretch`} />
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-gray-50/60 group-hover:bg-gray-100/80 p-2`}>
<div className={`sticky left-0 z-[60] ${CHECK_W} bg-gray-50/60 group-hover:bg-gray-100/80 self-stretch`} style={treeControlCellStyle(depth)} />
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-gray-50/60 group-hover:bg-gray-100/80 p-2`} style={treeNameCellStyle(depth)}>
<div className="flex items-center gap-2">
<span className="shrink-0 text-gray-400"></span>
{isEditing ? (
@ -846,7 +875,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
// ── Tree rendering ────────────────────────────────────────────────────────
function renderFolderInput(parentId: string | null) {
function renderFolderInput(parentId: string | null, depth: number) {
if (creatingFolderIn !== parentId) return null;
return (
<div
@ -854,10 +883,17 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
className="group flex items-center h-10 pr-8 border-b border-gray-50"
key={`new-folder-${parentId ?? "root"}`}
>
<div className={`sticky left-0 z-[60] ${CHECK_W} bg-white self-stretch`} />
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2`}>
<div
className={`sticky left-0 z-[60] ${CHECK_W} bg-white p-2 flex items-center justify-center self-stretch`}
style={treeControlCellStyle(depth)}
>
<ChevronRight className="h-3.5 w-3.5 text-gray-300 shrink-0" />
</div>
<div
className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2`}
style={treeNameCellStyle(depth)}
>
<div className="flex items-center gap-1.5">
<ChevronRight className="h-3.5 w-3.5 text-gray-300 shrink-0" />
<FolderPlus className="h-4 w-4 text-amber-400 shrink-0" />
<input
autoFocus
@ -911,7 +947,18 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
setViewingDocVersion(null);
setViewingDoc(doc);
}}
onContextMenu={(e) => e.stopPropagation()}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
closeRowActionMenus();
setContextMenu({
x: e.clientX,
y: e.clientY,
docId: doc.id,
folderId: null,
showFolderActions: false,
});
}}
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
>
{(() => {
@ -922,6 +969,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
<>
<div
className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${rowBg} group-hover:bg-gray-50`}
style={treeControlCellStyle(depth)}
onClick={(e) => e.stopPropagation()}
>
<input
@ -937,7 +985,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
/>
</div>
<div className={`sticky left-8 z-[60] ${NAME_COL_W} p-2 ${rowBg} group-hover:bg-gray-50`}>
<div className={`sticky left-8 z-[60] ${NAME_COL_W} p-2 ${rowBg} group-hover:bg-gray-50`} style={treeNameCellStyle(depth)}>
<div className="flex items-center gap-2">
{isProcessing ? (
<Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" />
@ -1008,6 +1056,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
filename={doc.filename}
loading={loadingVersionDocIds.has(doc.id)}
versions={versionsByDocId.get(doc.id) ?? []}
depth={depth}
onDownloadVersion={downloadDocVersion}
onOpenVersion={(versionId, label) => {
setViewingDocVersion({ id: versionId, label });
@ -1042,17 +1091,18 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
closeRowActionMenus();
setContextMenu({ x: e.clientX, y: e.clientY, folderId: folder.id, showFolderActions: true });
}}
className={`group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors select-none ${dragOverFolderId === folder.id ? "bg-blue-50 ring-1 ring-inset ring-blue-200" : ""}`}
>
<div className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${dragOverFolderId === folder.id ? "bg-blue-50" : "bg-white"} group-hover:bg-gray-50 self-stretch`}>
<div className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${dragOverFolderId === folder.id ? "bg-blue-50" : "bg-white"} group-hover:bg-gray-50 self-stretch`} style={treeControlCellStyle(depth)}>
{isExpanded
? <ChevronDown className="h-3.5 w-3.5 text-gray-400 shrink-0" />
: <ChevronRight className="h-3.5 w-3.5 text-gray-400 shrink-0" />
}
</div>
<div className={`sticky left-8 z-[60] ${NAME_COL_W} p-2 ${dragOverFolderId === folder.id ? "bg-blue-50" : "bg-white"} group-hover:bg-gray-50`}>
<div className={`sticky left-8 z-[60] ${NAME_COL_W} p-2 ${dragOverFolderId === folder.id ? "bg-blue-50" : "bg-white"} group-hover:bg-gray-50`} style={treeNameCellStyle(depth)}>
<div className="flex items-center gap-1.5">
{isExpanded
? <FolderOpen className="h-4 w-4 text-amber-500 shrink-0" />
@ -1100,7 +1150,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
})}
{/* New-folder input row at the bottom of this level */}
{renderFolderInput(parentId)}
{renderFolderInput(parentId, depth)}
</>
);
}
@ -1187,7 +1237,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
<ChevronDown className="h-3.5 w-3.5" />
</button>
{actionsOpen && (
<div className="absolute top-full right-0 mt-1 w-36 rounded-lg border border-gray-100 bg-white shadow-lg z-50 overflow-hidden">
<div className="absolute top-full right-0 mt-1 w-36 rounded-lg border border-gray-100 bg-white shadow-lg z-[120] overflow-hidden">
{tab === "documents" && (
<button
onClick={handleDownloadSelectedDocs}
@ -1365,7 +1415,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
{/* Blue ring wraps everything below the header when root-dropping */}
<div className="flex-1 flex flex-col min-h-0 relative">
{dragOverRoot && dragOverFolderId === null && (
<div className="absolute inset-0 border-2 border-blue-400 pointer-events-none z-20" />
<div className="absolute inset-0 border-2 border-blue-400 pointer-events-none z-[80]" />
)}
{/* Empty state */}
@ -1382,6 +1432,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
className="flex-1 flex flex-col"
onContextMenu={(e) => {
e.preventDefault();
closeRowActionMenus();
setContextMenu({ x: e.clientX, y: e.clientY, folderId: null, showFolderActions: false });
}}
onClick={() => setContextMenu(null)}
@ -1414,6 +1465,18 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
setViewingDocVersion(null);
setViewingDoc(doc);
}}
onContextMenu={(e) => {
e.preventDefault();
e.stopPropagation();
closeRowActionMenus();
setContextMenu({
x: e.clientX,
y: e.clientY,
docId: doc.id,
folderId: null,
showFolderActions: false,
});
}}
className="group flex items-center h-10 pr-8 border-b border-gray-50 hover:bg-gray-50 cursor-pointer transition-colors"
>
<div className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${selectedDocIds.includes(doc.id) ? "bg-gray-50" : "bg-white"} group-hover:bg-gray-50`} onClick={(e) => e.stopPropagation()}>
@ -1503,51 +1566,107 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
)}
{/* Context menu */}
{contextMenu && (
<div
ref={contextMenuRef}
className="fixed z-50 w-44 rounded-lg border border-gray-100 bg-white shadow-lg overflow-hidden text-xs"
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={(e) => e.stopPropagation()}
>
<button
className="w-full px-3 py-1.5 text-left text-gray-700 hover:bg-gray-50 flex items-center gap-2"
onClick={() => {
setCreatingFolderIn(contextMenu.folderId);
setNewFolderName("");
if (contextMenu.folderId) setExpandedFolderIds((prev) => new Set([...prev, contextMenu.folderId!]));
setContextMenu(null);
}}
>
<FolderPlus className="h-3.5 w-3.5 text-gray-400" />
{contextMenu.showFolderActions ? "New subfolder inside" : "New subfolder"}
</button>
{contextMenu.showFolderActions && contextMenu.folderId && (
<>
<button
className="w-full px-3 py-1.5 text-left text-gray-700 hover:bg-gray-50"
onClick={() => {
const f = folders.find((x) => x.id === contextMenu.folderId);
setRenameFolderValue(f?.name ?? "");
setRenamingFolderId(contextMenu.folderId!);
setContextMenu(null);
}}
>
Rename folder
</button>
<button
className="w-full px-3 py-1.5 text-left text-red-600 hover:bg-red-50"
onClick={() => {
handleDeleteFolder(contextMenu.folderId!);
setContextMenu(null);
}}
>
Delete folder
</button>
</>
)}
</div>
)}
{contextMenu &&
(() => {
const menuDoc = contextMenu.docId
? docs.find((doc) => doc.id === contextMenu.docId)
: null;
const menuDocHasVersions =
typeof menuDoc?.latest_version_number === "number" &&
menuDoc.latest_version_number >= 1;
const menuDocVersionsOpen = menuDoc
? expandedVersionDocIds.has(menuDoc.id)
: false;
return (
<div
ref={contextMenuRef}
className="fixed z-[120] w-48 rounded-xl border border-gray-100 bg-white shadow-lg overflow-hidden"
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={(e) => e.stopPropagation()}
>
{menuDoc ? (
<RowActionMenuItems
onClose={() => setContextMenu(null)}
onDownload={() => downloadDoc(menuDoc.id)}
onShowAllVersions={
menuDocHasVersions && !menuDocVersionsOpen
? () => void toggleVersions(menuDoc.id)
: undefined
}
onUploadNewVersion={() =>
void handleUploadNewVersion(menuDoc)
}
onRemoveFromFolder={
menuDoc.folder_id
? () =>
void handleRemoveDocFromFolder(
menuDoc.id,
)
: undefined
}
onDelete={() =>
void handleRemoveDoc(menuDoc.id)
}
/>
) : (
<RowActionMenuItems
onClose={() => setContextMenu(null)}
onNewSubfolder={() => {
setCreatingFolderIn(
contextMenu.folderId,
);
setNewFolderName("");
if (contextMenu.folderId) {
setExpandedFolderIds(
(prev) =>
new Set([
...prev,
contextMenu.folderId!,
]),
);
}
}}
newSubfolderLabel={
contextMenu.showFolderActions
? "New subfolder inside"
: "New subfolder"
}
onRename={
contextMenu.showFolderActions &&
contextMenu.folderId
? () => {
const f =
folders.find(
(x) =>
x.id ===
contextMenu.folderId,
);
setRenameFolderValue(
f?.name ?? "",
);
setRenamingFolderId(
contextMenu.folderId!,
);
}
: undefined
}
renameLabel="Rename folder"
onDelete={
contextMenu.showFolderActions &&
contextMenu.folderId
? () =>
handleDeleteFolder(
contextMenu.folderId!,
)
: undefined
}
deleteLabel="Delete folder"
/>
)}
</div>
);
})()}
</div>{/* end blue ring wrapper */}
</div>

View file

@ -1,7 +1,24 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Download, Eye, EyeOff, FolderMinus, Hash, History, Pencil, Trash2, Upload } from "lucide-react";
import {
Download,
Eye,
EyeOff,
FolderMinus,
FolderPlus,
Hash,
History,
Pencil,
Trash2,
Upload,
} from "lucide-react";
const CLOSE_ROW_ACTIONS_EVENT = "mike:close-row-actions";
export function closeRowActionMenus() {
document.dispatchEvent(new Event(CLOSE_ROW_ACTIONS_EVENT));
}
interface Props {
onDelete?: () => void;
@ -11,12 +28,130 @@ interface Props {
onRemoveFromFolder?: () => void;
onShowAllVersions?: () => void;
onUploadNewVersion?: () => void;
onNewSubfolder?: () => void;
deleting?: boolean;
onRename?: () => void;
onUpdateCmNumber?: () => void;
newSubfolderLabel?: string;
renameLabel?: string;
deleteLabel?: string;
}
export function RowActions({ onDelete, onHide, onUnhide, onDownload, onRemoveFromFolder, onShowAllVersions, onUploadNewVersion, deleting, onRename, onUpdateCmNumber }: Props) {
export function RowActionMenuItems({
onDelete,
onHide,
onUnhide,
onDownload,
onRemoveFromFolder,
onShowAllVersions,
onUploadNewVersion,
onNewSubfolder,
deleting,
onRename,
onUpdateCmNumber,
newSubfolderLabel = "New subfolder",
renameLabel = "Rename",
deleteLabel = "Delete",
onClose,
}: Props & { onClose: () => void }) {
return (
<>
{onNewSubfolder && (
<button
onClick={() => { onClose(); onNewSubfolder(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 hover:bg-gray-50 transition-colors"
>
<FolderPlus className="h-3.5 w-3.5 shrink-0" />
{newSubfolderLabel}
</button>
)}
{onRename && (
<button
onClick={() => { onClose(); onRename(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
>
<Pencil className="h-3.5 w-3.5" />
{renameLabel}
</button>
)}
{onUpdateCmNumber && (
<button
onClick={() => { onClose(); onUpdateCmNumber(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
>
<Hash className="h-3.5 w-3.5" />
Edit CM No.
</button>
)}
{onDownload && (
<button
onClick={() => { onClose(); onDownload(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
>
<Download className="h-3.5 w-3.5" />
Download
</button>
)}
{onShowAllVersions && (
<button
onClick={() => { onClose(); onShowAllVersions(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 hover:bg-gray-50 transition-colors"
>
<History className="h-3.5 w-3.5 shrink-0" />
Show all versions
</button>
)}
{onUploadNewVersion && (
<button
onClick={() => { onClose(); onUploadNewVersion(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 hover:bg-gray-50 transition-colors"
>
<Upload className="h-3.5 w-3.5 shrink-0" />
Upload new version
</button>
)}
{onRemoveFromFolder && (
<button
onClick={() => { onClose(); onRemoveFromFolder(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 hover:bg-gray-50 transition-colors"
>
<FolderMinus className="h-3.5 w-3.5 shrink-0" />
Remove from subfolder
</button>
)}
{onUnhide && (
<button
onClick={() => { onClose(); onUnhide(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
>
<Eye className="h-3.5 w-3.5" />
Unhide
</button>
)}
{onHide && (
<button
onClick={() => { onClose(); onHide(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
>
<EyeOff className="h-3.5 w-3.5" />
Hide
</button>
)}
{onDelete && (
<button
onClick={() => { onClose(); onDelete(); }}
disabled={deleting}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-red-500 hover:bg-red-50 transition-colors disabled:opacity-40"
>
<Trash2 className="h-3.5 w-3.5" />
{deleteLabel}
</button>
)}
</>
);
}
export function RowActions(props: Props) {
const [open, setOpen] = useState(false);
const [coords, setCoords] = useState({ top: 0, right: 0 });
const btnRef = useRef<HTMLButtonElement>(null);
@ -30,16 +165,33 @@ export function RowActions({ onDelete, onHide, onUnhide, onDownload, onRemoveFro
return () => document.removeEventListener("click", handleClick);
}, [open]);
useEffect(() => {
function handleCloseRowActions() {
setOpen(false);
}
document.addEventListener(CLOSE_ROW_ACTIONS_EVENT, handleCloseRowActions);
return () =>
document.removeEventListener(
CLOSE_ROW_ACTIONS_EVENT,
handleCloseRowActions,
);
}, []);
function handleToggle(e: React.MouseEvent) {
e.stopPropagation();
if (!open && btnRef.current) {
if (open) {
setOpen(false);
return;
}
closeRowActionMenus();
if (btnRef.current) {
const rect = btnRef.current.getBoundingClientRect();
setCoords({
top: rect.bottom + 4,
right: window.innerWidth - rect.right,
});
}
setOpen((o) => !o);
setOpen(true);
}
return (
@ -55,91 +207,13 @@ export function RowActions({ onDelete, onHide, onUnhide, onDownload, onRemoveFro
{open && (
<div
style={{ position: "fixed", top: coords.top, right: coords.right }}
className="z-50 w-48 rounded-xl border border-gray-100 bg-white shadow-lg overflow-hidden"
className="z-[120] w-48 rounded-xl border border-gray-100 bg-white shadow-lg overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{onRename && (
<button
onClick={() => { setOpen(false); onRename(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
>
<Pencil className="h-3.5 w-3.5" />
Rename
</button>
)}
{onUpdateCmNumber && (
<button
onClick={() => { setOpen(false); onUpdateCmNumber(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
>
<Hash className="h-3.5 w-3.5" />
Edit CM No.
</button>
)}
{onDownload && (
<button
onClick={() => { setOpen(false); onDownload(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
>
<Download className="h-3.5 w-3.5" />
Download
</button>
)}
{onShowAllVersions && (
<button
onClick={() => { setOpen(false); onShowAllVersions(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 hover:bg-gray-50 transition-colors"
>
<History className="h-3.5 w-3.5 shrink-0" />
Show all versions
</button>
)}
{onUploadNewVersion && (
<button
onClick={() => { setOpen(false); onUploadNewVersion(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 hover:bg-gray-50 transition-colors"
>
<Upload className="h-3.5 w-3.5 shrink-0" />
Upload new version
</button>
)}
{onRemoveFromFolder && (
<button
onClick={() => { setOpen(false); onRemoveFromFolder(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-left text-gray-600 hover:bg-gray-50 transition-colors"
>
<FolderMinus className="h-3.5 w-3.5 shrink-0" />
Remove from subfolder
</button>
)}
{onUnhide && (
<button
onClick={() => { setOpen(false); onUnhide(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
>
<Eye className="h-3.5 w-3.5" />
Unhide
</button>
)}
{onHide && (
<button
onClick={() => { setOpen(false); onHide(); }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-gray-600 hover:bg-gray-50 transition-colors"
>
<EyeOff className="h-3.5 w-3.5" />
Hide
</button>
)}
{onDelete && (
<button
onClick={() => { setOpen(false); onDelete(); }}
disabled={deleting}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-red-500 hover:bg-red-50 transition-colors disabled:opacity-40"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</button>
)}
<RowActionMenuItems
{...props}
onClose={() => setOpen(false)}
/>
</div>
)}
</>

View file

@ -37,6 +37,7 @@ import {
isModelAvailable,
type ModelProvider,
} from "@/app/lib/modelAvailability";
import type { ApiKeyState } from "@/app/lib/mikeApi";
// ---------------------------------------------------------------------------
// Types
@ -454,7 +455,7 @@ function TRChatInput({
onCancel: () => void;
model: string;
onModelChange: (id: string) => void;
apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null };
apiKeys?: ApiKeyState;
onHeightChange: (height: number) => void;
}) {
const [value, setValue] = useState("");
@ -642,10 +643,7 @@ export function TRChatPanel({
onChatIdChange,
}: Props) {
const { profile, updateModelPreference } = useUserProfile();
const apiKeys = {
claudeApiKey: profile?.claudeApiKey ?? null,
geminiApiKey: profile?.geminiApiKey ?? null,
};
const apiKeys = profile?.apiKeys;
const currentModel = profile?.tabularModel ?? "gemini-3-flash-preview";
const [apiKeyModalProvider, setApiKeyModalProvider] =
useState<ModelProvider | null>(null);
@ -993,7 +991,7 @@ export function TRChatPanel({
async function handleSubmit(trimmed: string) {
if (!trimmed || isLoading) return;
if (!isModelAvailable(currentModel, apiKeys)) {
if (apiKeys && !isModelAvailable(currentModel, apiKeys)) {
setApiKeyModalProvider(getModelProvider(currentModel));
return;
}

View file

@ -87,10 +87,7 @@ export function TRView({ reviewId, projectId }: Props) {
const tableRef = useRef<TRTableHandle>(null);
const router = useRouter();
const { profile } = useUserProfile();
const apiKeys = {
claudeApiKey: profile?.claudeApiKey ?? null,
geminiApiKey: profile?.geminiApiKey ?? null,
};
const apiKeys = profile?.apiKeys;
const tabularModel = profile?.tabularModel ?? "gemini-3-flash-preview";
useEffect(() => {
@ -243,7 +240,7 @@ export function TRView({ reviewId, projectId }: Props) {
// If columns changed since last save, update the review first
if (columns.length === 0) return;
if (!isModelAvailable(tabularModel, apiKeys)) {
if (apiKeys && !isModelAvailable(tabularModel, apiKeys)) {
setApiKeyModalProvider(getModelProvider(tabularModel));
return;
}

View file

@ -250,11 +250,11 @@ export function useAssistantChat({
const pushEvent = (event: AssistantEvent) => {
finalizeStreamingContent();
finalizeStreamingReasoning();
// Drop any in-flight placeholder unless we're pushing one ourselves.
let next = eventsRef.current;
if (event.type !== "tool_call_start" && event.type !== "thinking") {
next = next.filter((e) => !isStreamingPlaceholder(e));
}
// A real event, or a more specific placeholder such as
// tool_call_start, should replace any generic "Thinking..." line.
const next = eventsRef.current.filter(
(e) => !isStreamingPlaceholder(e),
);
eventsRef.current = [...next, event];
const snapshot = [...eventsRef.current];
setMessages((prev) => {

View file

@ -124,9 +124,18 @@ export async function updateUserProfile(payload: {
});
}
export type ApiKeyStatus = {
claude: boolean;
gemini: boolean;
export type ApiKeyProvider = "claude" | "gemini" | "openai";
export type ApiKeySource = "user" | "env" | null;
export type ApiKeyState = Record<
ApiKeyProvider,
{
configured: boolean;
source: ApiKeySource;
}
>;
export type ApiKeyStatus = Record<ApiKeyProvider, boolean> & {
sources?: Partial<Record<ApiKeyProvider, ApiKeySource>>;
};
export async function getApiKeyStatus(): Promise<ApiKeyStatus> {
@ -134,7 +143,7 @@ export async function getApiKeyStatus(): Promise<ApiKeyStatus> {
}
export async function saveApiKey(
provider: keyof ApiKeyStatus,
provider: ApiKeyProvider,
apiKey: string | null,
): Promise<ApiKeyStatus> {
return apiRequest<ApiKeyStatus>(`/user/api-keys/${provider}`, {

View file

@ -1,39 +1,40 @@
import { MODELS, type ModelOption } from "../components/assistant/ModelToggle";
import type { ApiKeyState } from "@/app/lib/mikeApi";
export type ModelProvider = "claude" | "gemini";
export type ModelProvider = "claude" | "gemini" | "openai";
export function getModelProvider(modelId: string): ModelProvider | null {
const model = MODELS.find((m) => m.id === modelId);
if (!model) return null;
return model.group === "Anthropic" ? "claude" : "gemini";
return modelGroupToProvider(model.group);
}
export function isModelAvailable(
modelId: string,
apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null },
apiKeys: ApiKeyState,
): boolean {
const provider = getModelProvider(modelId);
if (!provider) return false;
return provider === "claude"
? !!apiKeys.claudeApiKey?.trim()
: !!apiKeys.geminiApiKey?.trim();
return isProviderAvailable(provider, apiKeys);
}
export function isProviderAvailable(
provider: ModelProvider,
apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null },
apiKeys: ApiKeyState,
): boolean {
return provider === "claude"
? !!apiKeys.claudeApiKey?.trim()
: !!apiKeys.geminiApiKey?.trim();
return !!apiKeys[provider]?.configured;
}
export function providerLabel(provider: ModelProvider): string {
return provider === "claude" ? "Anthropic (Claude)" : "Google (Gemini)";
if (provider === "claude") return "Anthropic (Claude)";
if (provider === "openai") return "OpenAI";
return "Google (Gemini)";
}
export function modelGroupToProvider(
group: ModelOption["group"],
): ModelProvider {
return group === "Anthropic" ? "claude" : "gemini";
if (group === "Anthropic") return "claude";
if (group === "OpenAI") return "openai";
return "gemini";
}

View file

@ -10,6 +10,8 @@ import React, {
} from "react";
import { useAuth } from "@/contexts/AuthContext";
import {
type ApiKeyState,
type ApiKeyProvider,
type UserProfile as ApiUserProfile,
getUserProfile,
saveApiKey,
@ -24,8 +26,7 @@ interface UserProfile {
creditsRemaining: number;
tier: string;
tabularModel: string;
claudeApiKey: string | null;
geminiApiKey: string | null;
apiKeys: ApiKeyState;
}
interface UserProfileContextType {
@ -38,7 +39,7 @@ interface UserProfileContextType {
value: string,
) => Promise<boolean>;
updateApiKey: (
provider: "claude" | "gemini",
provider: ApiKeyProvider,
value: string | null,
) => Promise<boolean>;
reloadProfile: () => Promise<void>;
@ -49,14 +50,31 @@ const UserProfileContext = createContext<UserProfileContextType | undefined>(
undefined,
);
const CONFIGURED_KEY_MARKER = "configured";
const API_KEY_PROVIDERS: ApiKeyProvider[] = ["claude", "gemini", "openai"];
function emptyApiKeys(): ApiKeyState {
return {
claude: { configured: false, source: null },
gemini: { configured: false, source: null },
openai: { configured: false, source: null },
};
}
function toProfile(data: ApiUserProfile): UserProfile {
const { apiKeyStatus, ...profile } = data;
const apiKeys = emptyApiKeys();
for (const provider of API_KEY_PROVIDERS) {
apiKeys[provider] = {
configured: !!apiKeyStatus[provider],
source:
apiKeyStatus.sources?.[provider] ??
(apiKeyStatus[provider] ? "user" : null),
};
}
return {
...profile,
claudeApiKey: apiKeyStatus.claude ? CONFIGURED_KEY_MARKER : null,
geminiApiKey: apiKeyStatus.gemini ? CONFIGURED_KEY_MARKER : null,
apiKeys,
};
}
@ -83,8 +101,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
creditsRemaining: 999999, // temporarily unlimited
tier: "Free",
tabularModel: "gemini-3-flash-preview",
claudeApiKey: null,
geminiApiKey: null,
apiKeys: emptyApiKeys(),
});
} finally {
setLoading(false);
@ -157,12 +174,10 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
const updateApiKey = useCallback(
async (
provider: "claude" | "gemini",
provider: ApiKeyProvider,
value: string | null,
): Promise<boolean> => {
if (!user) return false;
const stateField =
provider === "claude" ? "claudeApiKey" : "geminiApiKey";
const normalized = value?.trim() ? value.trim() : null;
try {
await saveApiKey(provider, normalized);
@ -170,9 +185,13 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
prev
? {
...prev,
[stateField]: normalized
? CONFIGURED_KEY_MARKER
: null,
apiKeys: {
...prev.apiKeys,
[provider]: {
configured: !!normalized,
source: normalized ? "user" : null,
},
},
}
: null,
);