diff --git a/drizzle/0003_blue_korg.sql b/drizzle/0003_blue_korg.sql new file mode 100644 index 0000000..d138eef --- /dev/null +++ b/drizzle/0003_blue_korg.sql @@ -0,0 +1,2 @@ +ALTER TABLE `users` ADD `llm_provider` text;--> statement-breakpoint +ALTER TABLE `users` ADD `llm_model` text; \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..43bf807 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,359 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ef0ed770-bbc5-44b8-8842-fb51ffcde604", + "prevId": "a542a0cf-2a43-4e4d-a8b0-e6b14b8b007e", + "tables": { + "entries": { + "name": "entries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mood": { + "name": "mood", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rough_day": { + "name": "rough_day", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "entries_user_id_users_id_fk": { + "name": "entries_user_id_users_id_fk", + "tableFrom": "entries", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "entry_tags": { + "name": "entry_tags", + "columns": { + "entry_id": { + "name": "entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "entry_tags_entry_id_entries_id_fk": { + "name": "entry_tags_entry_id_entries_id_fk", + "tableFrom": "entry_tags", + "tableTo": "entries", + "columnsFrom": [ + "entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entry_tags_tag_id_tags_id_fk": { + "name": "entry_tags_tag_id_tags_id_fk", + "tableFrom": "entry_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "entry_tags_entry_id_tag_id_pk": { + "columns": [ + "entry_id", + "tag_id" + ], + "name": "entry_tags_entry_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions": { + "name": "sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tags_user_id_users_id_fk": { + "name": "tags_user_id_users_id_fk", + "tableFrom": "tags", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reminder_enabled": { + "name": "reminder_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reminder_time": { + "name": "reminder_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "llm_provider": { + "name": "llm_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "llm_api_key": { + "name": "llm_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "llm_model": { + "name": "llm_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index a4f909e..fe5bd3c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1769256216333, "tag": "0002_windy_rawhide_kid", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1769257297672, + "tag": "0003_blue_korg", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/app/api/llm/models/route.ts b/src/app/api/llm/models/route.ts new file mode 100644 index 0000000..7874198 --- /dev/null +++ b/src/app/api/llm/models/route.ts @@ -0,0 +1,176 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getSession } from "@/lib/auth"; + +interface ModelInfo { + id: string; + name: string; + description?: string; +} + +// POST /api/llm/models - Fetch available models for a provider +export async function POST(request: NextRequest) { + const user = await getSession(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const { provider, apiKey } = await request.json(); + + if (!provider || !apiKey) { + return NextResponse.json( + { error: "Provider and API key are required" }, + { status: 400 } + ); + } + + let models: ModelInfo[] = []; + + switch (provider) { + case "anthropic": + models = await fetchAnthropicModels(apiKey); + break; + case "openai": + models = await fetchOpenAIModels(apiKey); + break; + case "openrouter": + models = await fetchOpenRouterModels(apiKey); + break; + default: + return NextResponse.json({ error: "Unknown provider" }, { status: 400 }); + } + + return NextResponse.json({ models }); + } catch (error) { + console.error("Failed to fetch models:", error); + return NextResponse.json( + { error: "Failed to fetch models. Check your API key." }, + { status: 500 } + ); + } +} + +async function fetchAnthropicModels(apiKey: string): Promise { + // Anthropic doesn't have a models list endpoint, so we return known models + // Validate the key by making a simple request + const res = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: "claude-3-haiku-20240307", + max_tokens: 1, + messages: [{ role: "user", content: "hi" }], + }), + }); + + if (!res.ok && res.status === 401) { + throw new Error("Invalid API key"); + } + + return [ + { id: "claude-3-5-haiku-20241022", name: "Claude 3.5 Haiku", description: "Fast and affordable" }, + { id: "claude-3-haiku-20240307", name: "Claude 3 Haiku", description: "Fastest, cheapest" }, + { id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4", description: "Balanced performance" }, + { id: "claude-3-5-sonnet-20241022", name: "Claude 3.5 Sonnet", description: "Great balance" }, + { id: "claude-opus-4-20250514", name: "Claude Opus 4", description: "Most capable" }, + ]; +} + +async function fetchOpenAIModels(apiKey: string): Promise { + const res = await fetch("https://api.openai.com/v1/models", { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!res.ok) { + throw new Error("Failed to fetch models"); + } + + const data = await res.json(); + const chatModels = data.data + .filter((m: { id: string }) => + m.id.includes("gpt-4") || + m.id.includes("gpt-3.5") || + m.id.includes("o1") || + m.id.includes("o3") + ) + .map((m: { id: string }) => ({ + id: m.id, + name: formatOpenAIModelName(m.id), + description: getOpenAIModelDescription(m.id), + })) + .sort((a: ModelInfo, b: ModelInfo) => { + // Sort by preference - mini/nano first (cheapest) + const aScore = getModelPriorityScore(a.id); + const bScore = getModelPriorityScore(b.id); + return aScore - bScore; + }); + + return chatModels; +} + +function formatOpenAIModelName(id: string): string { + return id + .replace("gpt-", "GPT-") + .replace("-turbo", " Turbo") + .replace("-mini", " Mini") + .replace("-preview", " Preview"); +} + +function getOpenAIModelDescription(id: string): string { + if (id.includes("mini")) return "Fast and cheap"; + if (id.includes("o1") || id.includes("o3")) return "Reasoning model"; + if (id.includes("4o")) return "Multimodal, fast"; + if (id.includes("4-turbo")) return "Powerful, fast"; + if (id.includes("3.5")) return "Legacy, very cheap"; + return ""; +} + +function getModelPriorityScore(id: string): number { + if (id.includes("mini")) return 1; + if (id.includes("3.5")) return 2; + if (id.includes("4o") && !id.includes("mini")) return 3; + if (id.includes("4-turbo")) return 4; + if (id.includes("o1") || id.includes("o3")) return 5; + return 10; +} + +async function fetchOpenRouterModels(apiKey: string): Promise { + const res = await fetch("https://openrouter.ai/api/v1/models", { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }); + + if (!res.ok) { + throw new Error("Failed to fetch models"); + } + + const data = await res.json(); + + // Filter to text models and sort by price (cheapest first) + const models = data.data + .filter((m: { id: string; context_length?: number }) => + m.context_length && m.context_length > 1000 + ) + .map((m: { id: string; name?: string; pricing?: { prompt?: string } }) => ({ + id: m.id, + name: m.name || m.id, + description: m.pricing?.prompt ? `$${parseFloat(m.pricing.prompt) * 1000000}/M tokens` : "", + price: m.pricing?.prompt ? parseFloat(m.pricing.prompt) : Infinity, + })) + .sort((a: { price: number }, b: { price: number }) => a.price - b.price) + .slice(0, 30) // Top 30 cheapest + .map(({ id, name, description }: { id: string; name: string; description: string }) => ({ + id, + name, + description, + })); + + return models; +} diff --git a/src/app/api/reflections/route.ts b/src/app/api/reflections/route.ts index b6e4d5c..9ebdd89 100644 --- a/src/app/api/reflections/route.ts +++ b/src/app/api/reflections/route.ts @@ -11,22 +11,28 @@ export async function POST(request: NextRequest) { } try { - const { period } = await request.json(); // "week" | "month" | "year" + const { period } = await request.json(); - // Get user's API key + // Get user's LLM settings const users = await db - .select({ llmApiKey: schema.users.llmApiKey }) + .select({ + llmProvider: schema.users.llmProvider, + llmApiKey: schema.users.llmApiKey, + llmModel: schema.users.llmModel, + }) .from(schema.users) .where(eq(schema.users.id, user.id)) .limit(1); - if (!users[0]?.llmApiKey) { + if (!users[0]?.llmApiKey || !users[0]?.llmProvider || !users[0]?.llmModel) { return NextResponse.json( - { error: "Please add your Claude API key in settings" }, + { error: "Please configure your LLM settings first" }, { status: 400 } ); } + const { llmProvider, llmApiKey, llmModel } = users[0]; + // Calculate date range const now = new Date(); let startDate: Date; @@ -94,50 +100,33 @@ export async function POST(request: NextRequest) { const periodLabel = period === "week" ? "past week" : period === "month" ? "past month" : "past year"; - // Call Claude API - const response = await fetch("https://api.anthropic.com/v1/messages", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": users[0].llmApiKey, - "anthropic-version": "2023-06-01", - }, - body: JSON.stringify({ - model: "claude-sonnet-4-20250514", - max_tokens: 1024, - messages: [ - { - role: "user", - content: `You are a thoughtful, warm companion helping someone reflect on their gratitude journal. Below are their entries from the ${periodLabel}. - -Please write a brief, heartfelt reflection (2-3 paragraphs) that: + const systemPrompt = `You are a thoughtful, warm companion helping someone reflect on their gratitude journal. Write a brief, heartfelt reflection (2-3 paragraphs) that: 1. Identifies patterns or themes in what they're grateful for 2. Acknowledges any difficult days with compassion 3. Offers a gentle observation or insight 4. Ends with an encouraging thought -Keep the tone calm, supportive, and personal - like a wise friend. Don't be overly cheerful or use excessive positivity. Be genuine. +Keep the tone calm, supportive, and personal - like a wise friend. Don't be overly cheerful or use excessive positivity. Be genuine.`; -Here are the entries: + const userPrompt = `Here are my gratitude entries from the ${periodLabel}:\n\n${formattedEntries}`; -${formattedEntries}`, - }, - ], - }), - }); + // Call the appropriate LLM API + let reflection: string; - if (!response.ok) { - const error = await response.text(); - console.error("Claude API error:", error); - return NextResponse.json( - { error: "Failed to generate reflection. Please check your API key." }, - { status: 500 } - ); + switch (llmProvider) { + case "anthropic": + reflection = await callAnthropic(llmApiKey, llmModel, systemPrompt, userPrompt); + break; + case "openai": + reflection = await callOpenAI(llmApiKey, llmModel, systemPrompt, userPrompt); + break; + case "openrouter": + reflection = await callOpenRouter(llmApiKey, llmModel, systemPrompt, userPrompt); + break; + default: + return NextResponse.json({ error: "Unknown provider" }, { status: 400 }); } - const data = await response.json(); - const reflection = data.content[0]?.text || "Unable to generate reflection."; - return NextResponse.json({ reflection, period, @@ -147,8 +136,105 @@ ${formattedEntries}`, } catch (error) { console.error("Failed to generate reflection:", error); return NextResponse.json( - { error: "Failed to generate reflection" }, + { error: "Failed to generate reflection. Please check your API key and model." }, { status: 500 } ); } } + +async function callAnthropic( + apiKey: string, + model: string, + systemPrompt: string, + userPrompt: string +): Promise { + const response = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model, + max_tokens: 1024, + system: systemPrompt, + messages: [{ role: "user", content: userPrompt }], + }), + }); + + if (!response.ok) { + const error = await response.text(); + console.error("Anthropic API error:", error); + throw new Error("Failed to call Anthropic API"); + } + + const data = await response.json(); + return data.content[0]?.text || "Unable to generate reflection."; +} + +async function callOpenAI( + apiKey: string, + model: string, + systemPrompt: string, + userPrompt: string +): Promise { + const response = await fetch("https://api.openai.com/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + max_tokens: 1024, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + }), + }); + + if (!response.ok) { + const error = await response.text(); + console.error("OpenAI API error:", error); + throw new Error("Failed to call OpenAI API"); + } + + const data = await response.json(); + return data.choices[0]?.message?.content || "Unable to generate reflection."; +} + +async function callOpenRouter( + apiKey: string, + model: string, + systemPrompt: string, + userPrompt: string +): Promise { + const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + "HTTP-Referer": "https://quietthanks.app", + "X-Title": "Quiet Thanks", + }, + body: JSON.stringify({ + model, + max_tokens: 1024, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + }), + }); + + if (!response.ok) { + const error = await response.text(); + console.error("OpenRouter API error:", error); + throw new Error("Failed to call OpenRouter API"); + } + + const data = await response.json(); + return data.choices[0]?.message?.content || "Unable to generate reflection."; +} diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts index 7239a27..8358cd6 100644 --- a/src/app/api/settings/route.ts +++ b/src/app/api/settings/route.ts @@ -15,7 +15,9 @@ export async function GET() { .select({ reminderEnabled: schema.users.reminderEnabled, reminderTime: schema.users.reminderTime, - hasLlmKey: schema.users.llmApiKey, + llmProvider: schema.users.llmProvider, + llmApiKey: schema.users.llmApiKey, + llmModel: schema.users.llmModel, }) .from(schema.users) .where(eq(schema.users.id, user.id)) @@ -28,7 +30,9 @@ export async function GET() { return NextResponse.json({ reminderEnabled: users[0].reminderEnabled === 1, reminderTime: users[0].reminderTime || "20:00", - hasLlmKey: Boolean(users[0].hasLlmKey), + llmProvider: users[0].llmProvider || null, + hasLlmKey: Boolean(users[0].llmApiKey), + llmModel: users[0].llmModel || null, }); } catch (error) { console.error("Failed to fetch settings:", error); @@ -55,11 +59,18 @@ export async function PATCH(request: NextRequest) { updates.reminderTime = body.reminderTime; } + if (typeof body.llmProvider === "string") { + updates.llmProvider = body.llmProvider || null; + } + if (typeof body.llmApiKey === "string") { - // Store the API key (in production, encrypt this) updates.llmApiKey = body.llmApiKey || null; } + if (typeof body.llmModel === "string") { + updates.llmModel = body.llmModel || null; + } + if (Object.keys(updates).length === 0) { return NextResponse.json({ error: "No updates provided" }, { status: 400 }); } diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 8d4e77a..1643a68 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -4,30 +4,58 @@ import { useState, useEffect } from "react"; import { AppShell } from "@/components/AppShell"; import { useAuth } from "@/components/AuthProvider"; import { APP_NAME } from "@/lib/constants"; -import { LogOut, Users, Bell, Sparkles, Loader2, Check, Key } from "lucide-react"; +import { LogOut, Users, Bell, Sparkles, Loader2, Check, Key, ChevronDown } from "lucide-react"; import Link from "next/link"; interface UserSettings { reminderEnabled: boolean; reminderTime: string; + llmProvider: string | null; hasLlmKey: boolean; + llmModel: string | null; } +interface ModelInfo { + id: string; + name: string; + description?: string; +} + +const PROVIDERS = [ + { id: "openrouter", name: "OpenRouter", description: "Access many models, often cheapest" }, + { id: "openai", name: "OpenAI", description: "GPT-4o, GPT-4o-mini" }, + { id: "anthropic", name: "Anthropic", description: "Claude models" }, +]; + export default function SettingsPage() { const { user, logout } = useAuth(); const [settings, setSettings] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); - const [showApiKeyInput, setShowApiKeyInput] = useState(false); - const [apiKey, setApiKey] = useState(""); + const [showLlmSetup, setShowLlmSetup] = useState(false); const [notificationPermission, setNotificationPermission] = useState("default"); + // LLM setup state + const [selectedProvider, setSelectedProvider] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [models, setModels] = useState([]); + const [selectedModel, setSelectedModel] = useState(""); + const [isLoadingModels, setIsLoadingModels] = useState(false); + const [modelError, setModelError] = useState(null); + useEffect(() => { async function loadSettings() { try { const res = await fetch("/api/settings"); if (res.ok) { - setSettings(await res.json()); + const data = await res.json(); + setSettings(data); + if (data.llmProvider) { + setSelectedProvider(data.llmProvider); + } + if (data.llmModel) { + setSelectedModel(data.llmModel); + } } } catch (error) { console.error("Failed to load settings:", error); @@ -37,7 +65,6 @@ export default function SettingsPage() { } loadSettings(); - // Check notification permission if ("Notification" in window) { setNotificationPermission(Notification.permission); } @@ -75,7 +102,6 @@ export default function SettingsPage() { if (!settings) return; if (!settings.reminderEnabled) { - // Enabling - need permission first if (notificationPermission !== "granted") { await requestNotificationPermission(); } else { @@ -86,27 +112,99 @@ export default function SettingsPage() { } }; - const saveApiKey = async () => { + const fetchModels = async () => { + if (!selectedProvider || !apiKey) return; + + setIsLoadingModels(true); + setModelError(null); + setModels([]); + + try { + const res = await fetch("/api/llm/models", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider: selectedProvider, apiKey }), + }); + + const data = await res.json(); + + if (!res.ok) { + setModelError(data.error || "Failed to fetch models"); + return; + } + + setModels(data.models); + if (data.models.length > 0) { + setSelectedModel(data.models[0].id); + } + } catch { + setModelError("Failed to connect to API"); + } finally { + setIsLoadingModels(false); + } + }; + + const saveLlmSettings = async () => { + if (!selectedProvider || !apiKey || !selectedModel) return; + setIsSaving(true); try { const res = await fetch("/api/settings", { method: "PATCH", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ llmApiKey: apiKey }), + body: JSON.stringify({ + llmProvider: selectedProvider, + llmApiKey: apiKey, + llmModel: selectedModel, + }), }); + if (res.ok) { - setSettings((prev) => prev ? { ...prev, hasLlmKey: Boolean(apiKey) } : null); - setShowApiKeyInput(false); + setSettings((prev) => prev ? { + ...prev, + llmProvider: selectedProvider, + hasLlmKey: true, + llmModel: selectedModel, + } : null); + setShowLlmSetup(false); setApiKey(""); } } catch (error) { - console.error("Failed to save API key:", error); + console.error("Failed to save LLM settings:", error); } finally { setIsSaving(false); } }; - // Set up reminder check interval + const clearLlmSettings = async () => { + setIsSaving(true); + try { + await fetch("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + llmProvider: "", + llmApiKey: "", + llmModel: "", + }), + }); + setSettings((prev) => prev ? { + ...prev, + llmProvider: null, + hasLlmKey: false, + llmModel: null, + } : null); + setSelectedProvider(""); + setSelectedModel(""); + setModels([]); + } catch (error) { + console.error("Failed to clear LLM settings:", error); + } finally { + setIsSaving(false); + } + }; + + // Reminder check interval useEffect(() => { if (!settings?.reminderEnabled || !settings?.reminderTime) return; @@ -124,11 +222,12 @@ export default function SettingsPage() { } }; - // Check every minute const interval = setInterval(checkReminder, 60000); return () => clearInterval(interval); }, [settings?.reminderEnabled, settings?.reminderTime]); + const getProviderName = (id: string) => PROVIDERS.find(p => p.id === id)?.name || id; + return (
@@ -153,7 +252,7 @@ export default function SettingsPage() { )} - {/* Admin - only show for admin users */} + {/* Admin */} {user?.isAdmin && (

Daily reminder

-

- Get a gentle nudge to check in -

+

Get a gentle nudge to check in

- - - View reflections - + {settings.hasLlmKey && !showLlmSetup && ( +
+

+ {getProviderName(settings.llmProvider || "")} configured + {settings.llmModel && ( + • {settings.llmModel} + )} +

+
+ + + + + View reflections + +
)} - {showApiKeyInput && ( -
+ {showLlmSetup && ( +
+ {/* Provider selection */}
- -
-
- - setApiKey(e.target.value)} - placeholder="sk-ant-..." - className="w-full pl-9 pr-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:border-muted" - /> -
- + +
+ {PROVIDERS.map((provider) => ( + + ))}
-

- Get your API key from{" "} - + +

+

+ {selectedProvider === "anthropic" && ( + <>Get your key from console.anthropic.com + )} + {selectedProvider === "openai" && ( + <>Get your key from platform.openai.com + )} + {selectedProvider === "openrouter" && ( + <>Get your key from openrouter.ai + )} +

+
+ )} + + {/* Error */} + {modelError && ( +

{modelError}

+ )} + + {/* Model selection */} + {models.length > 0 && ( +
+ +
+ + +
+

+ {selectedProvider === "openrouter" && "Sorted by price (cheapest first)"} + {selectedProvider === "openai" && "Mini models are cheapest"} + {selectedProvider === "anthropic" && "Haiku is cheapest, Opus is most capable"} +

+
+ )} + + {/* Actions */} +
+ + Cancel + + {models.length > 0 && ( + + )} +
)}
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 4f50499..8dba047 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -8,7 +8,9 @@ export const users = sqliteTable("users", { isAdmin: integer("is_admin").notNull().default(0), // 1 = admin reminderEnabled: integer("reminder_enabled").notNull().default(0), reminderTime: text("reminder_time"), // HH:MM format - llmApiKey: text("llm_api_key"), // Encrypted API key for LLM + llmProvider: text("llm_provider"), // anthropic, openai, openrouter + llmApiKey: text("llm_api_key"), // API key for LLM + llmModel: text("llm_model"), // Selected model ID createdAt: integer("created_at").notNull(), // Unix ms });