diff --git a/backend/.gitignore b/backend/.gitignore index 12bb4bb..fb56fbb 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -2,4 +2,5 @@ node_modules dist .env* *.log +logs/ .DS_Store diff --git a/backend/schema.sql b/backend/schema.sql index cf72870..cb505e6 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -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; diff --git a/backend/src/lib/chatTools.ts b/backend/src/lib/chatTools.ts index d2a2491..6d85c6a 100644 --- a/backend/src/lib/chatTools.ts +++ b/backend/src/lib/chatTools.ts @@ -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 3–8 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 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 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; - 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 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[] = []; // 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; const toolResultPayload = newDocLabel ? { - ...(result as Record), + ...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, diff --git a/backend/src/lib/llm/index.ts b/backend/src/lib/llm/index.ts index 518ddc0..4b5e979 100644 --- a/backend/src/lib/llm/index.ts +++ b/backend/src/lib/llm/index.ts @@ -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 { 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 { const provider = providerForModel(params.model); if (provider === "claude") return completeClaudeText(params); + if (provider === "openai") return completeOpenAIText(params); return completeGeminiText(params); } diff --git a/backend/src/lib/llm/models.ts b/backend/src/lib/llm/models.ts index 5231400..ed4872e 100644 --- a/backend/src/lib/llm/models.ts +++ b/backend/src/lib/llm/models.ts @@ -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([ ...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([ 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}`); } diff --git a/backend/src/lib/llm/openai.ts b/backend/src/lib/llm/openai.ts new file mode 100644 index 0000000..dbb7ef6 --- /dev/null +++ b/backend/src/lib/llm/openai.ts @@ -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; +}; + +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 = {}; + try { + const parsed = JSON.parse(item.arguments || "{}"); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + input = parsed as Record; + } + } 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 { + 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 { + 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(); + 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 { + 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 }; diff --git a/backend/src/lib/llm/types.ts b/backend/src/lib/llm/types.ts index 8cc411a..a8409d8 100644 --- a/backend/src/lib/llm/types.ts +++ b/backend/src/lib/llm/types.ts @@ -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 = { diff --git a/backend/src/lib/userApiKeys.ts b/backend/src/lib/userApiKeys.ts index 51b26bd..4355c93 100644 --- a/backend/src/lib/userApiKeys.ts +++ b/backend/src/lib/userApiKeys.ts @@ -3,7 +3,11 @@ import { createServerSupabase } from "./supabase"; import type { UserApiKeys } from "./llm"; type Db = ReturnType; -export type ApiKeyProvider = "claude" | "gemini"; +export type ApiKeyProvider = "claude" | "gemini" | "openai"; +export type ApiKeySource = "user" | "env" | null; +export type ApiKeyStatus = Record & { + sources: Record; +}; 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> { - const status: Record = { +): Promise { + 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 { - 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); } diff --git a/backend/src/lib/userSettings.ts b/backend/src/lib/userSettings.ts index 2476060..bfbeb0f 100644 --- a/backend/src/lib/userSettings.ts +++ b/backend/src/lib/userSettings.ts @@ -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; } diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 206148f..fe272c6 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -17,6 +17,10 @@ import { checkProjectAccess } from "../lib/access"; export const chatRouter = Router(); type Db = ReturnType; +const isDev = process.env.NODE_ENV !== "production"; +const devLog = (...args: Parameters) => { + 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, }); diff --git a/backend/src/routes/user.ts b/backend/src/routes/user.ts index f0674ed..0df2021 100644 --- a/backend/src/routes/user.ts +++ b/backend/src/routes/user.ts @@ -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); diff --git a/frontend/src/app/(pages)/account/layout.tsx b/frontend/src/app/(pages)/account/layout.tsx index 543638c..32ceb0a 100644 --- a/frontend/src/app/(pages)/account/layout.tsx +++ b/frontend/src/app/(pages)/account/layout.tsx @@ -44,38 +44,58 @@ export default function AccountLayout({ } return ( -
-
-

+
+
+

Settings

+
-
+
+
-
{children}
+
{children}
-
+
); } diff --git a/frontend/src/app/(pages)/account/models/page.tsx b/frontend/src/app/(pages)/account/models/page.tsx index 153de3e..c83d681 100644 --- a/frontend/src/app/(pages)/account/models/page.tsx +++ b/frontend/src/app/(pages)/account/models/page.tsx @@ -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() { +

+ We recommend using a smaller model for tabular + reviews to reduce token costs. +

updateModelPreference("tabularModel", id) } @@ -66,29 +87,33 @@ export default function ModelsAndApiKeysPage() { own instance of Mike.

- Title generation automatically routes to the cheapest model - of whichever provider you’ve configured (Gemini Flash - Lite if a Gemini key is set, otherwise Claude Haiku). + Title generation automatically routes to the cheapest + configured provider model.

- - updateApiKey("claude", value.trim() || null) - } - onRemove={() => updateApiKey("claude", null)} - /> - - updateApiKey("gemini", value.trim() || null) - } - onRemove={() => updateApiKey("gemini", null)} - /> + {API_KEY_FIELDS.map((field) => ( + + updateApiKey( + field.provider, + value.trim() || null, + ) + } + onRemove={() => + updateApiKey(field.provider, null) + } + /> + ))}

