Add multi-provider LLM support with model selection

- Support OpenRouter, OpenAI, and Anthropic providers
- Fetch available models after entering API key
- OpenRouter sorted by price (cheapest first)
- Store provider, model, and key per user
- Update reflections API to use selected provider/model

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Gemini Agent
2026-01-24 12:23:31 +00:00
parent 9711baf54e
commit 83ee6eefec
8 changed files with 952 additions and 124 deletions

View File

@@ -0,0 +1,2 @@
ALTER TABLE `users` ADD `llm_provider` text;--> statement-breakpoint
ALTER TABLE `users` ADD `llm_model` text;

View File

@@ -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": {}
}
}

View File

@@ -22,6 +22,13 @@
"when": 1769256216333, "when": 1769256216333,
"tag": "0002_windy_rawhide_kid", "tag": "0002_windy_rawhide_kid",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1769257297672,
"tag": "0003_blue_korg",
"breakpoints": true
} }
] ]
} }

View File

@@ -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<ModelInfo[]> {
// 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<ModelInfo[]> {
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<ModelInfo[]> {
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;
}

View File

@@ -11,22 +11,28 @@ export async function POST(request: NextRequest) {
} }
try { 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 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) .from(schema.users)
.where(eq(schema.users.id, user.id)) .where(eq(schema.users.id, user.id))
.limit(1); .limit(1);
if (!users[0]?.llmApiKey) { if (!users[0]?.llmApiKey || !users[0]?.llmProvider || !users[0]?.llmModel) {
return NextResponse.json( return NextResponse.json(
{ error: "Please add your Claude API key in settings" }, { error: "Please configure your LLM settings first" },
{ status: 400 } { status: 400 }
); );
} }
const { llmProvider, llmApiKey, llmModel } = users[0];
// Calculate date range // Calculate date range
const now = new Date(); const now = new Date();
let startDate: 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"; const periodLabel = period === "week" ? "past week" : period === "month" ? "past month" : "past year";
// Call Claude API const systemPrompt = `You are a thoughtful, warm companion helping someone reflect on their gratitude journal. Write a brief, heartfelt reflection (2-3 paragraphs) that:
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:
1. Identifies patterns or themes in what they're grateful for 1. Identifies patterns or themes in what they're grateful for
2. Acknowledges any difficult days with compassion 2. Acknowledges any difficult days with compassion
3. Offers a gentle observation or insight 3. Offers a gentle observation or insight
4. Ends with an encouraging thought 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) { switch (llmProvider) {
const error = await response.text(); case "anthropic":
console.error("Claude API error:", error); reflection = await callAnthropic(llmApiKey, llmModel, systemPrompt, userPrompt);
return NextResponse.json( break;
{ error: "Failed to generate reflection. Please check your API key." }, case "openai":
{ status: 500 } 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({ return NextResponse.json({
reflection, reflection,
period, period,
@@ -147,8 +136,105 @@ ${formattedEntries}`,
} catch (error) { } catch (error) {
console.error("Failed to generate reflection:", error); console.error("Failed to generate reflection:", error);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to generate reflection" }, { error: "Failed to generate reflection. Please check your API key and model." },
{ status: 500 } { status: 500 }
); );
} }
} }
async function callAnthropic(
apiKey: string,
model: string,
systemPrompt: string,
userPrompt: string
): Promise<string> {
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<string> {
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<string> {
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.";
}

View File

@@ -15,7 +15,9 @@ export async function GET() {
.select({ .select({
reminderEnabled: schema.users.reminderEnabled, reminderEnabled: schema.users.reminderEnabled,
reminderTime: schema.users.reminderTime, reminderTime: schema.users.reminderTime,
hasLlmKey: schema.users.llmApiKey, llmProvider: schema.users.llmProvider,
llmApiKey: schema.users.llmApiKey,
llmModel: schema.users.llmModel,
}) })
.from(schema.users) .from(schema.users)
.where(eq(schema.users.id, user.id)) .where(eq(schema.users.id, user.id))
@@ -28,7 +30,9 @@ export async function GET() {
return NextResponse.json({ return NextResponse.json({
reminderEnabled: users[0].reminderEnabled === 1, reminderEnabled: users[0].reminderEnabled === 1,
reminderTime: users[0].reminderTime || "20:00", 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) { } catch (error) {
console.error("Failed to fetch settings:", error); console.error("Failed to fetch settings:", error);
@@ -55,11 +59,18 @@ export async function PATCH(request: NextRequest) {
updates.reminderTime = body.reminderTime; updates.reminderTime = body.reminderTime;
} }
if (typeof body.llmProvider === "string") {
updates.llmProvider = body.llmProvider || null;
}
if (typeof body.llmApiKey === "string") { if (typeof body.llmApiKey === "string") {
// Store the API key (in production, encrypt this)
updates.llmApiKey = body.llmApiKey || null; updates.llmApiKey = body.llmApiKey || null;
} }
if (typeof body.llmModel === "string") {
updates.llmModel = body.llmModel || null;
}
if (Object.keys(updates).length === 0) { if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: "No updates provided" }, { status: 400 }); return NextResponse.json({ error: "No updates provided" }, { status: 400 });
} }

View File

@@ -4,30 +4,58 @@ import { useState, useEffect } from "react";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { useAuth } from "@/components/AuthProvider"; import { useAuth } from "@/components/AuthProvider";
import { APP_NAME } from "@/lib/constants"; 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"; import Link from "next/link";
interface UserSettings { interface UserSettings {
reminderEnabled: boolean; reminderEnabled: boolean;
reminderTime: string; reminderTime: string;
llmProvider: string | null;
hasLlmKey: boolean; 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() { export default function SettingsPage() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const [settings, setSettings] = useState<UserSettings | null>(null); const [settings, setSettings] = useState<UserSettings | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [showApiKeyInput, setShowApiKeyInput] = useState(false); const [showLlmSetup, setShowLlmSetup] = useState(false);
const [apiKey, setApiKey] = useState("");
const [notificationPermission, setNotificationPermission] = useState<NotificationPermission>("default"); const [notificationPermission, setNotificationPermission] = useState<NotificationPermission>("default");
// LLM setup state
const [selectedProvider, setSelectedProvider] = useState<string>("");
const [apiKey, setApiKey] = useState("");
const [models, setModels] = useState<ModelInfo[]>([]);
const [selectedModel, setSelectedModel] = useState<string>("");
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [modelError, setModelError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
async function loadSettings() { async function loadSettings() {
try { try {
const res = await fetch("/api/settings"); const res = await fetch("/api/settings");
if (res.ok) { 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) { } catch (error) {
console.error("Failed to load settings:", error); console.error("Failed to load settings:", error);
@@ -37,7 +65,6 @@ export default function SettingsPage() {
} }
loadSettings(); loadSettings();
// Check notification permission
if ("Notification" in window) { if ("Notification" in window) {
setNotificationPermission(Notification.permission); setNotificationPermission(Notification.permission);
} }
@@ -75,7 +102,6 @@ export default function SettingsPage() {
if (!settings) return; if (!settings) return;
if (!settings.reminderEnabled) { if (!settings.reminderEnabled) {
// Enabling - need permission first
if (notificationPermission !== "granted") { if (notificationPermission !== "granted") {
await requestNotificationPermission(); await requestNotificationPermission();
} else { } 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); setIsSaving(true);
try { try {
const res = await fetch("/api/settings", { const res = await fetch("/api/settings", {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ llmApiKey: apiKey }), body: JSON.stringify({
llmProvider: selectedProvider,
llmApiKey: apiKey,
llmModel: selectedModel,
}),
}); });
if (res.ok) { if (res.ok) {
setSettings((prev) => prev ? { ...prev, hasLlmKey: Boolean(apiKey) } : null); setSettings((prev) => prev ? {
setShowApiKeyInput(false); ...prev,
llmProvider: selectedProvider,
hasLlmKey: true,
llmModel: selectedModel,
} : null);
setShowLlmSetup(false);
setApiKey(""); setApiKey("");
} }
} catch (error) { } catch (error) {
console.error("Failed to save API key:", error); console.error("Failed to save LLM settings:", error);
} finally { } finally {
setIsSaving(false); 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(() => { useEffect(() => {
if (!settings?.reminderEnabled || !settings?.reminderTime) return; if (!settings?.reminderEnabled || !settings?.reminderTime) return;
@@ -124,11 +222,12 @@ export default function SettingsPage() {
} }
}; };
// Check every minute
const interval = setInterval(checkReminder, 60000); const interval = setInterval(checkReminder, 60000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [settings?.reminderEnabled, settings?.reminderTime]); }, [settings?.reminderEnabled, settings?.reminderTime]);
const getProviderName = (id: string) => PROVIDERS.find(p => p.id === id)?.name || id;
return ( return (
<AppShell> <AppShell>
<header className="mb-8"> <header className="mb-8">
@@ -153,7 +252,7 @@ export default function SettingsPage() {
</div> </div>
)} )}
{/* Admin - only show for admin users */} {/* Admin */}
{user?.isAdmin && ( {user?.isAdmin && (
<Link <Link
href="/admin" href="/admin"
@@ -175,9 +274,7 @@ export default function SettingsPage() {
<Bell size={20} className="text-accent" /> <Bell size={20} className="text-accent" />
<div> <div>
<h3 className="font-medium">Daily reminder</h3> <h3 className="font-medium">Daily reminder</h3>
<p className="text-sm text-muted"> <p className="text-sm text-muted">Get a gentle nudge to check in</p>
Get a gentle nudge to check in
</p>
</div> </div>
</div> </div>
<button <button
@@ -221,16 +318,14 @@ export default function SettingsPage() {
<Sparkles size={20} className="text-accent" /> <Sparkles size={20} className="text-accent" />
<div> <div>
<h3 className="font-medium">AI reflections</h3> <h3 className="font-medium">AI reflections</h3>
<p className="text-sm text-muted"> <p className="text-sm text-muted">Generate insights from your entries</p>
Generate insights from your entries
</p>
</div> </div>
</div> </div>
{settings.hasLlmKey ? ( {settings.hasLlmKey ? (
<Check size={20} className="text-green-500" /> <Check size={20} className="text-green-500" />
) : ( ) : (
<button <button
onClick={() => setShowApiKeyInput(true)} onClick={() => setShowLlmSetup(true)}
className="px-3 py-1 text-sm bg-accent text-white rounded-lg hover:bg-accent/90" className="px-3 py-1 text-sm bg-accent text-white rounded-lg hover:bg-accent/90"
> >
Set up Set up
@@ -238,72 +333,162 @@ export default function SettingsPage() {
)} )}
</div> </div>
{settings.hasLlmKey && !showApiKeyInput && ( {settings.hasLlmKey && !showLlmSetup && (
<div className="mt-3 flex items-center gap-2"> <div className="mt-3 space-y-2">
<span className="text-sm text-green-500">API key configured</span> <p className="text-sm text-green-500">
<button {getProviderName(settings.llmProvider || "")} configured
onClick={() => setShowApiKeyInput(true)} {settings.llmModel && (
className="text-sm text-muted hover:text-foreground" <span className="text-muted"> {settings.llmModel}</span>
> )}
Change </p>
</button> <div className="flex items-center gap-3">
<span className="text-muted"></span> <button
<Link onClick={() => setShowLlmSetup(true)}
href="/reflections" className="text-sm text-muted hover:text-foreground"
className="text-sm text-accent hover:underline" >
> Change
View reflections </button>
</Link> <button
onClick={clearLlmSettings}
className="text-sm text-red-400 hover:text-red-300"
>
Remove
</button>
<span className="text-muted"></span>
<Link href="/reflections" className="text-sm text-accent hover:underline">
View reflections
</Link>
</div>
</div> </div>
)} )}
{showApiKeyInput && ( {showLlmSetup && (
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-4 border-t border-border pt-4">
{/* Provider selection */}
<div> <div>
<label className="block text-sm text-muted mb-1"> <label className="block text-sm text-muted mb-2">Provider</label>
Claude API Key <div className="grid gap-2">
</label> {PROVIDERS.map((provider) => (
<div className="flex gap-2"> <button
<div className="relative flex-1"> key={provider.id}
<Key size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted" /> onClick={() => {
<input setSelectedProvider(provider.id);
type="password" setModels([]);
value={apiKey} setSelectedModel("");
onChange={(e) => setApiKey(e.target.value)} setModelError(null);
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" className={`p-3 text-left rounded-lg border transition-colors ${
/> selectedProvider === provider.id
</div> ? "border-accent bg-accent/10"
<button : "border-border hover:border-muted"
onClick={saveApiKey} }`}
disabled={isSaving || !apiKey} >
className="px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50 flex items-center gap-2" <p className="font-medium">{provider.name}</p>
> <p className="text-sm text-muted">{provider.description}</p>
{isSaving ? <Loader2 size={14} className="animate-spin" /> : null} </button>
Save ))}
</button>
</div> </div>
</div> </div>
<p className="text-xs text-muted">
Get your API key from{" "} {/* API Key */}
<a {selectedProvider && (
href="https://console.anthropic.com/settings/keys" <div>
target="_blank" <label className="block text-sm text-muted mb-1">API Key</label>
rel="noopener noreferrer" <div className="flex gap-2">
className="text-accent hover:underline" <div className="relative flex-1">
<Key size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={
selectedProvider === "anthropic" ? "sk-ant-..." :
selectedProvider === "openai" ? "sk-..." :
"sk-or-..."
}
className="w-full pl-9 pr-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:border-muted"
/>
</div>
<button
onClick={fetchModels}
disabled={!apiKey || isLoadingModels}
className="px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50 flex items-center gap-2"
>
{isLoadingModels ? (
<Loader2 size={14} className="animate-spin" />
) : null}
Load Models
</button>
</div>
<p className="mt-1 text-xs text-muted">
{selectedProvider === "anthropic" && (
<>Get your key from <a href="https://console.anthropic.com/settings/keys" target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">console.anthropic.com</a></>
)}
{selectedProvider === "openai" && (
<>Get your key from <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">platform.openai.com</a></>
)}
{selectedProvider === "openrouter" && (
<>Get your key from <a href="https://openrouter.ai/keys" target="_blank" rel="noopener noreferrer" className="text-accent hover:underline">openrouter.ai</a></>
)}
</p>
</div>
)}
{/* Error */}
{modelError && (
<p className="text-sm text-red-400">{modelError}</p>
)}
{/* Model selection */}
{models.length > 0 && (
<div>
<label className="block text-sm text-muted mb-1">Model</label>
<div className="relative">
<select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="w-full px-3 py-2 bg-background border border-border rounded-lg text-sm focus:outline-none focus:border-muted appearance-none pr-10"
>
{models.map((model) => (
<option key={model.id} value={model.id}>
{model.name} {model.description && `- ${model.description}`}
</option>
))}
</select>
<ChevronDown size={16} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted pointer-events-none" />
</div>
<p className="mt-1 text-xs text-muted">
{selectedProvider === "openrouter" && "Sorted by price (cheapest first)"}
{selectedProvider === "openai" && "Mini models are cheapest"}
{selectedProvider === "anthropic" && "Haiku is cheapest, Opus is most capable"}
</p>
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
onClick={() => {
setShowLlmSetup(false);
setApiKey("");
setModels([]);
setModelError(null);
}}
className="px-4 py-2 text-sm text-muted hover:text-foreground"
> >
console.anthropic.com Cancel
</a> </button>
</p> {models.length > 0 && (
<button <button
onClick={() => { onClick={saveLlmSettings}
setShowApiKeyInput(false); disabled={isSaving || !selectedModel}
setApiKey(""); className="flex items-center gap-2 px-4 py-2 text-sm bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50"
}} >
className="text-sm text-muted hover:text-foreground" {isSaving && <Loader2 size={14} className="animate-spin" />}
> Save
Cancel </button>
</button> )}
</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -8,7 +8,9 @@ export const users = sqliteTable("users", {
isAdmin: integer("is_admin").notNull().default(0), // 1 = admin isAdmin: integer("is_admin").notNull().default(0), // 1 = admin
reminderEnabled: integer("reminder_enabled").notNull().default(0), reminderEnabled: integer("reminder_enabled").notNull().default(0),
reminderTime: text("reminder_time"), // HH:MM format 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 createdAt: integer("created_at").notNull(), // Unix ms
}); });