mirror of
https://github.com/willchen96/mike.git
synced 2026-07-02 22:01:00 +02:00
feat: add OpenAI model support and harden OSS security defaults
This commit is contained in:
parent
adc2cf2370
commit
bef75b082d
24 changed files with 1301 additions and 364 deletions
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
|
|
@ -2,4 +2,5 @@ node_modules
|
||||||
dist
|
dist
|
||||||
.env*
|
.env*
|
||||||
*.log
|
*.log
|
||||||
|
logs/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ create trigger on_auth_user_created
|
||||||
create table if not exists public.user_api_keys (
|
create table if not exists public.user_api_keys (
|
||||||
id uuid primary key default gen_random_uuid(),
|
id uuid primary key default gen_random_uuid(),
|
||||||
user_id uuid not null references auth.users(id) on delete cascade,
|
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,
|
encrypted_key text not null,
|
||||||
iv text not null,
|
iv text not null,
|
||||||
auth_tag 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()
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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 <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.
|
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).
|
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.
|
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.
|
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:
|
DOCUMENT EDITING:
|
||||||
|
|
@ -459,8 +460,19 @@ type ParsedCitation = {
|
||||||
function normalizeCitation(raw: unknown): ParsedCitation | null {
|
function normalizeCitation(raw: unknown): ParsedCitation | null {
|
||||||
if (!raw || typeof raw !== "object") return null;
|
if (!raw || typeof raw !== "object") return null;
|
||||||
const c = raw as Record<string, unknown>;
|
const c = raw as Record<string, unknown>;
|
||||||
if (typeof c.ref !== "number" || typeof c.doc_id !== "string") return null;
|
const markerRef =
|
||||||
if (typeof c.quote !== "string" || !c.quote) return null;
|
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;
|
let page: number | string;
|
||||||
if (typeof c.page === "number") {
|
if (typeof c.page === "number") {
|
||||||
page = c.page;
|
page = c.page;
|
||||||
|
|
@ -468,10 +480,10 @@ function normalizeCitation(raw: unknown): ParsedCitation | null {
|
||||||
page = c.page;
|
page = c.page;
|
||||||
} else {
|
} else {
|
||||||
const n = parseInt(String(c.page ?? ""), 10);
|
const n = parseInt(String(c.page ?? ""), 10);
|
||||||
if (!Number.isFinite(n)) return null;
|
if (!Number.isFinite(n)) page = 1;
|
||||||
page = n;
|
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;
|
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
|
* Append a tool-activity summary to the most recent assistant message so
|
||||||
* the model can see what it just did (read / create / edit / workflow
|
* the model can see what it just did (read / create / edit / workflow
|
||||||
|
|
@ -725,6 +747,8 @@ export async function generateDocx(
|
||||||
BorderStyle,
|
BorderStyle,
|
||||||
TextRun,
|
TextRun,
|
||||||
AlignmentType,
|
AlignmentType,
|
||||||
|
LevelFormat,
|
||||||
|
LevelSuffix,
|
||||||
PageOrientation,
|
PageOrientation,
|
||||||
PageBreak,
|
PageBreak,
|
||||||
} = await import("docx");
|
} = await import("docx");
|
||||||
|
|
@ -766,42 +790,236 @@ export async function generateDocx(
|
||||||
HeadingLevel.HEADING_3,
|
HeadingLevel.HEADING_3,
|
||||||
HeadingLevel.HEADING_4,
|
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;
|
heading?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
level?: number;
|
level?: number;
|
||||||
pageBreak?: boolean;
|
pageBreak?: boolean;
|
||||||
table?: { headers: string[]; rows: string[][] };
|
table?: { headers: string[]; rows: string[][] };
|
||||||
}[]) {
|
}[]).entries()) {
|
||||||
if (section.pageBreak) {
|
if (section.pageBreak) {
|
||||||
children.push(new Paragraph({ children: [new PageBreak()] }));
|
children.push(new Paragraph({ children: [new PageBreak()] }));
|
||||||
}
|
}
|
||||||
if (section.heading) {
|
if (section.heading) {
|
||||||
const idx = Math.min((section.level ?? 1) - 1, 3);
|
const stripped = stripManualNumbering(section.heading);
|
||||||
counters[idx]++;
|
const isUnnumbered = isUnnumberedHeading(
|
||||||
for (let i = idx + 1; i < 4; i++) counters[i] = 0;
|
stripped.text,
|
||||||
const prefix = counters.slice(0, idx + 1).join(".");
|
sectionIndex,
|
||||||
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 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 normalizedTable = normalizeTable(section.table);
|
||||||
const { headers, rows } = section.table;
|
if (normalizedTable) {
|
||||||
|
const { headers, rows } = normalizedTable;
|
||||||
const colCount = headers.length;
|
const colCount = headers.length;
|
||||||
const tableRows: InstanceType<typeof TableRow>[] = [];
|
const tableRows: InstanceType<typeof TableRow>[] = [];
|
||||||
// Header row
|
// Header row
|
||||||
|
|
@ -834,19 +1052,7 @@ export async function generateDocx(
|
||||||
// LLMs occasionally emit malformed rows (extra fragments from
|
// LLMs occasionally emit malformed rows (extra fragments from
|
||||||
// stray delimiters, or short rows); padding/truncating here
|
// stray delimiters, or short rows); padding/truncating here
|
||||||
// keeps the rendered table aligned to the headers.
|
// keeps the rendered table aligned to the headers.
|
||||||
for (const rawRow of rows) {
|
for (const normalized 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`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
tableRows.push(
|
tableRows.push(
|
||||||
new TableRow({
|
new TableRow({
|
||||||
children: normalized.map(
|
children: normalized.map(
|
||||||
|
|
@ -878,38 +1084,55 @@ export async function generateDocx(
|
||||||
children.push(new Paragraph({ text: "" }));
|
children.push(new Paragraph({ text: "" }));
|
||||||
}
|
}
|
||||||
if (section.content) {
|
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")) {
|
for (const line of section.content.split("\n")) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
const bulletMatch = trimmed.match(/^[-•*]\s+(.+)/);
|
const bulletMatch = trimmed.match(/^[-•*]\s+(.+)/);
|
||||||
if (bulletMatch) {
|
const rawText = bulletMatch
|
||||||
children.push(
|
? bulletMatch[1].trim()
|
||||||
new Paragraph({
|
: trimmed;
|
||||||
bullet: { level: 0 },
|
const manualList = parseManualListMarker(rawText);
|
||||||
spacing: { after: 120 },
|
const numeric = stripManualNumbering(rawText);
|
||||||
children: [
|
const text = bulletMatch
|
||||||
new TextRun({
|
? rawText
|
||||||
text: bulletMatch[1],
|
: manualList.levelOffset !== null
|
||||||
font: FONT,
|
? manualList.text
|
||||||
size: SIZE,
|
: numeric.text;
|
||||||
}),
|
const inferredLevel =
|
||||||
],
|
currentClauseLevel === null || contentIsSignatureBlock
|
||||||
}),
|
? undefined
|
||||||
);
|
: bulletMatch
|
||||||
} else {
|
? currentClauseLevel + 2
|
||||||
children.push(
|
: manualList.levelOffset !== null
|
||||||
new Paragraph({
|
? currentClauseLevel + manualList.levelOffset
|
||||||
spacing: { after: 120 },
|
: numeric.levelFromPrefix !== null
|
||||||
children: [
|
? numeric.levelFromPrefix
|
||||||
new TextRun({
|
: numberedBodyParagraphs === 0
|
||||||
text: trimmed,
|
? currentClauseLevel + 1
|
||||||
font: FONT,
|
: currentClauseLevel + 2;
|
||||||
size: SIZE,
|
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({
|
const doc = new Document({
|
||||||
|
numbering: {
|
||||||
|
config: [
|
||||||
|
{
|
||||||
|
reference: LEGAL_NUMBERING_REF,
|
||||||
|
levels: legalNumberingLevels,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
sections: [{ properties: pageSetup, children }],
|
sections: [{ properties: pageSetup, children }],
|
||||||
});
|
});
|
||||||
const buf = await Packer.toBuffer(doc);
|
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 docId = crypto.randomUUID().replace(/-/g, "");
|
||||||
const safeTitle =
|
const safeTitle =
|
||||||
title
|
title
|
||||||
|
|
@ -1644,7 +1888,13 @@ export async function runToolCalls(
|
||||||
const filename = docStore.get(docId)?.filename;
|
const filename = docStore.get(docId)?.filename;
|
||||||
const documentId = docIndex?.[docId]?.document_id;
|
const documentId = docIndex?.[docId]?.document_id;
|
||||||
if (filename) docsRead.push({ filename, document_id: documentId });
|
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") {
|
} else if (tc.function.name === "find_in_document") {
|
||||||
const rawDocId = args.doc_id as string;
|
const rawDocId = args.doc_id as string;
|
||||||
const docId =
|
const docId =
|
||||||
|
|
@ -1714,7 +1964,9 @@ export async function runToolCalls(
|
||||||
db,
|
db,
|
||||||
);
|
);
|
||||||
const filename = docStore.get(docId)?.filename ?? docId;
|
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)) {
|
if (docStore.get(docId)) {
|
||||||
const documentId = docIndex?.[docId]?.document_id;
|
const documentId = docIndex?.[docId]?.document_id;
|
||||||
docsRead.push({ filename, document_id: documentId });
|
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
|
// model can pass it as `doc_id` to edit_document / read_document
|
||||||
// / find_in_document in the same turn. Without this the model
|
// / 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.
|
// 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
|
const toolResultPayload = newDocLabel
|
||||||
? {
|
? {
|
||||||
...(result as Record<string, unknown>),
|
...safeToolResult,
|
||||||
doc_id: newDocLabel,
|
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({
|
toolResults.push({
|
||||||
role: "tool",
|
role: "tool",
|
||||||
tool_call_id: tc.id,
|
tool_call_id: tc.id,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { streamClaude, completeClaudeText } from "./claude";
|
import { streamClaude, completeClaudeText } from "./claude";
|
||||||
import { streamGemini, completeGeminiText } from "./gemini";
|
import { streamGemini, completeGeminiText } from "./gemini";
|
||||||
|
import { streamOpenAI, completeOpenAIText } from "./openai";
|
||||||
import { providerForModel } from "./models";
|
import { providerForModel } from "./models";
|
||||||
import type { StreamChatParams, StreamChatResult, UserApiKeys } from "./types";
|
import type { StreamChatParams, StreamChatResult, UserApiKeys } from "./types";
|
||||||
|
|
||||||
|
|
@ -11,6 +12,7 @@ export async function streamChatWithTools(
|
||||||
): Promise<StreamChatResult> {
|
): Promise<StreamChatResult> {
|
||||||
const provider = providerForModel(params.model);
|
const provider = providerForModel(params.model);
|
||||||
if (provider === "claude") return streamClaude(params);
|
if (provider === "claude") return streamClaude(params);
|
||||||
|
if (provider === "openai") return streamOpenAI(params);
|
||||||
return streamGemini(params);
|
return streamGemini(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -23,5 +25,6 @@ export async function completeText(params: {
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
const provider = providerForModel(params.model);
|
const provider = providerForModel(params.model);
|
||||||
if (provider === "claude") return completeClaudeText(params);
|
if (provider === "claude") return completeClaudeText(params);
|
||||||
|
if (provider === "openai") return completeOpenAIText(params);
|
||||||
return completeGeminiText(params);
|
return completeGeminiText(params);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,15 +9,18 @@ export const GEMINI_MAIN_MODELS = [
|
||||||
"gemini-3.1-pro-preview",
|
"gemini-3.1-pro-preview",
|
||||||
"gemini-3-flash-preview",
|
"gemini-3-flash-preview",
|
||||||
] as const;
|
] 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.
|
// 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 CLAUDE_MID_MODELS = ["claude-sonnet-4-6"] as const;
|
||||||
export const GEMINI_MID_MODELS = ["gemini-3-flash-preview"] 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
|
// Low-tier (used for title generation, lightweight extractions) — user picks
|
||||||
// one in account settings.
|
// one in account settings.
|
||||||
export const CLAUDE_LOW_MODELS = ["claude-haiku-4-5"] as const;
|
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 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_MAIN_MODEL = "gemini-3-flash-preview";
|
||||||
export const DEFAULT_TITLE_MODEL = "gemini-3.1-flash-lite-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>([
|
const ALL_MODELS = new Set<string>([
|
||||||
...CLAUDE_MAIN_MODELS,
|
...CLAUDE_MAIN_MODELS,
|
||||||
...GEMINI_MAIN_MODELS,
|
...GEMINI_MAIN_MODELS,
|
||||||
|
...OPENAI_MAIN_MODELS,
|
||||||
...CLAUDE_MID_MODELS,
|
...CLAUDE_MID_MODELS,
|
||||||
...GEMINI_MID_MODELS,
|
...GEMINI_MID_MODELS,
|
||||||
|
...OPENAI_MID_MODELS,
|
||||||
...CLAUDE_LOW_MODELS,
|
...CLAUDE_LOW_MODELS,
|
||||||
...GEMINI_LOW_MODELS,
|
...GEMINI_LOW_MODELS,
|
||||||
|
...OPENAI_LOW_MODELS,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -39,6 +45,7 @@ const ALL_MODELS = new Set<string>([
|
||||||
export function providerForModel(model: string): Provider {
|
export function providerForModel(model: string): Provider {
|
||||||
if (model.startsWith("claude")) return "claude";
|
if (model.startsWith("claude")) return "claude";
|
||||||
if (model.startsWith("gemini")) return "gemini";
|
if (model.startsWith("gemini")) return "gemini";
|
||||||
|
if (model.startsWith("gpt-")) return "openai";
|
||||||
throw new Error(`Unknown model id: ${model}`);
|
throw new Error(`Unknown model id: ${model}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
291
backend/src/lib/llm/openai.ts
Normal file
291
backend/src/lib/llm/openai.ts
Normal 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 };
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// Callers always speak OpenAI-style tools + { role, content } messages; each
|
// Callers always speak OpenAI-style tools + { role, content } messages; each
|
||||||
// provider translates internally.
|
// provider translates internally.
|
||||||
|
|
||||||
export type Provider = "claude" | "gemini";
|
export type Provider = "claude" | "gemini" | "openai";
|
||||||
|
|
||||||
export type OpenAIToolSchema = {
|
export type OpenAIToolSchema = {
|
||||||
type: "function";
|
type: "function";
|
||||||
|
|
@ -39,6 +39,7 @@ export type StreamCallbacks = {
|
||||||
export type UserApiKeys = {
|
export type UserApiKeys = {
|
||||||
claude?: string | null;
|
claude?: string | null;
|
||||||
gemini?: string | null;
|
gemini?: string | null;
|
||||||
|
openai?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StreamChatParams = {
|
export type StreamChatParams = {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,11 @@ import { createServerSupabase } from "./supabase";
|
||||||
import type { UserApiKeys } from "./llm";
|
import type { UserApiKeys } from "./llm";
|
||||||
|
|
||||||
type Db = ReturnType<typeof createServerSupabase>;
|
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 = {
|
type EncryptedKeyRow = {
|
||||||
provider: ApiKeyProvider;
|
provider: ApiKeyProvider;
|
||||||
|
|
@ -12,7 +16,25 @@ type EncryptedKeyRow = {
|
||||||
auth_tag: string;
|
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 {
|
function encryptionKey(): Buffer {
|
||||||
const secret =
|
const secret =
|
||||||
|
|
@ -72,12 +94,25 @@ export function normalizeApiKeyProvider(value: string): ApiKeyProvider | null {
|
||||||
export async function getUserApiKeyStatus(
|
export async function getUserApiKeyStatus(
|
||||||
userId: string,
|
userId: string,
|
||||||
db: Db = createServerSupabase(),
|
db: Db = createServerSupabase(),
|
||||||
): Promise<Record<ApiKeyProvider, boolean>> {
|
): Promise<ApiKeyStatus> {
|
||||||
const status: Record<ApiKeyProvider, boolean> = {
|
const status: ApiKeyStatus = {
|
||||||
claude: false,
|
claude: false,
|
||||||
gemini: 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
|
const { data, error } = await db
|
||||||
.from("user_api_keys")
|
.from("user_api_keys")
|
||||||
.select("provider")
|
.select("provider")
|
||||||
|
|
@ -86,7 +121,10 @@ export async function getUserApiKeyStatus(
|
||||||
|
|
||||||
for (const row of data ?? []) {
|
for (const row of data ?? []) {
|
||||||
const provider = normalizeApiKeyProvider(String(row.provider));
|
const provider = normalizeApiKeyProvider(String(row.provider));
|
||||||
if (provider) status[provider] = true;
|
if (provider && !status[provider]) {
|
||||||
|
status[provider] = true;
|
||||||
|
status.sources[provider] = "user";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return status;
|
return status;
|
||||||
|
|
@ -96,7 +134,11 @@ export async function getUserApiKeys(
|
||||||
userId: string,
|
userId: string,
|
||||||
db: Db = createServerSupabase(),
|
db: Db = createServerSupabase(),
|
||||||
): Promise<UserApiKeys> {
|
): 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
|
const { data, error } = await db
|
||||||
.from("user_api_keys")
|
.from("user_api_keys")
|
||||||
|
|
@ -107,6 +149,7 @@ export async function getUserApiKeys(
|
||||||
for (const row of (data ?? []) as EncryptedKeyRow[]) {
|
for (const row of (data ?? []) as EncryptedKeyRow[]) {
|
||||||
const provider = normalizeApiKeyProvider(row.provider);
|
const provider = normalizeApiKeyProvider(row.provider);
|
||||||
if (!provider) continue;
|
if (!provider) continue;
|
||||||
|
if (apiKeys[provider]?.trim()) continue;
|
||||||
apiKeys[provider] = decrypt(row);
|
apiKeys[provider] = decrypt(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
resolveModel,
|
resolveModel,
|
||||||
DEFAULT_TITLE_MODEL,
|
DEFAULT_TITLE_MODEL,
|
||||||
DEFAULT_TABULAR_MODEL,
|
DEFAULT_TABULAR_MODEL,
|
||||||
|
OPENAI_LOW_MODELS,
|
||||||
type UserApiKeys,
|
type UserApiKeys,
|
||||||
} from "./llm";
|
} from "./llm";
|
||||||
import { getUserApiKeys as getStoredUserApiKeys } from "./userApiKeys";
|
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
|
// 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
|
// 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
|
// available, otherwise OpenAI nano, otherwise Claude Haiku. With no user keys
|
||||||
// (the dev-mode env fallback).
|
// set, defaults to Gemini (the dev-mode env fallback).
|
||||||
function resolveTitleModel(apiKeys: UserApiKeys): string {
|
function resolveTitleModel(apiKeys: UserApiKeys): string {
|
||||||
if (apiKeys.gemini?.trim()) return DEFAULT_TITLE_MODEL;
|
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";
|
if (apiKeys.claude?.trim()) return "claude-haiku-4-5";
|
||||||
return DEFAULT_TITLE_MODEL;
|
return DEFAULT_TITLE_MODEL;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ import { checkProjectAccess } from "../lib/access";
|
||||||
export const chatRouter = Router();
|
export const chatRouter = Router();
|
||||||
|
|
||||||
type Db = ReturnType<typeof createServerSupabase>;
|
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 = {
|
type AccessibleChat = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -436,7 +440,7 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
||||||
const project_id = parsedProjectId.projectId;
|
const project_id = parsedProjectId.projectId;
|
||||||
const model = parsedModel.model;
|
const model = parsedModel.model;
|
||||||
|
|
||||||
console.log("[chat/stream] incoming request", {
|
devLog("[chat/stream] incoming request", {
|
||||||
userId,
|
userId,
|
||||||
chat_id,
|
chat_id,
|
||||||
project_id,
|
project_id,
|
||||||
|
|
@ -497,7 +501,7 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
||||||
chatTitle = newChat.title;
|
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");
|
const lastUser = [...messages].reverse().find((m) => m.role === "user");
|
||||||
if (lastUser) {
|
if (lastUser) {
|
||||||
|
|
@ -530,7 +534,7 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
||||||
|
|
||||||
const workflowStore = await buildWorkflowStore(userId, userEmail, db);
|
const workflowStore = await buildWorkflowStore(userId, userEmail, db);
|
||||||
|
|
||||||
console.log("[chat/stream] starting LLM stream", {
|
devLog("[chat/stream] starting LLM stream", {
|
||||||
apiMessageCount: apiMessages.length,
|
apiMessageCount: apiMessages.length,
|
||||||
docCount: Object.keys(docIndex).length,
|
docCount: Object.keys(docIndex).length,
|
||||||
workflowCount: Object.keys(workflowStore).length,
|
workflowCount: Object.keys(workflowStore).length,
|
||||||
|
|
@ -562,7 +566,7 @@ chatRouter.post("/", requireAuth, async (req, res) => {
|
||||||
projectId: resolvedProjectId,
|
projectId: resolvedProjectId,
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("[chat/stream] LLM stream finished", {
|
devLog("[chat/stream] LLM stream finished", {
|
||||||
fullTextLen: fullText?.length ?? 0,
|
fullTextLen: fullText?.length ?? 0,
|
||||||
eventCount: events?.length ?? 0,
|
eventCount: events?.length ?? 0,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,9 @@ import { requireAuth } from "../middleware/auth";
|
||||||
import { createServerSupabase } from "../lib/supabase";
|
import { createServerSupabase } from "../lib/supabase";
|
||||||
import { DEFAULT_TABULAR_MODEL, resolveModel } from "../lib/llm";
|
import { DEFAULT_TABULAR_MODEL, resolveModel } from "../lib/llm";
|
||||||
import {
|
import {
|
||||||
|
type ApiKeyStatus,
|
||||||
getUserApiKeyStatus,
|
getUserApiKeyStatus,
|
||||||
|
hasEnvApiKey,
|
||||||
normalizeApiKeyProvider,
|
normalizeApiKeyProvider,
|
||||||
saveUserApiKey,
|
saveUserApiKey,
|
||||||
} from "../lib/userApiKeys";
|
} from "../lib/userApiKeys";
|
||||||
|
|
@ -23,7 +25,7 @@ type UserProfileRow = {
|
||||||
|
|
||||||
function serializeProfile(
|
function serializeProfile(
|
||||||
row: UserProfileRow,
|
row: UserProfileRow,
|
||||||
apiKeyStatus?: { claude: boolean; gemini: boolean },
|
apiKeyStatus?: ApiKeyStatus,
|
||||||
) {
|
) {
|
||||||
const creditsUsed = row.message_credits_used ?? 0;
|
const creditsUsed = row.message_credits_used ?? 0;
|
||||||
return {
|
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;
|
typeof req.body?.api_key === "string" ? req.body.api_key : null;
|
||||||
const db = createServerSupabase();
|
const db = createServerSupabase();
|
||||||
try {
|
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);
|
await saveUserApiKey(userId, provider, apiKey, db);
|
||||||
const status = await getUserApiKeyStatus(userId, db);
|
const status = await getUserApiKeyStatus(userId, db);
|
||||||
res.json(status);
|
res.json(status);
|
||||||
|
|
|
||||||
|
|
@ -44,38 +44,58 @@ export default function AccountLayout({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full md:overflow-y-auto px-6 py-6 md:py-10">
|
<div className="flex h-full flex-col overflow-y-auto">
|
||||||
<div className="max-w-5xl w-full mx-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 mb-8 font-eb-garamond">
|
<h1 className="text-4xl font-medium font-eb-garamond">
|
||||||
Settings
|
Settings
|
||||||
</h1>
|
</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
|
<nav
|
||||||
aria-label="Settings"
|
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) => {
|
<div className="-m-1 min-w-0 p-1">
|
||||||
const active = pathname === tab.href;
|
<div className="-m-1 min-w-0 overflow-x-auto overflow-y-hidden p-1">
|
||||||
return (
|
<ul className="mb-0 flex gap-1 md:flex-col">
|
||||||
<button
|
{TABS.map((tab) => {
|
||||||
key={tab.id}
|
const active =
|
||||||
onClick={() => router.push(tab.href)}
|
pathname === tab.href ||
|
||||||
className={`text-left whitespace-nowrap px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
(tab.href !== "/account" &&
|
||||||
active
|
pathname.startsWith(tab.href));
|
||||||
? "bg-gray-100 text-gray-900"
|
return (
|
||||||
: "text-gray-500 hover:text-gray-900 hover:bg-gray-50"
|
<li key={tab.id}>
|
||||||
}`}
|
<button
|
||||||
>
|
type="button"
|
||||||
{tab.label}
|
aria-current={
|
||||||
</button>
|
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>
|
</nav>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">{children}</div>
|
<div className="min-w-0 outline-none">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,32 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { useUserProfile } from "@/contexts/UserProfileContext";
|
import { useUserProfile } from "@/contexts/UserProfileContext";
|
||||||
|
import type { ApiKeyState } from "@/app/lib/mikeApi";
|
||||||
import { MODELS } from "@/app/components/assistant/ModelToggle";
|
import { MODELS } from "@/app/components/assistant/ModelToggle";
|
||||||
import {
|
import {
|
||||||
isModelAvailable,
|
isModelAvailable,
|
||||||
modelGroupToProvider,
|
modelGroupToProvider,
|
||||||
|
providerLabel,
|
||||||
} from "@/app/lib/modelAvailability";
|
} 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() {
|
export default function ModelsAndApiKeysPage() {
|
||||||
const { profile, updateModelPreference, updateApiKey } = useUserProfile();
|
const { profile, updateModelPreference, updateApiKey } = useUserProfile();
|
||||||
|
|
||||||
|
|
@ -36,15 +56,16 @@ export default function ModelsAndApiKeysPage() {
|
||||||
<label className="text-sm text-gray-600 block mb-2">
|
<label className="text-sm text-gray-600 block mb-2">
|
||||||
Tabular review model
|
Tabular review model
|
||||||
</label>
|
</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
|
<TabularModelDropdown
|
||||||
value={
|
value={
|
||||||
profile?.tabularModel ??
|
profile?.tabularModel ??
|
||||||
"gemini-3-flash-preview"
|
"gemini-3-flash-preview"
|
||||||
}
|
}
|
||||||
apiKeys={{
|
apiKeys={profile?.apiKeys}
|
||||||
claudeApiKey: profile?.claudeApiKey ?? null,
|
|
||||||
geminiApiKey: profile?.geminiApiKey ?? null,
|
|
||||||
}}
|
|
||||||
onChange={(id) =>
|
onChange={(id) =>
|
||||||
updateModelPreference("tabularModel", id)
|
updateModelPreference("tabularModel", id)
|
||||||
}
|
}
|
||||||
|
|
@ -66,29 +87,33 @@ export default function ModelsAndApiKeysPage() {
|
||||||
own instance of Mike.
|
own instance of Mike.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-400 mb-4 max-w-xl">
|
<p className="text-xs text-gray-400 mb-4 max-w-xl">
|
||||||
Title generation automatically routes to the cheapest model
|
Title generation automatically routes to the cheapest
|
||||||
of whichever provider you’ve configured (Gemini Flash
|
configured provider model.
|
||||||
Lite if a Gemini key is set, otherwise Claude Haiku).
|
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-4 max-w-xl">
|
<div className="space-y-4 max-w-xl">
|
||||||
<ApiKeyField
|
{API_KEY_FIELDS.map((field) => (
|
||||||
label="Anthropic (Claude) API Key"
|
<ApiKeyField
|
||||||
placeholder="sk-ant-…"
|
key={field.provider}
|
||||||
hasSavedKey={!!profile?.claudeApiKey}
|
label={field.label}
|
||||||
onSave={(value) =>
|
placeholder={field.placeholder}
|
||||||
updateApiKey("claude", value.trim() || null)
|
hasSavedKey={
|
||||||
}
|
!!profile?.apiKeys[field.provider].configured
|
||||||
onRemove={() => updateApiKey("claude", null)}
|
}
|
||||||
/>
|
isServerConfigured={
|
||||||
<ApiKeyField
|
profile?.apiKeys[field.provider].source ===
|
||||||
label="Google (Gemini) API Key"
|
"env"
|
||||||
placeholder="AI…"
|
}
|
||||||
hasSavedKey={!!profile?.geminiApiKey}
|
onSave={(value) =>
|
||||||
onSave={(value) =>
|
updateApiKey(
|
||||||
updateApiKey("gemini", value.trim() || null)
|
field.provider,
|
||||||
}
|
value.trim() || null,
|
||||||
onRemove={() => updateApiKey("gemini", null)}
|
)
|
||||||
/>
|
}
|
||||||
|
onRemove={() =>
|
||||||
|
updateApiKey(field.provider, null)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -102,12 +127,16 @@ function TabularModelDropdown({
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (id: string) => void;
|
onChange: (id: string) => void;
|
||||||
apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null };
|
apiKeys?: ApiKeyState;
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const selected = MODELS.find((m) => m.id === value);
|
const selected = MODELS.find((m) => m.id === value);
|
||||||
const selectedAvailable = isModelAvailable(value, apiKeys);
|
const selectedAvailable = apiKeys ? isModelAvailable(value, apiKeys) : true;
|
||||||
const groups: ("Anthropic" | "Google")[] = ["Anthropic", "Google"];
|
const groups: ("Anthropic" | "Google" | "OpenAI")[] = [
|
||||||
|
"Anthropic",
|
||||||
|
"Google",
|
||||||
|
"OpenAI",
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu onOpenChange={setIsOpen}>
|
<DropdownMenu onOpenChange={setIsOpen}>
|
||||||
|
|
@ -145,10 +174,9 @@ function TabularModelDropdown({
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
{items.map((m) => {
|
{items.map((m) => {
|
||||||
const provider = modelGroupToProvider(m.group);
|
const provider = modelGroupToProvider(m.group);
|
||||||
const available = isModelAvailable(
|
const available = apiKeys
|
||||||
m.id,
|
? isModelAvailable(m.id, apiKeys)
|
||||||
apiKeys,
|
: true;
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={m.id}
|
key={m.id}
|
||||||
|
|
@ -156,7 +184,7 @@ function TabularModelDropdown({
|
||||||
onSelect={() => onChange(m.id)}
|
onSelect={() => onChange(m.id)}
|
||||||
title={
|
title={
|
||||||
!available
|
!available
|
||||||
? `Add a ${provider === "claude" ? "Claude" : "Gemini"} API key to use this model`
|
? `Add a ${providerLabel(provider)} API key to use this model`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -186,12 +214,14 @@ function ApiKeyField({
|
||||||
label,
|
label,
|
||||||
placeholder,
|
placeholder,
|
||||||
hasSavedKey,
|
hasSavedKey,
|
||||||
|
isServerConfigured,
|
||||||
onSave,
|
onSave,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
hasSavedKey: boolean;
|
hasSavedKey: boolean;
|
||||||
|
isServerConfigured: boolean;
|
||||||
onSave: (value: string) => Promise<boolean>;
|
onSave: (value: string) => Promise<boolean>;
|
||||||
onRemove: () => Promise<boolean>;
|
onRemove: () => Promise<boolean>;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -229,7 +259,20 @@ function ApiKeyField({
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-gray-600 block mb-2">{label}</label>
|
<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">
|
<p className="text-xs text-gray-500 mb-2">
|
||||||
A key is saved. Paste a new key to replace it.
|
A key is saved. Paste a new key to replace it.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -240,15 +283,23 @@ function ApiKeyField({
|
||||||
type={reveal ? "text" : "password"}
|
type={reveal ? "text" : "password"}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => setValue(e.target.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"
|
className="pr-10"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
|
disabled={isServerConfigured}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setReveal((r) => !r)}
|
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"}
|
aria-label={reveal ? "Hide key" : "Show key"}
|
||||||
>
|
>
|
||||||
{reveal ? (
|
{reveal ? (
|
||||||
|
|
@ -260,7 +311,7 @@ function ApiKeyField({
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
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"
|
className="min-w-[80px] transition-all bg-black hover:bg-gray-900 text-white"
|
||||||
>
|
>
|
||||||
{isSaving ? (
|
{isSaving ? (
|
||||||
|
|
@ -274,7 +325,7 @@ function ApiKeyField({
|
||||||
"Save"
|
"Save"
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{hasSavedKey && (
|
{hasSavedKey && !isServerConfigured && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,19 @@ import { EditCard, applyOptimisticResolution } from "./EditCard";
|
||||||
import { PreResponseWrapper } from "../shared/PreResponseWrapper";
|
import { PreResponseWrapper } from "../shared/PreResponseWrapper";
|
||||||
import { supabase } from "@/lib/supabase";
|
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
|
* Card rendered above the per-edit EditCards when a message produced
|
||||||
* multiple tracked-change proposals. Lets the user resolve every pending
|
* 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="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" />
|
<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="font-medium ml-2">
|
||||||
<span className="ml-1">
|
{toolCallLabel(event.name)}
|
||||||
{event.name ? `${event.name}...` : "tool..."}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -67,12 +67,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput(
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [model, setModel] = useSelectedModel();
|
const [model, setModel] = useSelectedModel();
|
||||||
const { profile } = useUserProfile();
|
const { profile } = useUserProfile();
|
||||||
const apiKeys = profile
|
const apiKeys = profile?.apiKeys;
|
||||||
? {
|
|
||||||
claudeApiKey: profile?.claudeApiKey ?? null,
|
|
||||||
geminiApiKey: profile?.geminiApiKey ?? null,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const [docSelectorOpen, setDocSelectorOpen] = useState(false);
|
const [docSelectorOpen, setDocSelectorOpen] = useState(false);
|
||||||
const [workflowModalOpen, setWorkflowModalOpen] = useState(false);
|
const [workflowModalOpen, setWorkflowModalOpen] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,12 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { isModelAvailable } from "@/app/lib/modelAvailability";
|
import { isModelAvailable } from "@/app/lib/modelAvailability";
|
||||||
|
import type { ApiKeyState } from "@/app/lib/mikeApi";
|
||||||
|
|
||||||
export interface ModelOption {
|
export interface ModelOption {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
group: "Anthropic" | "Google";
|
group: "Anthropic" | "Google" | "OpenAI";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MODELS: ModelOption[] = [
|
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: "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.1-pro-preview", label: "Gemini 3.1 Pro", group: "Google" },
|
||||||
{ id: "gemini-3-flash-preview", label: "Gemini 3 Flash", 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 DEFAULT_MODEL_ID = "gemini-3-flash-preview";
|
||||||
|
|
||||||
export const ALLOWED_MODEL_IDS = new Set(MODELS.map((m) => m.id));
|
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 {
|
interface Props {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (id: string) => void;
|
onChange: (id: string) => void;
|
||||||
apiKeys?: {
|
apiKeys?: ApiKeyState;
|
||||||
claudeApiKey: string | null;
|
|
||||||
geminiApiKey: string | null;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelToggle({ value, onChange, apiKeys }: Props) {
|
export function ModelToggle({ value, onChange, apiKeys }: Props) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { type CSSProperties, useEffect, useRef, useState } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
Upload,
|
Upload,
|
||||||
|
|
@ -54,7 +54,11 @@ import type {
|
||||||
} from "@/app/components/shared/types";
|
} from "@/app/components/shared/types";
|
||||||
import { ToolbarTabs } from "@/app/components/shared/ToolbarTabs";
|
import { ToolbarTabs } from "@/app/components/shared/ToolbarTabs";
|
||||||
import { RenameableTitle } from "@/app/components/shared/RenameableTitle";
|
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 { AddDocumentsModal } from "@/app/components/shared/AddDocumentsModal";
|
||||||
import { PeopleModal } from "@/app/components/shared/PeopleModal";
|
import { PeopleModal } from "@/app/components/shared/PeopleModal";
|
||||||
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
|
import { OwnerOnlyModal } from "@/app/components/shared/OwnerOnlyModal";
|
||||||
|
|
@ -74,12 +78,35 @@ type Tab = "documents" | "assistant" | "reviews";
|
||||||
type ContextMenu = {
|
type ContextMenu = {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
docId?: string | null;
|
||||||
folderId: string | null; // null = right-clicked on root/empty space
|
folderId: string | null; // null = right-clicked on root/empty space
|
||||||
showFolderActions: boolean; // true when right-clicked on a specific folder row
|
showFolderActions: boolean; // true when right-clicked on a specific folder row
|
||||||
};
|
};
|
||||||
|
|
||||||
const CHECK_W = "w-8 shrink-0";
|
const CHECK_W = "w-8 shrink-0";
|
||||||
const NAME_COL_W = "w-[300px] 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 {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
|
@ -113,6 +140,7 @@ function DocVersionHistory({
|
||||||
filename,
|
filename,
|
||||||
loading,
|
loading,
|
||||||
versions,
|
versions,
|
||||||
|
depth = 0,
|
||||||
onDownloadVersion,
|
onDownloadVersion,
|
||||||
onOpenVersion,
|
onOpenVersion,
|
||||||
onRenameVersion,
|
onRenameVersion,
|
||||||
|
|
@ -121,6 +149,7 @@ function DocVersionHistory({
|
||||||
filename: string;
|
filename: string;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
versions: MikeDocumentVersion[];
|
versions: MikeDocumentVersion[];
|
||||||
|
depth?: number;
|
||||||
onDownloadVersion: (
|
onDownloadVersion: (
|
||||||
docId: string,
|
docId: string,
|
||||||
versionId: string,
|
versionId: string,
|
||||||
|
|
@ -150,8 +179,8 @@ function DocVersionHistory({
|
||||||
if (loading && versions.length === 0) {
|
if (loading && versions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center h-9 border-b border-gray-50 text-xs text-gray-500 bg-gray-50/60">
|
<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-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`}>
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<Loader2 className="h-3 w-3 animate-spin text-gray-400" />
|
<Loader2 className="h-3 w-3 animate-spin text-gray-400" />
|
||||||
<span>Loading versions…</span>
|
<span>Loading versions…</span>
|
||||||
|
|
@ -163,8 +192,8 @@ function DocVersionHistory({
|
||||||
if (versions.length === 0) {
|
if (versions.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center h-9 border-b border-gray-50 text-xs text-gray-400 bg-gray-50/60">
|
<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-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`}>
|
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-gray-50/60 p-2`} style={treeNameCellStyle(depth)}>
|
||||||
<div>
|
<div>
|
||||||
No version history.
|
No version history.
|
||||||
</div>
|
</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"
|
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-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`}>
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="shrink-0 text-gray-400">↳</span>
|
<span className="shrink-0 text-gray-400">↳</span>
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
|
|
@ -846,7 +875,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
||||||
|
|
||||||
// ── Tree rendering ────────────────────────────────────────────────────────
|
// ── Tree rendering ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function renderFolderInput(parentId: string | null) {
|
function renderFolderInput(parentId: string | null, depth: number) {
|
||||||
if (creatingFolderIn !== parentId) return null;
|
if (creatingFolderIn !== parentId) return null;
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
className="group flex items-center h-10 pr-8 border-b border-gray-50"
|
||||||
key={`new-folder-${parentId ?? "root"}`}
|
key={`new-folder-${parentId ?? "root"}`}
|
||||||
>
|
>
|
||||||
<div className={`sticky left-0 z-[60] ${CHECK_W} bg-white self-stretch`} />
|
<div
|
||||||
<div className={`sticky left-8 z-[60] ${NAME_COL_W} bg-white p-2`}>
|
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">
|
<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" />
|
<FolderPlus className="h-4 w-4 text-amber-400 shrink-0" />
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
|
|
@ -911,7 +947,18 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
||||||
setViewingDocVersion(null);
|
setViewingDocVersion(null);
|
||||||
setViewingDoc(doc);
|
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"
|
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
|
<div
|
||||||
className={`sticky left-0 z-[60] ${CHECK_W} p-2 flex items-center justify-center ${rowBg} group-hover:bg-gray-50`}
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<input
|
<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"
|
className="h-2.5 w-2.5 rounded border-gray-200 cursor-pointer accent-black"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
{isProcessing ? (
|
{isProcessing ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400 shrink-0" />
|
<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}
|
filename={doc.filename}
|
||||||
loading={loadingVersionDocIds.has(doc.id)}
|
loading={loadingVersionDocIds.has(doc.id)}
|
||||||
versions={versionsByDocId.get(doc.id) ?? []}
|
versions={versionsByDocId.get(doc.id) ?? []}
|
||||||
|
depth={depth}
|
||||||
onDownloadVersion={downloadDocVersion}
|
onDownloadVersion={downloadDocVersion}
|
||||||
onOpenVersion={(versionId, label) => {
|
onOpenVersion={(versionId, label) => {
|
||||||
setViewingDocVersion({ id: versionId, label });
|
setViewingDocVersion({ id: versionId, label });
|
||||||
|
|
@ -1042,17 +1091,18 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
closeRowActionMenus();
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, folderId: folder.id, showFolderActions: true });
|
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" : ""}`}
|
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
|
{isExpanded
|
||||||
? <ChevronDown className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
? <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" />
|
: <ChevronRight className="h-3.5 w-3.5 text-gray-400 shrink-0" />
|
||||||
}
|
}
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-1.5">
|
||||||
{isExpanded
|
{isExpanded
|
||||||
? <FolderOpen className="h-4 w-4 text-amber-500 shrink-0" />
|
? <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 */}
|
{/* 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" />
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
{actionsOpen && (
|
{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" && (
|
{tab === "documents" && (
|
||||||
<button
|
<button
|
||||||
onClick={handleDownloadSelectedDocs}
|
onClick={handleDownloadSelectedDocs}
|
||||||
|
|
@ -1365,7 +1415,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
||||||
{/* Blue ring wraps everything below the header when root-dropping */}
|
{/* Blue ring wraps everything below the header when root-dropping */}
|
||||||
<div className="flex-1 flex flex-col min-h-0 relative">
|
<div className="flex-1 flex flex-col min-h-0 relative">
|
||||||
{dragOverRoot && dragOverFolderId === null && (
|
{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 */}
|
{/* Empty state */}
|
||||||
|
|
@ -1382,6 +1432,7 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
||||||
className="flex-1 flex flex-col"
|
className="flex-1 flex flex-col"
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
closeRowActionMenus();
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, folderId: null, showFolderActions: false });
|
setContextMenu({ x: e.clientX, y: e.clientY, folderId: null, showFolderActions: false });
|
||||||
}}
|
}}
|
||||||
onClick={() => setContextMenu(null)}
|
onClick={() => setContextMenu(null)}
|
||||||
|
|
@ -1414,6 +1465,18 @@ export function ProjectPage({ projectId, initialTab = "documents" }: Props) {
|
||||||
setViewingDocVersion(null);
|
setViewingDocVersion(null);
|
||||||
setViewingDoc(doc);
|
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"
|
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()}>
|
<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 */}
|
{/* Context menu */}
|
||||||
{contextMenu && (
|
{contextMenu &&
|
||||||
<div
|
(() => {
|
||||||
ref={contextMenuRef}
|
const menuDoc = contextMenu.docId
|
||||||
className="fixed z-50 w-44 rounded-lg border border-gray-100 bg-white shadow-lg overflow-hidden text-xs"
|
? docs.find((doc) => doc.id === contextMenu.docId)
|
||||||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
: null;
|
||||||
onClick={(e) => e.stopPropagation()}
|
const menuDocHasVersions =
|
||||||
>
|
typeof menuDoc?.latest_version_number === "number" &&
|
||||||
<button
|
menuDoc.latest_version_number >= 1;
|
||||||
className="w-full px-3 py-1.5 text-left text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
const menuDocVersionsOpen = menuDoc
|
||||||
onClick={() => {
|
? expandedVersionDocIds.has(menuDoc.id)
|
||||||
setCreatingFolderIn(contextMenu.folderId);
|
: false;
|
||||||
setNewFolderName("");
|
|
||||||
if (contextMenu.folderId) setExpandedFolderIds((prev) => new Set([...prev, contextMenu.folderId!]));
|
return (
|
||||||
setContextMenu(null);
|
<div
|
||||||
}}
|
ref={contextMenuRef}
|
||||||
>
|
className="fixed z-[120] w-48 rounded-xl border border-gray-100 bg-white shadow-lg overflow-hidden"
|
||||||
<FolderPlus className="h-3.5 w-3.5 text-gray-400" />
|
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||||
{contextMenu.showFolderActions ? "New subfolder inside" : "New subfolder"}
|
onClick={(e) => e.stopPropagation()}
|
||||||
</button>
|
>
|
||||||
{contextMenu.showFolderActions && contextMenu.folderId && (
|
{menuDoc ? (
|
||||||
<>
|
<RowActionMenuItems
|
||||||
<button
|
onClose={() => setContextMenu(null)}
|
||||||
className="w-full px-3 py-1.5 text-left text-gray-700 hover:bg-gray-50"
|
onDownload={() => downloadDoc(menuDoc.id)}
|
||||||
onClick={() => {
|
onShowAllVersions={
|
||||||
const f = folders.find((x) => x.id === contextMenu.folderId);
|
menuDocHasVersions && !menuDocVersionsOpen
|
||||||
setRenameFolderValue(f?.name ?? "");
|
? () => void toggleVersions(menuDoc.id)
|
||||||
setRenamingFolderId(contextMenu.folderId!);
|
: undefined
|
||||||
setContextMenu(null);
|
}
|
||||||
}}
|
onUploadNewVersion={() =>
|
||||||
>
|
void handleUploadNewVersion(menuDoc)
|
||||||
Rename folder
|
}
|
||||||
</button>
|
onRemoveFromFolder={
|
||||||
<button
|
menuDoc.folder_id
|
||||||
className="w-full px-3 py-1.5 text-left text-red-600 hover:bg-red-50"
|
? () =>
|
||||||
onClick={() => {
|
void handleRemoveDocFromFolder(
|
||||||
handleDeleteFolder(contextMenu.folderId!);
|
menuDoc.id,
|
||||||
setContextMenu(null);
|
)
|
||||||
}}
|
: undefined
|
||||||
>
|
}
|
||||||
Delete folder
|
onDelete={() =>
|
||||||
</button>
|
void handleRemoveDoc(menuDoc.id)
|
||||||
</>
|
}
|
||||||
)}
|
/>
|
||||||
</div>
|
) : (
|
||||||
)}
|
<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>{/* end blue ring wrapper */}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,24 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
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 {
|
interface Props {
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
|
|
@ -11,12 +28,130 @@ interface Props {
|
||||||
onRemoveFromFolder?: () => void;
|
onRemoveFromFolder?: () => void;
|
||||||
onShowAllVersions?: () => void;
|
onShowAllVersions?: () => void;
|
||||||
onUploadNewVersion?: () => void;
|
onUploadNewVersion?: () => void;
|
||||||
|
onNewSubfolder?: () => void;
|
||||||
deleting?: boolean;
|
deleting?: boolean;
|
||||||
onRename?: () => void;
|
onRename?: () => void;
|
||||||
onUpdateCmNumber?: () => 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 [open, setOpen] = useState(false);
|
||||||
const [coords, setCoords] = useState({ top: 0, right: 0 });
|
const [coords, setCoords] = useState({ top: 0, right: 0 });
|
||||||
const btnRef = useRef<HTMLButtonElement>(null);
|
const btnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
@ -30,16 +165,33 @@ export function RowActions({ onDelete, onHide, onUnhide, onDownload, onRemoveFro
|
||||||
return () => document.removeEventListener("click", handleClick);
|
return () => document.removeEventListener("click", handleClick);
|
||||||
}, [open]);
|
}, [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) {
|
function handleToggle(e: React.MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (!open && btnRef.current) {
|
if (open) {
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeRowActionMenus();
|
||||||
|
if (btnRef.current) {
|
||||||
const rect = btnRef.current.getBoundingClientRect();
|
const rect = btnRef.current.getBoundingClientRect();
|
||||||
setCoords({
|
setCoords({
|
||||||
top: rect.bottom + 4,
|
top: rect.bottom + 4,
|
||||||
right: window.innerWidth - rect.right,
|
right: window.innerWidth - rect.right,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setOpen((o) => !o);
|
setOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -55,91 +207,13 @@ export function RowActions({ onDelete, onHide, onUnhide, onDownload, onRemoveFro
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
style={{ position: "fixed", top: coords.top, right: coords.right }}
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{onRename && (
|
<RowActionMenuItems
|
||||||
<button
|
{...props}
|
||||||
onClick={() => { setOpen(false); onRename(); }}
|
onClose={() => setOpen(false)}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import {
|
||||||
isModelAvailable,
|
isModelAvailable,
|
||||||
type ModelProvider,
|
type ModelProvider,
|
||||||
} from "@/app/lib/modelAvailability";
|
} from "@/app/lib/modelAvailability";
|
||||||
|
import type { ApiKeyState } from "@/app/lib/mikeApi";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
|
|
@ -454,7 +455,7 @@ function TRChatInput({
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
model: string;
|
model: string;
|
||||||
onModelChange: (id: string) => void;
|
onModelChange: (id: string) => void;
|
||||||
apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null };
|
apiKeys?: ApiKeyState;
|
||||||
onHeightChange: (height: number) => void;
|
onHeightChange: (height: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
|
|
@ -642,10 +643,7 @@ export function TRChatPanel({
|
||||||
onChatIdChange,
|
onChatIdChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { profile, updateModelPreference } = useUserProfile();
|
const { profile, updateModelPreference } = useUserProfile();
|
||||||
const apiKeys = {
|
const apiKeys = profile?.apiKeys;
|
||||||
claudeApiKey: profile?.claudeApiKey ?? null,
|
|
||||||
geminiApiKey: profile?.geminiApiKey ?? null,
|
|
||||||
};
|
|
||||||
const currentModel = profile?.tabularModel ?? "gemini-3-flash-preview";
|
const currentModel = profile?.tabularModel ?? "gemini-3-flash-preview";
|
||||||
const [apiKeyModalProvider, setApiKeyModalProvider] =
|
const [apiKeyModalProvider, setApiKeyModalProvider] =
|
||||||
useState<ModelProvider | null>(null);
|
useState<ModelProvider | null>(null);
|
||||||
|
|
@ -993,7 +991,7 @@ export function TRChatPanel({
|
||||||
|
|
||||||
async function handleSubmit(trimmed: string) {
|
async function handleSubmit(trimmed: string) {
|
||||||
if (!trimmed || isLoading) return;
|
if (!trimmed || isLoading) return;
|
||||||
if (!isModelAvailable(currentModel, apiKeys)) {
|
if (apiKeys && !isModelAvailable(currentModel, apiKeys)) {
|
||||||
setApiKeyModalProvider(getModelProvider(currentModel));
|
setApiKeyModalProvider(getModelProvider(currentModel));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,10 +87,7 @@ export function TRView({ reviewId, projectId }: Props) {
|
||||||
const tableRef = useRef<TRTableHandle>(null);
|
const tableRef = useRef<TRTableHandle>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { profile } = useUserProfile();
|
const { profile } = useUserProfile();
|
||||||
const apiKeys = {
|
const apiKeys = profile?.apiKeys;
|
||||||
claudeApiKey: profile?.claudeApiKey ?? null,
|
|
||||||
geminiApiKey: profile?.geminiApiKey ?? null,
|
|
||||||
};
|
|
||||||
const tabularModel = profile?.tabularModel ?? "gemini-3-flash-preview";
|
const tabularModel = profile?.tabularModel ?? "gemini-3-flash-preview";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -243,7 +240,7 @@ export function TRView({ reviewId, projectId }: Props) {
|
||||||
// If columns changed since last save, update the review first
|
// If columns changed since last save, update the review first
|
||||||
if (columns.length === 0) return;
|
if (columns.length === 0) return;
|
||||||
|
|
||||||
if (!isModelAvailable(tabularModel, apiKeys)) {
|
if (apiKeys && !isModelAvailable(tabularModel, apiKeys)) {
|
||||||
setApiKeyModalProvider(getModelProvider(tabularModel));
|
setApiKeyModalProvider(getModelProvider(tabularModel));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -250,11 +250,11 @@ export function useAssistantChat({
|
||||||
const pushEvent = (event: AssistantEvent) => {
|
const pushEvent = (event: AssistantEvent) => {
|
||||||
finalizeStreamingContent();
|
finalizeStreamingContent();
|
||||||
finalizeStreamingReasoning();
|
finalizeStreamingReasoning();
|
||||||
// Drop any in-flight placeholder unless we're pushing one ourselves.
|
// A real event, or a more specific placeholder such as
|
||||||
let next = eventsRef.current;
|
// tool_call_start, should replace any generic "Thinking..." line.
|
||||||
if (event.type !== "tool_call_start" && event.type !== "thinking") {
|
const next = eventsRef.current.filter(
|
||||||
next = next.filter((e) => !isStreamingPlaceholder(e));
|
(e) => !isStreamingPlaceholder(e),
|
||||||
}
|
);
|
||||||
eventsRef.current = [...next, event];
|
eventsRef.current = [...next, event];
|
||||||
const snapshot = [...eventsRef.current];
|
const snapshot = [...eventsRef.current];
|
||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
|
|
|
||||||
|
|
@ -124,9 +124,18 @@ export async function updateUserProfile(payload: {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ApiKeyStatus = {
|
export type ApiKeyProvider = "claude" | "gemini" | "openai";
|
||||||
claude: boolean;
|
export type ApiKeySource = "user" | "env" | null;
|
||||||
gemini: boolean;
|
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> {
|
export async function getApiKeyStatus(): Promise<ApiKeyStatus> {
|
||||||
|
|
@ -134,7 +143,7 @@ export async function getApiKeyStatus(): Promise<ApiKeyStatus> {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveApiKey(
|
export async function saveApiKey(
|
||||||
provider: keyof ApiKeyStatus,
|
provider: ApiKeyProvider,
|
||||||
apiKey: string | null,
|
apiKey: string | null,
|
||||||
): Promise<ApiKeyStatus> {
|
): Promise<ApiKeyStatus> {
|
||||||
return apiRequest<ApiKeyStatus>(`/user/api-keys/${provider}`, {
|
return apiRequest<ApiKeyStatus>(`/user/api-keys/${provider}`, {
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,40 @@
|
||||||
import { MODELS, type ModelOption } from "../components/assistant/ModelToggle";
|
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 {
|
export function getModelProvider(modelId: string): ModelProvider | null {
|
||||||
const model = MODELS.find((m) => m.id === modelId);
|
const model = MODELS.find((m) => m.id === modelId);
|
||||||
if (!model) return null;
|
if (!model) return null;
|
||||||
return model.group === "Anthropic" ? "claude" : "gemini";
|
return modelGroupToProvider(model.group);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isModelAvailable(
|
export function isModelAvailable(
|
||||||
modelId: string,
|
modelId: string,
|
||||||
apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null },
|
apiKeys: ApiKeyState,
|
||||||
): boolean {
|
): boolean {
|
||||||
const provider = getModelProvider(modelId);
|
const provider = getModelProvider(modelId);
|
||||||
if (!provider) return false;
|
if (!provider) return false;
|
||||||
return provider === "claude"
|
return isProviderAvailable(provider, apiKeys);
|
||||||
? !!apiKeys.claudeApiKey?.trim()
|
|
||||||
: !!apiKeys.geminiApiKey?.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isProviderAvailable(
|
export function isProviderAvailable(
|
||||||
provider: ModelProvider,
|
provider: ModelProvider,
|
||||||
apiKeys: { claudeApiKey: string | null; geminiApiKey: string | null },
|
apiKeys: ApiKeyState,
|
||||||
): boolean {
|
): boolean {
|
||||||
return provider === "claude"
|
return !!apiKeys[provider]?.configured;
|
||||||
? !!apiKeys.claudeApiKey?.trim()
|
|
||||||
: !!apiKeys.geminiApiKey?.trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function providerLabel(provider: ModelProvider): string {
|
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(
|
export function modelGroupToProvider(
|
||||||
group: ModelOption["group"],
|
group: ModelOption["group"],
|
||||||
): ModelProvider {
|
): ModelProvider {
|
||||||
return group === "Anthropic" ? "claude" : "gemini";
|
if (group === "Anthropic") return "claude";
|
||||||
|
if (group === "OpenAI") return "openai";
|
||||||
|
return "gemini";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import React, {
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import {
|
import {
|
||||||
|
type ApiKeyState,
|
||||||
|
type ApiKeyProvider,
|
||||||
type UserProfile as ApiUserProfile,
|
type UserProfile as ApiUserProfile,
|
||||||
getUserProfile,
|
getUserProfile,
|
||||||
saveApiKey,
|
saveApiKey,
|
||||||
|
|
@ -24,8 +26,7 @@ interface UserProfile {
|
||||||
creditsRemaining: number;
|
creditsRemaining: number;
|
||||||
tier: string;
|
tier: string;
|
||||||
tabularModel: string;
|
tabularModel: string;
|
||||||
claudeApiKey: string | null;
|
apiKeys: ApiKeyState;
|
||||||
geminiApiKey: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserProfileContextType {
|
interface UserProfileContextType {
|
||||||
|
|
@ -38,7 +39,7 @@ interface UserProfileContextType {
|
||||||
value: string,
|
value: string,
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
updateApiKey: (
|
updateApiKey: (
|
||||||
provider: "claude" | "gemini",
|
provider: ApiKeyProvider,
|
||||||
value: string | null,
|
value: string | null,
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
reloadProfile: () => Promise<void>;
|
reloadProfile: () => Promise<void>;
|
||||||
|
|
@ -49,14 +50,31 @@ const UserProfileContext = createContext<UserProfileContextType | undefined>(
|
||||||
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 {
|
function toProfile(data: ApiUserProfile): UserProfile {
|
||||||
const { apiKeyStatus, ...profile } = data;
|
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 {
|
return {
|
||||||
...profile,
|
...profile,
|
||||||
claudeApiKey: apiKeyStatus.claude ? CONFIGURED_KEY_MARKER : null,
|
apiKeys,
|
||||||
geminiApiKey: apiKeyStatus.gemini ? CONFIGURED_KEY_MARKER : null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,8 +101,7 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
|
||||||
creditsRemaining: 999999, // temporarily unlimited
|
creditsRemaining: 999999, // temporarily unlimited
|
||||||
tier: "Free",
|
tier: "Free",
|
||||||
tabularModel: "gemini-3-flash-preview",
|
tabularModel: "gemini-3-flash-preview",
|
||||||
claudeApiKey: null,
|
apiKeys: emptyApiKeys(),
|
||||||
geminiApiKey: null,
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -157,12 +174,10 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
const updateApiKey = useCallback(
|
const updateApiKey = useCallback(
|
||||||
async (
|
async (
|
||||||
provider: "claude" | "gemini",
|
provider: ApiKeyProvider,
|
||||||
value: string | null,
|
value: string | null,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
const stateField =
|
|
||||||
provider === "claude" ? "claudeApiKey" : "geminiApiKey";
|
|
||||||
const normalized = value?.trim() ? value.trim() : null;
|
const normalized = value?.trim() ? value.trim() : null;
|
||||||
try {
|
try {
|
||||||
await saveApiKey(provider, normalized);
|
await saveApiKey(provider, normalized);
|
||||||
|
|
@ -170,9 +185,13 @@ export function UserProfileProvider({ children }: { children: ReactNode }) {
|
||||||
prev
|
prev
|
||||||
? {
|
? {
|
||||||
...prev,
|
...prev,
|
||||||
[stateField]: normalized
|
apiKeys: {
|
||||||
? CONFIGURED_KEY_MARKER
|
...prev.apiKeys,
|
||||||
: null,
|
[provider]: {
|
||||||
|
configured: !!normalized,
|
||||||
|
source: normalized ? "user" : null,
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue