diff --git a/orchestrator/src/client/App.tsx b/orchestrator/src/client/App.tsx index 79f686f..59a643c 100644 --- a/orchestrator/src/client/App.tsx +++ b/orchestrator/src/client/App.tsx @@ -4,11 +4,13 @@ import React, { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; +import { Route, Routes } from "react-router-dom"; import { Toaster } from "@/components/ui/sonner"; import type { Job, JobSource, JobStatus } from "../shared/types"; import { Header, JobList, PipelineProgress, Stats } from "./components"; import * as api from "./api"; +import { SettingsPage } from "./pages/SettingsPage"; const DEFAULT_PIPELINE_SOURCES: JobSource[] = ["gradcracker", "indeed", "linkedin"]; const PIPELINE_SOURCES_STORAGE_KEY = "jobops.pipeline.sources"; @@ -170,17 +172,25 @@ export const App: React.FC = () => { onPipelineSourcesChange={setPipelineSources} /> -
- - - + + + + +
+ } /> - + } /> + diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 6f09f0d..1e7f77c 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -8,7 +8,8 @@ import type { JobsListResponse, PipelineStatusResponse, JobSource, - PipelineRun + PipelineRun, + AppSettings, } from '../../shared/types'; const API_BASE = '/api'; @@ -92,6 +93,18 @@ export async function runPipeline(config?: { }); } +// Settings API +export async function getSettings(): Promise { + return fetchApi('/settings'); +} + +export async function updateSettings(update: { model?: string | null }): Promise { + return fetchApi('/settings', { + method: 'PATCH', + body: JSON.stringify(update), + }); +} + // Database API export async function clearDatabase(): Promise<{ message: string; diff --git a/orchestrator/src/client/components/Header.tsx b/orchestrator/src/client/components/Header.tsx index a34b5fd..67d4b03 100644 --- a/orchestrator/src/client/components/Header.tsx +++ b/orchestrator/src/client/components/Header.tsx @@ -3,7 +3,8 @@ */ import React from "react"; -import { ChevronDown, Loader2, Play, RefreshCcw, Rocket, Trash2 } from "lucide-react"; +import { ChevronDown, Loader2, Play, RefreshCcw, Rocket, Settings, Trash2 } from "lucide-react"; +import { Link } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { @@ -112,6 +113,13 @@ export const Header: React.FC = ({ Refresh + + + + + + + + ) +} + diff --git a/orchestrator/src/server/api/routes.ts b/orchestrator/src/server/api/routes.ts index 0aaf59d..bd66185 100644 --- a/orchestrator/src/server/api/routes.ts +++ b/orchestrator/src/server/api/routes.ts @@ -6,6 +6,7 @@ import { Router, Request, Response } from 'express'; import { z } from 'zod'; import * as jobsRepo from '../repositories/jobs.js'; import * as pipelineRepo from '../repositories/pipeline.js'; +import * as settingsRepo from '../repositories/settings.js'; import { runPipeline, processJob, getPipelineStatus, subscribeToProgress, getProgress } from '../pipeline/index.js'; import { createNotionEntry } from '../services/notion.js'; import { clearDatabase } from '../db/clear.js'; @@ -174,6 +175,63 @@ apiRouter.post('/jobs/:id/reject', async (req: Request, res: Response) => { // Pipeline API // ============================================================================ +/** + * GET /api/settings - Get app settings (effective + defaults) + */ +apiRouter.get('/settings', async (_req: Request, res: Response) => { + try { + const overrideModel = await settingsRepo.getSetting('model'); + const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini'; + const model = overrideModel || defaultModel; + + res.json({ + success: true, + data: { + model, + defaultModel, + overrideModel, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + res.status(500).json({ success: false, error: message }); + } +}); + +const updateSettingsSchema = z.object({ + model: z.string().trim().min(1).max(200).nullable().optional(), +}); + +/** + * PATCH /api/settings - Update settings overrides + */ +apiRouter.patch('/settings', async (req: Request, res: Response) => { + try { + const input = updateSettingsSchema.parse(req.body); + + if ('model' in input) { + const model = input.model ?? null; + await settingsRepo.setSetting('model', model); + } + + const overrideModel = await settingsRepo.getSetting('model'); + const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini'; + const model = overrideModel || defaultModel; + + res.json({ + success: true, + data: { + model, + defaultModel, + overrideModel, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + res.status(400).json({ success: false, error: message }); + } +}); + /** * GET /api/pipeline/status - Get pipeline status */ diff --git a/orchestrator/src/server/db/migrate.ts b/orchestrator/src/server/db/migrate.ts index 4792170..051314b 100644 --- a/orchestrator/src/server/db/migrate.ts +++ b/orchestrator/src/server/db/migrate.ts @@ -88,6 +88,13 @@ const migrations = [ error_message TEXT )`, + `CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )`, + // Add source column for existing databases (safe to skip if already present) `ALTER TABLE jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'gradcracker'`, `UPDATE jobs SET source = 'gradcracker' WHERE source IS NULL OR source = ''`, diff --git a/orchestrator/src/server/db/schema.ts b/orchestrator/src/server/db/schema.ts index 3d66498..f317983 100644 --- a/orchestrator/src/server/db/schema.ts +++ b/orchestrator/src/server/db/schema.ts @@ -82,7 +82,16 @@ export const pipelineRuns = sqliteTable('pipeline_runs', { errorMessage: text('error_message'), }); +export const settings = sqliteTable('settings', { + key: text('key').primaryKey(), + value: text('value').notNull(), + createdAt: text('created_at').notNull().default(sql`(datetime('now'))`), + updatedAt: text('updated_at').notNull().default(sql`(datetime('now'))`), +}); + export type JobRow = typeof jobs.$inferSelect; export type NewJobRow = typeof jobs.$inferInsert; export type PipelineRunRow = typeof pipelineRuns.$inferSelect; export type NewPipelineRunRow = typeof pipelineRuns.$inferInsert; +export type SettingsRow = typeof settings.$inferSelect; +export type NewSettingsRow = typeof settings.$inferInsert; diff --git a/orchestrator/src/server/repositories/settings.ts b/orchestrator/src/server/repositories/settings.ts new file mode 100644 index 0000000..70602e0 --- /dev/null +++ b/orchestrator/src/server/repositories/settings.ts @@ -0,0 +1,42 @@ +/** + * Settings repository - key/value storage for runtime configuration. + */ + +import { eq } from 'drizzle-orm' +import { db, schema } from '../db/index.js' + +const { settings } = schema + +export type SettingKey = 'model' + +export async function getSetting(key: SettingKey): Promise { + const [row] = await db.select().from(settings).where(eq(settings.key, key)) + return row?.value ?? null +} + +export async function setSetting(key: SettingKey, value: string | null): Promise { + const now = new Date().toISOString() + + if (value === null) { + await db.delete(settings).where(eq(settings.key, key)) + return + } + + const [existing] = await db.select({ key: settings.key }).from(settings).where(eq(settings.key, key)) + + if (existing) { + await db + .update(settings) + .set({ value, updatedAt: now }) + .where(eq(settings.key, key)) + return + } + + await db.insert(settings).values({ + key, + value, + createdAt: now, + updatedAt: now, + }) +} + diff --git a/orchestrator/src/server/services/scorer.ts b/orchestrator/src/server/services/scorer.ts index 814739e..71428b5 100644 --- a/orchestrator/src/server/services/scorer.ts +++ b/orchestrator/src/server/services/scorer.ts @@ -3,6 +3,7 @@ */ import type { Job } from '../../shared/types.js'; +import { getSetting } from '../repositories/settings.js'; const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; @@ -24,7 +25,8 @@ export async function scoreJobSuitability( return mockScore(job); } - const model = process.env.MODEL || 'openai/gpt-4o-mini'; + const overrideModel = await getSetting('model'); + const model = overrideModel || process.env.MODEL || 'openai/gpt-4o-mini'; const prompt = buildScoringPrompt(job, profile); diff --git a/orchestrator/src/shared/types.ts b/orchestrator/src/shared/types.ts index 235969c..8e65dc3 100644 --- a/orchestrator/src/shared/types.ts +++ b/orchestrator/src/shared/types.ts @@ -171,3 +171,9 @@ export interface PipelineStatusResponse { lastRun: PipelineRun | null; nextScheduledRun: string | null; } + +export interface AppSettings { + model: string; + defaultModel: string; + overrideModel: string | null; +}