From 2ef02b8f72dbbecdc4b465d24cbf7956c38477fd Mon Sep 17 00:00:00 2001 From: "Hieu Nguyen (Jack)" Date: Sun, 27 Sep 2026 15:20:32 +0700 Subject: [PATCH] update agent scope --- drizzle/migrations/0034_add_agent_enabled.sql | 1 + .../0035_add_mailbox_agent_model.sql | 1 + .../migrations/0036_add_agent_ai_usage.sql | 12 ++++++ .../migrations/0037_generalize_ai_usage.sql | 3 ++ .../0038_scope_standard_api_keys.sql | 1 + drizzle/migrations/meta/_journal.json | 35 +++++++++++++++ src/app/(admin)/agent/page.tsx | 38 ++++++++++++---- src/app/(admin)/agent/types.d.ts | 7 +-- src/app/(admin)/agent/utils.ts | 14 +++++- src/app/(admin)/ai-usage/page.tsx | 35 +++++++++++++++ src/app/(admin)/ai-usage/types.d.ts | 4 ++ src/app/(admin)/ai-usage/usage-chart.tsx | 23 ++++++++++ src/app/(admin)/ai-usage/utils.ts | 43 +++++++++++++++++++ src/app/(admin)/api-keys/page.tsx | 5 ++- .../assistant-availability-types.d.ts | 1 + src/app/(dashboard)/dashboard-state-utils.ts | 23 ++++++++++ src/app/(dashboard)/dashboard-state.ts | 27 ++++++++---- src/app/(dashboard)/layout.tsx | 23 +++++++--- .../(dashboard)/use-assistant-availability.ts | 40 +++++++++++++++++ src/app/api/admin/agent/utils.ts | 34 +++++++++++---- src/app/api/admin/ai-usage/route.ts | 1 + src/app/api/admin/ai-usage/utils.ts | 38 ++++++++++++++++ src/app/api/admin/api-keys/utils.ts | 5 ++- src/app/api/agent/availability/route.ts | 1 + src/app/api/agent/availability/utils.ts | 9 ++++ src/app/api/agent/settings/route.ts | 15 ++++--- src/app/api/api-keys/route.ts | 18 +++++--- src/app/api/v1/messages/route.ts | 7 ++- src/app/api/v1/send/route.ts | 3 ++ src/components/agent/agent-panel.tsx | 39 +++++++++-------- src/components/agent/agent-turn.tsx | 6 +-- src/components/agent/tool-activity.tsx | 16 ++++--- src/components/agent/types.d.ts | 4 +- src/components/column-width-preferences.ts | 25 ++++++++++- .../messages/message-list-visibility-utils.ts | 12 ++++++ .../messages/message-split-layout.tsx | 30 +++++++++---- .../settings/api-keys-settings-types.d.ts | 1 + .../settings/api-keys-settings-utils.ts | 2 +- src/components/settings/api-keys-settings.tsx | 19 ++++---- .../mcp-agent-instructions-types.d.ts | 4 ++ .../settings/mcp-agent-instructions-utils.ts | 10 +++++ .../settings/mcp-agent-instructions.tsx | 22 ++++++++++ src/components/sidebar-state.tsx | 24 ++++++++--- src/db/schema/index.ts | 16 +++++++ src/lib/agent/availability-client.ts | 14 ++++++ src/lib/agent/chat.ts | 10 +++-- src/lib/agent/jobs/utils.ts | 19 +++++--- src/lib/agent/model-ids.ts | 8 ++++ src/lib/agent/model.ts | 10 +++-- src/lib/agent/pricing.ts | 21 +++++++++ src/lib/agent/provider-types.d.ts | 3 ++ src/lib/agent/provider.ts | 26 ++++++++--- src/lib/ai/usage-types.d.ts | 5 +++ src/lib/ai/usage.ts | 21 +++++++++ src/lib/api/key-auth-types.d.ts | 1 + src/lib/api/key-auth.ts | 7 ++- src/lib/jmap/access.ts | 2 +- src/lib/jmap/emails.ts | 2 +- src/lib/jmap/identities.ts | 5 ++- 59 files changed, 716 insertions(+), 135 deletions(-) create mode 100644 drizzle/migrations/0034_add_agent_enabled.sql create mode 100644 drizzle/migrations/0035_add_mailbox_agent_model.sql create mode 100644 drizzle/migrations/0036_add_agent_ai_usage.sql create mode 100644 drizzle/migrations/0037_generalize_ai_usage.sql create mode 100644 drizzle/migrations/0038_scope_standard_api_keys.sql create mode 100644 src/app/(admin)/ai-usage/page.tsx create mode 100644 src/app/(admin)/ai-usage/types.d.ts create mode 100644 src/app/(admin)/ai-usage/usage-chart.tsx create mode 100644 src/app/(admin)/ai-usage/utils.ts create mode 100644 src/app/(dashboard)/assistant-availability-types.d.ts create mode 100644 src/app/(dashboard)/dashboard-state-utils.ts create mode 100644 src/app/(dashboard)/use-assistant-availability.ts create mode 100644 src/app/api/admin/ai-usage/route.ts create mode 100644 src/app/api/admin/ai-usage/utils.ts create mode 100644 src/app/api/agent/availability/route.ts create mode 100644 src/app/api/agent/availability/utils.ts create mode 100644 src/components/messages/message-list-visibility-utils.ts create mode 100644 src/components/settings/mcp-agent-instructions-types.d.ts create mode 100644 src/components/settings/mcp-agent-instructions-utils.ts create mode 100644 src/components/settings/mcp-agent-instructions.tsx create mode 100644 src/lib/agent/availability-client.ts create mode 100644 src/lib/agent/model-ids.ts create mode 100644 src/lib/agent/pricing.ts create mode 100644 src/lib/ai/usage-types.d.ts create mode 100644 src/lib/ai/usage.ts diff --git a/drizzle/migrations/0034_add_agent_enabled.sql b/drizzle/migrations/0034_add_agent_enabled.sql new file mode 100644 index 0000000..adac117 --- /dev/null +++ b/drizzle/migrations/0034_add_agent_enabled.sql @@ -0,0 +1 @@ +ALTER TABLE `app_settings` ADD `agent_enabled` integer DEFAULT 1 NOT NULL; diff --git a/drizzle/migrations/0035_add_mailbox_agent_model.sql b/drizzle/migrations/0035_add_mailbox_agent_model.sql new file mode 100644 index 0000000..4b954b3 --- /dev/null +++ b/drizzle/migrations/0035_add_mailbox_agent_model.sql @@ -0,0 +1 @@ +ALTER TABLE `mailbox_agent_settings` ADD `model_id` text; diff --git a/drizzle/migrations/0036_add_agent_ai_usage.sql b/drizzle/migrations/0036_add_agent_ai_usage.sql new file mode 100644 index 0000000..349682c --- /dev/null +++ b/drizzle/migrations/0036_add_agent_ai_usage.sql @@ -0,0 +1,12 @@ +ALTER TABLE `app_settings` ADD `agent_model_rates` text; +CREATE TABLE `agent_ai_usage` ( + `id` text PRIMARY KEY NOT NULL, + `created_at` integer NOT NULL, + `provider` text NOT NULL, + `model` text NOT NULL, + `source` text NOT NULL, + `input_tokens` integer, + `output_tokens` integer, + `cost_usd_micros` integer +); +CREATE INDEX `agent_ai_usage_created_idx` ON `agent_ai_usage` (`created_at`); diff --git a/drizzle/migrations/0037_generalize_ai_usage.sql b/drizzle/migrations/0037_generalize_ai_usage.sql new file mode 100644 index 0000000..b6c30af --- /dev/null +++ b/drizzle/migrations/0037_generalize_ai_usage.sql @@ -0,0 +1,3 @@ +ALTER TABLE `agent_ai_usage` RENAME TO `ai_usage`; +DROP INDEX `agent_ai_usage_created_idx`; +CREATE INDEX `ai_usage_created_idx` ON `ai_usage` (`created_at`); diff --git a/drizzle/migrations/0038_scope_standard_api_keys.sql b/drizzle/migrations/0038_scope_standard_api_keys.sql new file mode 100644 index 0000000..668c950 --- /dev/null +++ b/drizzle/migrations/0038_scope_standard_api_keys.sql @@ -0,0 +1 @@ +ALTER TABLE api_keys ADD COLUMN mailbox_scope_enabled integer NOT NULL DEFAULT 0; diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 25e00bc..097c951 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -239,6 +239,41 @@ "when": 1788850800005, "tag": "0033_add_agent_provider_settings", "breakpoints": true + }, + { + "idx": 34, + "version": "6", + "when": 1788850800006, + "tag": "0034_add_agent_enabled", + "breakpoints": true + }, + { + "idx": 35, + "version": "6", + "when": 1788850800007, + "tag": "0035_add_mailbox_agent_model", + "breakpoints": true + }, + { + "idx": 36, + "version": "6", + "when": 1788850800008, + "tag": "0036_add_agent_ai_usage", + "breakpoints": true + }, + { + "idx": 37, + "version": "6", + "when": 1788850800009, + "tag": "0037_generalize_ai_usage", + "breakpoints": true + }, + { + "idx": 38, + "version": "6", + "when": 1788850800010, + "tag": "0038_scope_standard_api_keys", + "breakpoints": true } ] } diff --git a/src/app/(admin)/agent/page.tsx b/src/app/(admin)/agent/page.tsx index 228c9d3..7152b4c 100644 --- a/src/app/(admin)/agent/page.tsx +++ b/src/app/(admin)/agent/page.tsx @@ -1,20 +1,28 @@ "use client"; +import Link from "next/link"; import { useEffect, useState } from "react"; import { Bot } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; import { DEFAULT_CLOUDFLARE_MODEL } from "@/lib/agent/provider-constants"; +import { saveAssistantAvailability } from "@/lib/agent/availability-client"; +import { parseAgentModelIds, toggleAgentModelId } from "@/lib/agent/model-ids"; import type { AgentModelOption, AgentProviderPreset } from "@/lib/agent/provider-types"; import type { AgentAdminForm } from "./types"; -import { baseUrlForPreset, loadAgentAdminSettings, loadAgentModels, PROVIDER_PRESETS, saveAgentAdminSettings } from "./utils"; +import { baseUrlForPreset, loadAgentAdminSettings, loadAgentModels, PROVIDER_PRESETS, saveAgentAdminSettings, saveAgentEnabled, updateAgentModelRate } from "./utils"; -const initialForm: AgentAdminForm = { provider: "cloudflare", preset: "openai", baseUrl: "", apiKey: "", model: DEFAULT_CLOUDFLARE_MODEL }; +const initialForm: AgentAdminForm = { provider: "cloudflare", preset: "openai", baseUrl: "", apiKey: "", model: DEFAULT_CLOUDFLARE_MODEL, rates: {} }; export default function AdminAgentPage() { const [form, setForm] = useState(initialForm); + const [assistantEnabled, setAssistantEnabled] = useState(true); + const [enabledSaving, setEnabledSaving] = useState(false); + const [enabledStatus, setEnabledStatus] = useState(null); const [loaded, setLoaded] = useState(false); const [cloudflareAvailable, setCloudflareAvailable] = useState(false); const [hasSavedKey, setHasSavedKey] = useState(false); @@ -30,7 +38,8 @@ export default function AdminAgentPage() { let active = true; void loadAgentAdminSettings().then((data) => { if (!active) return; - setForm({ provider: data.config.provider, preset: data.config.preset, baseUrl: data.config.baseUrl, apiKey: "", model: data.config.model }); + setForm({ provider: data.config.provider, preset: data.config.preset, baseUrl: data.config.baseUrl, apiKey: "", model: data.config.models.join(", "), rates: data.config.rates }); + setAssistantEnabled(data.assistantEnabled); setCloudflareAvailable(data.cloudflareAvailable); setHasSavedKey(data.config.hasApiKey); setSavedEndpoint(`${data.config.preset}|${data.config.baseUrl}`); @@ -40,6 +49,7 @@ export default function AdminAgentPage() { }, []); const canUseSavedKey = hasSavedKey && savedEndpoint === `${form.preset}|${form.baseUrl}`; + const selectedModelIds = parseAgentModelIds(form.model); useEffect(() => { if (!loaded) return; setModels([]); @@ -68,17 +78,28 @@ export default function AdminAgentPage() { setStatus(null); try { const data = await saveAgentAdminSettings(form); - setForm((current) => ({ ...current, apiKey: "" })); + setForm((current) => ({ ...current, apiKey: "", model: data.config.models.join(", "), rates: data.config.rates })); setHasSavedKey(data.config.hasApiKey); setSavedEndpoint(`${data.config.preset}|${data.config.baseUrl}`); - setStatus("Agent provider saved. New chats and auto-drafts will use this model."); + setStatus("Agent provider saved. Assistant settings can now choose from these models."); } catch (error) { setStatus(error instanceof Error ? error.message : "Could not save agent settings"); } finally { setSaving(false); } } return
-

Agent

Choose the AI provider and model used by the email assistant for chat and drafts.

+

Agent

Choose the AI provider and models used by the email assistant for chat and drafts.

View Usage
+ + +

Enable assistant

Show assistant chat and allow automatic drafts for everyone.

+ { + setEnabledSaving(true); + setEnabledStatus(null); + void saveAgentEnabled(checked).then((data) => { setAssistantEnabled(data.assistantEnabled); saveAssistantAvailability(data.assistantEnabled); }).catch((error) => setEnabledStatus(error instanceof Error ? error.message : "Could not update assistant availability")).finally(() => setEnabledSaving(false)); + }} aria-label="Enable assistant" /> +
+ {enabledStatus &&

{enabledStatus}

} +
AI providerEmail content is sent to the selected provider when someone uses the assistant. Sending a draft still requires human approval.
@@ -88,9 +109,10 @@ export default function AdminAgentPage() {
setForm((current) => ({ ...current, baseUrl: event.target.value, model: "" }))} required />
setForm((current) => ({ ...current, apiKey: event.target.value }))} />

{canUseSavedKey ? "An API key is saved on the server. Enter a new one to replace it." : "The API key is stored on the server and is never shown again."}

} -
{modelSource === "suggested" &&

Showing suggested Cloudflare models because a full catalog is unavailable.

}{modelError &&

{modelError}

} setForm((current) => ({ ...current, model: event.target.value }))} placeholder={form.provider === "cloudflare" ? DEFAULT_CLOUDFLARE_MODEL : "provider/model-id"} required />

Choose a model that supports tool calling so the assistant can read mail and create drafts.

+

Models

{modelsLoading &&

Loading models…

}{!modelsLoading && !models.length &&

Models unavailable

}{models.map((item) => )}
{modelSource === "suggested" &&

Showing suggested Cloudflare models because a full catalog is unavailable.

}{modelError &&

{modelError}

} setForm((current) => ({ ...current, model: event.target.value }))} placeholder={form.provider === "cloudflare" ? DEFAULT_CLOUDFLARE_MODEL : "provider/model-id, provider/another-model"} required />

