can change openrouter model in UI
This commit is contained in:
@@ -4,11 +4,13 @@
|
|||||||
|
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { Route, Routes } from "react-router-dom";
|
||||||
|
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import type { Job, JobSource, JobStatus } from "../shared/types";
|
import type { Job, JobSource, JobStatus } from "../shared/types";
|
||||||
import { Header, JobList, PipelineProgress, Stats } from "./components";
|
import { Header, JobList, PipelineProgress, Stats } from "./components";
|
||||||
import * as api from "./api";
|
import * as api from "./api";
|
||||||
|
import { SettingsPage } from "./pages/SettingsPage";
|
||||||
|
|
||||||
const DEFAULT_PIPELINE_SOURCES: JobSource[] = ["gradcracker", "indeed", "linkedin"];
|
const DEFAULT_PIPELINE_SOURCES: JobSource[] = ["gradcracker", "indeed", "linkedin"];
|
||||||
const PIPELINE_SOURCES_STORAGE_KEY = "jobops.pipeline.sources";
|
const PIPELINE_SOURCES_STORAGE_KEY = "jobops.pipeline.sources";
|
||||||
@@ -170,17 +172,25 @@ export const App: React.FC = () => {
|
|||||||
onPipelineSourcesChange={setPipelineSources}
|
onPipelineSourcesChange={setPipelineSources}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12">
|
<Routes>
|
||||||
<PipelineProgress isRunning={isPipelineRunning} />
|
<Route
|
||||||
<Stats stats={stats} />
|
path="/"
|
||||||
<JobList
|
element={
|
||||||
jobs={jobs}
|
<main className="container mx-auto max-w-7xl space-y-6 px-4 py-6 pb-12">
|
||||||
onApply={handleApply}
|
<PipelineProgress isRunning={isPipelineRunning} />
|
||||||
onReject={handleReject}
|
<Stats stats={stats} />
|
||||||
onProcess={handleProcess}
|
<JobList
|
||||||
processingJobId={processingJobId}
|
jobs={jobs}
|
||||||
|
onApply={handleApply}
|
||||||
|
onReject={handleReject}
|
||||||
|
onProcess={handleProcess}
|
||||||
|
processingJobId={processingJobId}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</main>
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
</Routes>
|
||||||
|
|
||||||
<Toaster position="bottom-right" richColors closeButton />
|
<Toaster position="bottom-right" richColors closeButton />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import type {
|
|||||||
JobsListResponse,
|
JobsListResponse,
|
||||||
PipelineStatusResponse,
|
PipelineStatusResponse,
|
||||||
JobSource,
|
JobSource,
|
||||||
PipelineRun
|
PipelineRun,
|
||||||
|
AppSettings,
|
||||||
} from '../../shared/types';
|
} from '../../shared/types';
|
||||||
|
|
||||||
const API_BASE = '/api';
|
const API_BASE = '/api';
|
||||||
@@ -92,6 +93,18 @@ export async function runPipeline(config?: {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Settings API
|
||||||
|
export async function getSettings(): Promise<AppSettings> {
|
||||||
|
return fetchApi<AppSettings>('/settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSettings(update: { model?: string | null }): Promise<AppSettings> {
|
||||||
|
return fetchApi<AppSettings>('/settings', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(update),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Database API
|
// Database API
|
||||||
export async function clearDatabase(): Promise<{
|
export async function clearDatabase(): Promise<{
|
||||||
message: string;
|
message: string;
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -112,6 +113,13 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
<span className="hidden sm:inline">Refresh</span>
|
<span className="hidden sm:inline">Refresh</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button asChild variant="outline" size="sm">
|
||||||
|
<Link to="/settings">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Settings</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onRunPipeline}
|
onClick={onRunPipeline}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import '../index.css';
|
import '../index.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* Settings page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState } from "react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import type { AppSettings } from "../../shared/types"
|
||||||
|
import * as api from "../api"
|
||||||
|
|
||||||
|
export const SettingsPage: React.FC = () => {
|
||||||
|
const [settings, setSettings] = useState<AppSettings | null>(null)
|
||||||
|
const [modelDraft, setModelDraft] = useState("")
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true
|
||||||
|
setIsLoading(true)
|
||||||
|
api
|
||||||
|
.getSettings()
|
||||||
|
.then((data) => {
|
||||||
|
if (!isMounted) return
|
||||||
|
setSettings(data)
|
||||||
|
setModelDraft(data.overrideModel ?? "")
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to load settings"
|
||||||
|
toast.error(message)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!isMounted) return
|
||||||
|
setIsLoading(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const effectiveModel = settings?.model ?? ""
|
||||||
|
const defaultModel = settings?.defaultModel ?? ""
|
||||||
|
const overrideModel = settings?.overrideModel
|
||||||
|
|
||||||
|
const canSave = useMemo(() => {
|
||||||
|
if (!settings) return false
|
||||||
|
const next = modelDraft.trim()
|
||||||
|
const current = (overrideModel ?? "").trim()
|
||||||
|
return next !== current
|
||||||
|
}, [modelDraft, overrideModel, settings])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!settings) return
|
||||||
|
try {
|
||||||
|
setIsSaving(true)
|
||||||
|
const trimmed = modelDraft.trim()
|
||||||
|
const updated = await api.updateSettings({ model: trimmed.length > 0 ? trimmed : null })
|
||||||
|
setSettings(updated)
|
||||||
|
setModelDraft(updated.overrideModel ?? "")
|
||||||
|
toast.success("Settings saved")
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to save settings"
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = async () => {
|
||||||
|
try {
|
||||||
|
setIsSaving(true)
|
||||||
|
const updated = await api.updateSettings({ model: null })
|
||||||
|
setSettings(updated)
|
||||||
|
setModelDraft("")
|
||||||
|
toast.success("Reset to default")
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Failed to reset settings"
|
||||||
|
toast.error(message)
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container mx-auto max-w-3xl space-y-6 px-4 py-6 pb-12">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight">Settings</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Configure runtime behavior for this app.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Model</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium">Override model</div>
|
||||||
|
<Input
|
||||||
|
value={modelDraft}
|
||||||
|
onChange={(event) => setModelDraft(event.target.value)}
|
||||||
|
placeholder={defaultModel || "openai/gpt-4o-mini"}
|
||||||
|
disabled={isLoading || isSaving}
|
||||||
|
/>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Leave blank to use the default from server env (`MODEL`).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">Effective</div>
|
||||||
|
<div className="break-words font-mono text-xs">{effectiveModel || "—"}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">Default (env)</div>
|
||||||
|
<div className="break-words font-mono text-xs">{defaultModel || "—"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button onClick={handleSave} disabled={isLoading || isSaving || !canSave}>
|
||||||
|
{isSaving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={handleReset} disabled={isLoading || isSaving || !settings}>
|
||||||
|
Reset to default
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ import { Router, Request, Response } from 'express';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import * as jobsRepo from '../repositories/jobs.js';
|
import * as jobsRepo from '../repositories/jobs.js';
|
||||||
import * as pipelineRepo from '../repositories/pipeline.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 { runPipeline, processJob, getPipelineStatus, subscribeToProgress, getProgress } from '../pipeline/index.js';
|
||||||
import { createNotionEntry } from '../services/notion.js';
|
import { createNotionEntry } from '../services/notion.js';
|
||||||
import { clearDatabase } from '../db/clear.js';
|
import { clearDatabase } from '../db/clear.js';
|
||||||
@@ -174,6 +175,63 @@ apiRouter.post('/jobs/:id/reject', async (req: Request, res: Response) => {
|
|||||||
// Pipeline API
|
// 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
|
* GET /api/pipeline/status - Get pipeline status
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -88,6 +88,13 @@ const migrations = [
|
|||||||
error_message TEXT
|
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)
|
// Add source column for existing databases (safe to skip if already present)
|
||||||
`ALTER TABLE jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'gradcracker'`,
|
`ALTER TABLE jobs ADD COLUMN source TEXT NOT NULL DEFAULT 'gradcracker'`,
|
||||||
`UPDATE jobs SET source = 'gradcracker' WHERE source IS NULL OR source = ''`,
|
`UPDATE jobs SET source = 'gradcracker' WHERE source IS NULL OR source = ''`,
|
||||||
|
|||||||
@@ -82,7 +82,16 @@ export const pipelineRuns = sqliteTable('pipeline_runs', {
|
|||||||
errorMessage: text('error_message'),
|
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 JobRow = typeof jobs.$inferSelect;
|
||||||
export type NewJobRow = typeof jobs.$inferInsert;
|
export type NewJobRow = typeof jobs.$inferInsert;
|
||||||
export type PipelineRunRow = typeof pipelineRuns.$inferSelect;
|
export type PipelineRunRow = typeof pipelineRuns.$inferSelect;
|
||||||
export type NewPipelineRunRow = typeof pipelineRuns.$inferInsert;
|
export type NewPipelineRunRow = typeof pipelineRuns.$inferInsert;
|
||||||
|
export type SettingsRow = typeof settings.$inferSelect;
|
||||||
|
export type NewSettingsRow = typeof settings.$inferInsert;
|
||||||
|
|||||||
@@ -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<string | null> {
|
||||||
|
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<void> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Job } from '../../shared/types.js';
|
import type { Job } from '../../shared/types.js';
|
||||||
|
import { getSetting } from '../repositories/settings.js';
|
||||||
|
|
||||||
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||||
|
|
||||||
@@ -24,7 +25,8 @@ export async function scoreJobSuitability(
|
|||||||
return mockScore(job);
|
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);
|
const prompt = buildScoringPrompt(job, profile);
|
||||||
|
|
||||||
|
|||||||
@@ -171,3 +171,9 @@ export interface PipelineStatusResponse {
|
|||||||
lastRun: PipelineRun | null;
|
lastRun: PipelineRun | null;
|
||||||
nextScheduledRun: string | null;
|
nextScheduledRun: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
model: string;
|
||||||
|
defaultModel: string;
|
||||||
|
overrideModel: string | null;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user