@@ -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 ( @@ -145,10 +174,9 @@ function TabularModelDropdown({ {items.map((m) => { const provider = modelGroupToProvider(m.group); - const available = isModelAvailable( - m.id, - apiKeys, - ); + const available = apiKeys + ? isModelAvailable(m.id, apiKeys) + : true; return ( 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; onRemove: () => Promise; }) { @@ -229,7 +259,20 @@ function ApiKeyField({ return (
- {hasSavedKey && ( + {isServerConfigured && ( +
+

+ A server .env key is configured for this provider. + Browser API-key edits are disabled. +

+ {hasSavedKey && ( +

+ The server key will be used for this provider. +

+ )} +
+ )} + {hasSavedKey && !isServerConfigured && (

A key is saved. Paste a new key to replace it.

@@ -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} />
- {hasSavedKey && ( + {hasSavedKey && !isServerConfigured && ( {actionsOpen && ( -
+
{tab === "documents" && ( - {contextMenu.showFolderActions && contextMenu.folderId && ( - <> - - - - )} -
- )} + {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 ( +
e.stopPropagation()} + > + {menuDoc ? ( + 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) + } + /> + ) : ( + 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" + /> + )} +
+ ); + })()}
{/* end blue ring wrapper */} diff --git a/frontend/src/app/components/shared/RowActions.tsx b/frontend/src/app/components/shared/RowActions.tsx index 54c348a..5a43bd1 100644 --- a/frontend/src/app/components/shared/RowActions.tsx +++ b/frontend/src/app/components/shared/RowActions.tsx @@ -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 && ( + + )} + {onRename && ( + + )} + {onUpdateCmNumber && ( + + )} + {onDownload && ( + + )} + {onShowAllVersions && ( + + )} + {onUploadNewVersion && ( + + )} + {onRemoveFromFolder && ( + + )} + {onUnhide && ( + + )} + {onHide && ( + + )} + {onDelete && ( + + )} + + ); +} + +export function RowActions(props: Props) { const [open, setOpen] = useState(false); const [coords, setCoords] = useState({ top: 0, right: 0 }); const btnRef = useRef(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 && (
e.stopPropagation()} > - {onRename && ( - - )} - {onUpdateCmNumber && ( - - )} - {onDownload && ( - - )} - {onShowAllVersions && ( - - )} - {onUploadNewVersion && ( - - )} - {onRemoveFromFolder && ( - - )} - {onUnhide && ( - - )} - {onHide && ( - - )} - {onDelete && ( - - )} + setOpen(false)} + />
)} diff --git a/frontend/src/app/components/tabular/TRChatPanel.tsx b/frontend/src/app/components/tabular/TRChatPanel.tsx index 86e3caa..e066486 100644 --- a/frontend/src/app/components/tabular/TRChatPanel.tsx +++ b/frontend/src/app/components/tabular/TRChatPanel.tsx @@ -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(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; } diff --git a/frontend/src/app/components/tabular/TabularReviewView.tsx b/frontend/src/app/components/tabular/TabularReviewView.tsx index af87589..2ac834a 100644 --- a/frontend/src/app/components/tabular/TabularReviewView.tsx +++ b/frontend/src/app/components/tabular/TabularReviewView.tsx @@ -87,10 +87,7 @@ export function TRView({ reviewId, projectId }: Props) { const tableRef = useRef(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; } diff --git a/frontend/src/app/hooks/useAssistantChat.ts b/frontend/src/app/hooks/useAssistantChat.ts index 968a4d8..1a28ac1 100644 --- a/frontend/src/app/hooks/useAssistantChat.ts +++ b/frontend/src/app/hooks/useAssistantChat.ts @@ -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) => { diff --git a/frontend/src/app/lib/mikeApi.ts b/frontend/src/app/lib/mikeApi.ts index 8857411..d574fea 100644 --- a/frontend/src/app/lib/mikeApi.ts +++ b/frontend/src/app/lib/mikeApi.ts @@ -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 & { + sources?: Partial>; }; export async function getApiKeyStatus(): Promise { @@ -134,7 +143,7 @@ export async function getApiKeyStatus(): Promise { } export async function saveApiKey( - provider: keyof ApiKeyStatus, + provider: ApiKeyProvider, apiKey: string | null, ): Promise { return apiRequest(`/user/api-keys/${provider}`, { diff --git a/frontend/src/app/lib/modelAvailability.ts b/frontend/src/app/lib/modelAvailability.ts index 933a8c2..fe41f46 100644 --- a/frontend/src/app/lib/modelAvailability.ts +++ b/frontend/src/app/lib/modelAvailability.ts @@ -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"; } diff --git a/frontend/src/contexts/UserProfileContext.tsx b/frontend/src/contexts/UserProfileContext.tsx index b082fcd..cb166ca 100644 --- a/frontend/src/contexts/UserProfileContext.tsx +++ b/frontend/src/contexts/UserProfileContext.tsx @@ -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; updateApiKey: ( - provider: "claude" | "gemini", + provider: ApiKeyProvider, value: string | null, ) => Promise; reloadProfile: () => Promise; @@ -49,14 +50,31 @@ const UserProfileContext = createContext( 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 => { 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, );