Choose models that support tool calling so the assistant can read mail and create drafts. The first model is the default.

+

Estimated cost rates

Optional USD per 1 million tokens. Usage without both rates shows spending as unavailable.

{selectedModelIds.map((model) =>
{model}
)}
{status &&

{status}

} - +
; diff --git a/src/app/(admin)/agent/types.d.ts b/src/app/(admin)/agent/types.d.ts index cec7bc7..f2ccfc6 100644 --- a/src/app/(admin)/agent/types.d.ts +++ b/src/app/(admin)/agent/types.d.ts @@ -1,6 +1,7 @@ -import type { AgentModelOption, AgentProviderKind, AgentProviderPreset, AgentProviderPublicConfig } from "@/lib/agent/provider-types"; +import type { AgentModelOption, AgentModelRates, AgentProviderKind, AgentProviderPreset, AgentProviderPublicConfig } from "@/lib/agent/provider-types"; export type AgentAdminConfig = AgentProviderPublicConfig; -export type AgentAdminSettingsResponse = { config: AgentAdminConfig; cloudflareAvailable: boolean; error?: string }; +export type AgentAdminSettingsResponse = { config: AgentAdminConfig; assistantEnabled: boolean; cloudflareAvailable: boolean; error?: string }; +export type AgentEnabledResponse = { assistantEnabled: boolean; error?: string }; export type AgentAdminModelsResponse = { models: AgentModelOption[]; source: "catalog" | "suggested"; error?: string }; -export type AgentAdminForm = { provider: AgentProviderKind; preset: AgentProviderPreset; baseUrl: string; apiKey: string; model: string }; +export type AgentAdminForm = { provider: AgentProviderKind; preset: AgentProviderPreset; baseUrl: string; apiKey: string; model: string; rates: Record }; diff --git a/src/app/(admin)/agent/utils.ts b/src/app/(admin)/agent/utils.ts index 11b217e..d80cfed 100644 --- a/src/app/(admin)/agent/utils.ts +++ b/src/app/(admin)/agent/utils.ts @@ -1,7 +1,7 @@ import { authFetch } from "@/lib/auth/client"; import { PROVIDER_BASE_URLS } from "@/lib/agent/provider-constants"; import type { AgentProviderPreset } from "@/lib/agent/provider-types"; -import type { AgentAdminForm, AgentAdminModelsResponse, AgentAdminSettingsResponse } from "./types"; +import type { AgentAdminForm, AgentAdminModelsResponse, AgentAdminSettingsResponse, AgentEnabledResponse } from "./types"; export const PROVIDER_PRESETS: { id: AgentProviderPreset; label: string }[] = [ { id: "openai", label: "OpenAI" }, @@ -14,6 +14,11 @@ export function baseUrlForPreset(preset: AgentProviderPreset, current = "") { return preset === "custom" ? current : PROVIDER_BASE_URLS[preset]; } +export function updateAgentModelRate(form: AgentAdminForm, model: string, kind: "input" | "output", value: string): AgentAdminForm { + const current = form.rates[model] ?? { input: null, output: null }; + return { ...form, rates: { ...form.rates, [model]: { ...current, [kind]: value === "" ? null : Number(value) } } }; +} + export async function loadAgentAdminSettings(): Promise { const response = await authFetch("/api/admin/agent"); const data = await response.json() as AgentAdminSettingsResponse; @@ -34,3 +39,10 @@ export async function saveAgentAdminSettings(form: AgentAdminForm): Promise { + const response = await authFetch("/api/admin/agent", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled }) }); + const data = await response.json() as AgentEnabledResponse; + if (!response.ok) throw new Error(data.error || "Could not update assistant availability"); + return data; +} diff --git a/src/app/(admin)/ai-usage/page.tsx b/src/app/(admin)/ai-usage/page.tsx new file mode 100644 index 0000000..30af3ca --- /dev/null +++ b/src/app/(admin)/ai-usage/page.tsx @@ -0,0 +1,35 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import type { AiUsageResponse } from "./types"; +import { fetchAiUsage, formatEstimatedUsd, formatTokenCount, formatUsageDate, formatUsageProvider } from "./utils"; +import { UsageChart } from "./usage-chart"; + +export default function AiUsagePage() { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loadingPage, setLoadingPage] = useState(false); + + useEffect(() => { + let active = true; + void fetchAiUsage(1).then((result) => { if (active) setData(result); }).catch((cause) => { if (active) setError(cause instanceof Error ? cause.message : "Could not load AI usage"); }); + return () => { active = false; }; + }, []); + + const totals = data?.totals; + return
+

