mirror of
https://github.com/Tony0410/quietthanks.git
synced 2026-05-24 21:31:41 +08:00
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:
2
drizzle/0003_blue_korg.sql
Normal file
2
drizzle/0003_blue_korg.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `users` ADD `llm_provider` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `users` ADD `llm_model` text;
|
||||||
359
drizzle/meta/0003_snapshot.json
Normal file
359
drizzle/meta/0003_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
176
src/app/api/llm/models/route.ts
Normal file
176
src/app/api/llm/models/route.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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.";
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,31 +333,67 @@ 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">
|
||||||
|
{getProviderName(settings.llmProvider || "")} configured
|
||||||
|
{settings.llmModel && (
|
||||||
|
<span className="text-muted"> • {settings.llmModel}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowApiKeyInput(true)}
|
onClick={() => setShowLlmSetup(true)}
|
||||||
className="text-sm text-muted hover:text-foreground"
|
className="text-sm text-muted hover:text-foreground"
|
||||||
>
|
>
|
||||||
Change
|
Change
|
||||||
</button>
|
</button>
|
||||||
<span className="text-muted">•</span>
|
<button
|
||||||
<Link
|
onClick={clearLlmSettings}
|
||||||
href="/reflections"
|
className="text-sm text-red-400 hover:text-red-300"
|
||||||
className="text-sm text-accent hover:underline"
|
|
||||||
>
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
<span className="text-muted">•</span>
|
||||||
|
<Link href="/reflections" className="text-sm text-accent hover:underline">
|
||||||
View reflections
|
View reflections
|
||||||
</Link>
|
</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) => (
|
||||||
|
<button
|
||||||
|
key={provider.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProvider(provider.id);
|
||||||
|
setModels([]);
|
||||||
|
setSelectedModel("");
|
||||||
|
setModelError(null);
|
||||||
|
}}
|
||||||
|
className={`p-3 text-left rounded-lg border transition-colors ${
|
||||||
|
selectedProvider === provider.id
|
||||||
|
? "border-accent bg-accent/10"
|
||||||
|
: "border-border hover:border-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="font-medium">{provider.name}</p>
|
||||||
|
<p className="text-sm text-muted">{provider.description}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Key */}
|
||||||
|
{selectedProvider && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-muted mb-1">API Key</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Key size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
<Key size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
|
||||||
@@ -270,40 +401,94 @@ export default function SettingsPage() {
|
|||||||
type="password"
|
type="password"
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChange={(e) => setApiKey(e.target.value)}
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
placeholder="sk-ant-..."
|
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"
|
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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={saveApiKey}
|
onClick={fetchModels}
|
||||||
disabled={isSaving || !apiKey}
|
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"
|
className="px-4 py-2 bg-accent text-white rounded-lg hover:bg-accent/90 disabled:opacity-50 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{isSaving ? <Loader2 size={14} className="animate-spin" /> : null}
|
{isLoadingModels ? (
|
||||||
Save
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : null}
|
||||||
|
Load Models
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p className="mt-1 text-xs text-muted">
|
||||||
<p className="text-xs text-muted">
|
{selectedProvider === "anthropic" && (
|
||||||
Get your API key from{" "}
|
<>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></>
|
||||||
<a
|
)}
|
||||||
href="https://console.anthropic.com/settings/keys"
|
{selectedProvider === "openai" && (
|
||||||
target="_blank"
|
<>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></>
|
||||||
rel="noopener noreferrer"
|
)}
|
||||||
className="text-accent hover:underline"
|
{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></>
|
||||||
console.anthropic.com
|
)}
|
||||||
</a>
|
|
||||||
</p>
|
</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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowApiKeyInput(false);
|
setShowLlmSetup(false);
|
||||||
setApiKey("");
|
setApiKey("");
|
||||||
|
setModels([]);
|
||||||
|
setModelError(null);
|
||||||
}}
|
}}
|
||||||
className="text-sm text-muted hover:text-foreground"
|
className="px-4 py-2 text-sm text-muted hover:text-foreground"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
{models.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={saveLlmSettings}
|
||||||
|
disabled={isSaving || !selectedModel}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{isSaving && <Loader2 size={14} className="animate-spin" />}
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user