feat: add OpenAI model support and harden OSS security defaults

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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