AI Usage

Requests recorded since usage tracking was added.

Back to Agent
+ {error &&

{error}

} +
+

Total tokens

{totals ? formatTokenCount(totals.totalTokens) : "—"}

+

Total requests

{totals ? formatTokenCount(totals.requests) : "—"}

+

Estimated spend

{totals && totals.pricedRequests ? formatEstimatedUsd(totals.costUsdMicros) : "—"}

{totals && totals.pricedRequests < totals.requests &&

{formatTokenCount(totals.requests - totals.pricedRequests)} requests have no rate.

}
+
+ +
{data?.rows.map((row) => )}{data && data.rows.length === 0 && }{!data && !error && }
Date & timeModelProviderInput tokensOutput tokensSpending
{formatUsageDate(row.createdAt)}{row.model}{formatUsageProvider(row.provider)}{formatTokenCount(row.inputTokens)}{formatTokenCount(row.outputTokens)}{formatEstimatedUsd(row.costUsdMicros)}
No AI usage recorded yet.
Loading usage…
+ {data &&
Page {data.page} of {data.totalPages} · {data.pageSize} per page
} +

Spending is estimated from the per-model rates saved in Agent settings. A dash means no rate or token count was available.

+
; +} diff --git a/src/app/(admin)/ai-usage/types.d.ts b/src/app/(admin)/ai-usage/types.d.ts new file mode 100644 index 0000000..1be3726 --- /dev/null +++ b/src/app/(admin)/ai-usage/types.d.ts @@ -0,0 +1,4 @@ +export type AiUsageRow = { id: string; createdAt: string; provider: string; model: string; source: string; inputTokens: number | null; outputTokens: number | null; costUsdMicros: number | null }; +export type AiUsageDaily = { date: string; requests: number; inputTokens: number; outputTokens: number }; +export type AiUsageTotals = { requests: number; inputTokens: number; outputTokens: number; totalTokens: number; costUsdMicros: number; pricedRequests: number }; +export type AiUsageResponse = { totals: AiUsageTotals; daily: AiUsageDaily[]; rows: AiUsageRow[]; page: number; pageSize: number; totalPages: number; error?: string }; diff --git a/src/app/(admin)/ai-usage/usage-chart.tsx b/src/app/(admin)/ai-usage/usage-chart.tsx new file mode 100644 index 0000000..44feefb --- /dev/null +++ b/src/app/(admin)/ai-usage/usage-chart.tsx @@ -0,0 +1,23 @@ +import type { AiUsageDaily } from "./types"; +import { fillDailyUsage, formatDailyLabel, formatTokenCount } from "./utils"; + +export function UsageChart({ daily }: { daily: AiUsageDaily[] }) { + const days = fillDailyUsage(daily); + const maxTokens = Math.max(0, ...days.map((day) => day.inputTokens + day.outputTokens)); + + return
+

Usage by day

Input and output tokens over the last 30 days (UTC)

Input Output
+
+
{formatTokenCount(maxTokens)}0
+
+ {days.map((day, index) => { + const total = day.inputTokens + day.outputTokens; + return
+
+ {index % 5 === 0 || index === days.length - 1 ? formatDailyLabel(day.date) : ""} +
; + })} +
+
+
; +} diff --git a/src/app/(admin)/ai-usage/utils.ts b/src/app/(admin)/ai-usage/utils.ts new file mode 100644 index 0000000..da01b5a --- /dev/null +++ b/src/app/(admin)/ai-usage/utils.ts @@ -0,0 +1,43 @@ +import { authFetch } from "@/lib/auth/client"; +import type { AiUsageDaily, AiUsageResponse } from "./types"; + +export async function fetchAiUsage(page: number): Promise { + const response = await authFetch(`/api/admin/ai-usage?page=${page}`); + const data = await response.json() as AiUsageResponse; + if (!response.ok) throw new Error(data.error || "Could not load AI usage"); + return data; +} + +export function formatTokenCount(value: number | null) { + return value === null ? "—" : new Intl.NumberFormat().format(value); +} + +export function formatEstimatedUsd(micros: number | null) { + if (micros === null) return "—"; + const dollars = micros / 1_000_000; + return `$${dollars.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: dollars < 0.01 && dollars > 0 ? 6 : 2 })}`; +} + +export function formatUsageDate(value: string) { + return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)); +} + +export function formatUsageProvider(value: string) { + return ({ cloudflare: "Cloudflare Workers AI", openai: "OpenAI", openrouter: "OpenRouter", groq: "Groq", custom: "Custom API" } as Record)[value] ?? value; +} + +export function fillDailyUsage(days: AiUsageDaily[]): AiUsageDaily[] { + const byDate = new Map(days.map((day) => [day.date, day])); + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + return Array.from({ length: 30 }, (_, index) => { + const date = new Date(today); + date.setUTCDate(today.getUTCDate() - 29 + index); + const key = date.toISOString().slice(0, 10); + return byDate.get(key) ?? { date: key, requests: 0, inputTokens: 0, outputTokens: 0 }; + }); +} + +export function formatDailyLabel(value: string) { + return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", timeZone: "UTC" }).format(new Date(`${value}T00:00:00Z`)); +} diff --git a/src/app/(admin)/api-keys/page.tsx b/src/app/(admin)/api-keys/page.tsx index a4d904d..972c5d2 100644 --- a/src/app/(admin)/api-keys/page.tsx +++ b/src/app/(admin)/api-keys/page.tsx @@ -18,6 +18,7 @@ import { Label } from "@/components/ui/label"; import { Checkbox } from "@/components/ui/checkbox"; import { List, ListRow } from "@/components/ui/list"; import { SectionRowSkeleton } from "@/components/page-skeletons"; +import { McpAgentInstructions } from "@/components/settings/mcp-agent-instructions"; import { authFetch } from "@/lib/auth/client"; import type { AdminApiKeyScope } from "@/lib/api/scopes-types"; import type { ApiKey } from "./types"; @@ -28,6 +29,7 @@ export default function ApiKeysPage() { const [name, setName] = useState(""); const [scopes, setScopes] = useState(["domains"]); const [mcpAllowed, setMcpAllowed] = useState(false); + const [createdMcpAllowed, setCreatedMcpAllowed] = useState(false); const [newKey, setNewKey] = useState(null); const [copied, setCopied] = useState(false); const [createOpen, setCreateOpen] = useState(false); @@ -51,6 +53,7 @@ export default function ApiKeysPage() { const json = (await res.json()) as { key?: string; error?: string }; if (!res.ok || !json.key) throw new Error(typeof json.error === "string" ? json.error : "Could not create API key"); setNewKey(json.key ?? null); + setCreatedMcpAllowed(mcpAllowed); setCopied(false); setName(""); }, @@ -120,7 +123,7 @@ export default function ApiKeysPage() { {create.isPending ? "Creating..." : "Create key"} } - {newKey &&
{newKey}
} + {newKey &&
{newKey}{createdMcpAllowed && }
} diff --git a/src/app/(dashboard)/assistant-availability-types.d.ts b/src/app/(dashboard)/assistant-availability-types.d.ts new file mode 100644 index 0000000..5e333a3 --- /dev/null +++ b/src/app/(dashboard)/assistant-availability-types.d.ts @@ -0,0 +1 @@ +export type AssistantAvailabilityResponse = { enabled: boolean }; diff --git a/src/app/(dashboard)/dashboard-state-utils.ts b/src/app/(dashboard)/dashboard-state-utils.ts new file mode 100644 index 0000000..166078d --- /dev/null +++ b/src/app/(dashboard)/dashboard-state-utils.ts @@ -0,0 +1,23 @@ +const ASSISTANT_OPEN_KEY = "mailflare-dashboard:assistant-open"; +const ASSISTANT_FULL_SIZE_KEY = "mailflare-dashboard:assistant-full-size"; + +export function readInitialAssistantPanelState() { + if (typeof window === "undefined") return { open: false, fullSize: false }; + try { + return { + open: localStorage.getItem(ASSISTANT_OPEN_KEY) === "true", + fullSize: localStorage.getItem(ASSISTANT_FULL_SIZE_KEY) === "true", + }; + } catch { return { open: false, fullSize: false }; } +} + +export function saveAssistantPanelState(open: boolean, fullSize: boolean, userPrefix: string | null) { + try { + localStorage.setItem(ASSISTANT_OPEN_KEY, String(open)); + localStorage.setItem(ASSISTANT_FULL_SIZE_KEY, String(fullSize)); + if (userPrefix) { + localStorage.setItem(`${userPrefix}:assistant-open`, String(open)); + localStorage.setItem(`${userPrefix}:assistant-full-size`, String(fullSize)); + } + } catch { /* Storage is optional. */ } +} diff --git a/src/app/(dashboard)/dashboard-state.ts b/src/app/(dashboard)/dashboard-state.ts index 702492b..e3b9380 100644 --- a/src/app/(dashboard)/dashboard-state.ts +++ b/src/app/(dashboard)/dashboard-state.ts @@ -1,8 +1,9 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useLayoutEffect, useState } from "react"; import { usePathname, useRouter } from "next/navigation"; import { authFetch } from "@/lib/auth/client"; +import { readInitialAssistantPanelState, saveAssistantPanelState } from "./dashboard-state-utils"; const emailPath = /^\/(?:inbox|sent|archived|spam|trash|starred|snoozed|drafts)\/[^/]+$|^\/folders\/[^/]+\/[^/]+$/; @@ -11,8 +12,16 @@ export function useDashboardState() { const router = useRouter(); const [assistantOpen, setAssistantOpen] = useState(false); const [assistantFullSize, setAssistantFullSize] = useState(false); + const [panelRestored, setPanelRestored] = useState(false); const [storagePrefix, setStoragePrefix] = useState(null); + useLayoutEffect(() => { + const saved = readInitialAssistantPanelState(); + setAssistantOpen(saved.open); + setAssistantFullSize(saved.fullSize); + setPanelRestored(true); + }, []); + useEffect(() => { let cancelled = false; void authFetch("/api/auth/me", { redirectOnUnauthorized: false }).then(async (response) => { @@ -21,8 +30,11 @@ export function useDashboardState() { if (cancelled || !data.user?.id) return; const prefix = `mailflare-dashboard:${data.user.id}`; try { - setAssistantOpen(localStorage.getItem(`${prefix}:assistant-open`) === "true"); - setAssistantFullSize(localStorage.getItem(`${prefix}:assistant-full-size`) === "true"); + const savedOpen = localStorage.getItem(`${prefix}:assistant-open`); + const savedFullSize = localStorage.getItem(`${prefix}:assistant-full-size`); + const initial = readInitialAssistantPanelState(); + setAssistantOpen(savedOpen === null ? initial.open : savedOpen === "true"); + setAssistantFullSize(savedFullSize === null ? initial.fullSize : savedFullSize === "true"); if (window.location.pathname === "/inbox") { const savedEmail = localStorage.getItem(`${prefix}:current-email`); if (savedEmail && emailPath.test(savedEmail)) router.replace(savedEmail); @@ -34,12 +46,9 @@ export function useDashboardState() { }, []); useEffect(() => { - if (!storagePrefix) return; - try { - localStorage.setItem(`${storagePrefix}:assistant-open`, String(assistantOpen)); - localStorage.setItem(`${storagePrefix}:assistant-full-size`, String(assistantFullSize)); - } catch { /* Storage is optional. */ } - }, [storagePrefix, assistantOpen, assistantFullSize]); + if (!panelRestored) return; + saveAssistantPanelState(assistantOpen, assistantFullSize, storagePrefix); + }, [panelRestored, storagePrefix, assistantOpen, assistantFullSize]); useEffect(() => { if (!storagePrefix || !emailPath.test(pathname)) return; diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index e31ea3b..ac01f77 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect } from "react"; import Link from "next/link"; import { HelpCircle, Sparkles } from "lucide-react"; import { AuthGuard } from "@/components/auth/auth-guard"; @@ -19,6 +20,7 @@ import { SidebarResizeBoundary } from "@/components/sidebar-resize-boundary"; import { ShortcutsProvider } from "@/components/shortcuts"; import clsx from "clsx"; import { useDashboardState } from "./dashboard-state"; +import { useAssistantAvailability } from "./use-assistant-availability"; export default function DashboardLayout({ children, @@ -26,6 +28,15 @@ export default function DashboardLayout({ children: React.ReactNode; }) { const { assistantOpen, setAssistantOpen, assistantFullSize, setAssistantFullSize } = useDashboardState(); + const assistantEnabled = useAssistantAvailability(); + const assistantVisible = assistantEnabled === true && assistantOpen; + + useEffect(() => { + if (assistantEnabled === false && (assistantOpen || assistantFullSize)) { + setAssistantOpen(false); + setAssistantFullSize(false); + } + }, [assistantEnabled, assistantOpen, assistantFullSize, setAssistantOpen, setAssistantFullSize]); return ( @@ -34,7 +45,7 @@ export default function DashboardLayout({ -
+