ai chooses projects
This commit is contained in:
@@ -10,6 +10,7 @@ import type {
|
||||
JobSource,
|
||||
PipelineRun,
|
||||
AppSettings,
|
||||
ResumeProjectsSettings,
|
||||
} from '../../shared/types';
|
||||
|
||||
const API_BASE = '/api';
|
||||
@@ -102,6 +103,7 @@ export async function updateSettings(update: {
|
||||
model?: string | null
|
||||
pipelineWebhookUrl?: string | null
|
||||
jobCompleteWebhookUrl?: string | null
|
||||
resumeProjects?: ResumeProjectsSettings | null
|
||||
}): Promise<AppSettings> {
|
||||
return fetchApi<AppSettings>('/settings', {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -7,16 +7,41 @@ import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import type { AppSettings } from "../../shared/types"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import type { AppSettings, ResumeProjectsSettings } from "../../shared/types"
|
||||
import * as api from "../api"
|
||||
|
||||
function arraysEqual(a: string[], b: string[]) {
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function resumeProjectsEqual(a: ResumeProjectsSettings, b: ResumeProjectsSettings) {
|
||||
return (
|
||||
a.maxProjects === b.maxProjects &&
|
||||
arraysEqual(a.lockedProjectIds, b.lockedProjectIds) &&
|
||||
arraysEqual(a.aiSelectableProjectIds, b.aiSelectableProjectIds)
|
||||
)
|
||||
}
|
||||
|
||||
function clampInt(value: number, min: number, max: number) {
|
||||
const int = Math.floor(value)
|
||||
if (Number.isNaN(int)) return min
|
||||
return Math.min(max, Math.max(min, int))
|
||||
}
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const [settings, setSettings] = useState<AppSettings | null>(null)
|
||||
const [modelDraft, setModelDraft] = useState("")
|
||||
const [pipelineWebhookUrlDraft, setPipelineWebhookUrlDraft] = useState("")
|
||||
const [jobCompleteWebhookUrlDraft, setJobCompleteWebhookUrlDraft] = useState("")
|
||||
const [resumeProjectsDraft, setResumeProjectsDraft] = useState<ResumeProjectsSettings | null>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
@@ -31,6 +56,7 @@ export const SettingsPage: React.FC = () => {
|
||||
setModelDraft(data.overrideModel ?? "")
|
||||
setPipelineWebhookUrlDraft(data.overridePipelineWebhookUrl ?? "")
|
||||
setJobCompleteWebhookUrlDraft(data.overrideJobCompleteWebhookUrl ?? "")
|
||||
setResumeProjectsDraft(data.resumeProjects)
|
||||
})
|
||||
.catch((error) => {
|
||||
const message = error instanceof Error ? error.message : "Failed to load settings"
|
||||
@@ -55,9 +81,12 @@ export const SettingsPage: React.FC = () => {
|
||||
const effectiveJobCompleteWebhookUrl = settings?.jobCompleteWebhookUrl ?? ""
|
||||
const defaultJobCompleteWebhookUrl = settings?.defaultJobCompleteWebhookUrl ?? ""
|
||||
const overrideJobCompleteWebhookUrl = settings?.overrideJobCompleteWebhookUrl
|
||||
const profileProjects = settings?.profileProjects ?? []
|
||||
const maxProjectsTotal = profileProjects.length
|
||||
const lockedCount = resumeProjectsDraft?.lockedProjectIds.length ?? 0
|
||||
|
||||
const canSave = useMemo(() => {
|
||||
if (!settings) return false
|
||||
if (!settings || !resumeProjectsDraft) return false
|
||||
const next = modelDraft.trim()
|
||||
const current = (overrideModel ?? "").trim()
|
||||
const nextWebhook = pipelineWebhookUrlDraft.trim()
|
||||
@@ -67,7 +96,8 @@ export const SettingsPage: React.FC = () => {
|
||||
return (
|
||||
next !== current ||
|
||||
nextWebhook !== currentWebhook ||
|
||||
nextJobCompleteWebhook !== currentJobCompleteWebhook
|
||||
nextJobCompleteWebhook !== currentJobCompleteWebhook ||
|
||||
!resumeProjectsEqual(resumeProjectsDraft, settings.resumeProjects)
|
||||
)
|
||||
}, [
|
||||
settings,
|
||||
@@ -77,24 +107,30 @@ export const SettingsPage: React.FC = () => {
|
||||
overrideModel,
|
||||
overridePipelineWebhookUrl,
|
||||
overrideJobCompleteWebhookUrl,
|
||||
resumeProjectsDraft,
|
||||
])
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!settings) return
|
||||
if (!settings || !resumeProjectsDraft) return
|
||||
try {
|
||||
setIsSaving(true)
|
||||
const trimmed = modelDraft.trim()
|
||||
const webhookTrimmed = pipelineWebhookUrlDraft.trim()
|
||||
const jobCompleteTrimmed = jobCompleteWebhookUrlDraft.trim()
|
||||
const resumeProjectsOverride = resumeProjectsEqual(resumeProjectsDraft, settings.defaultResumeProjects)
|
||||
? null
|
||||
: resumeProjectsDraft
|
||||
const updated = await api.updateSettings({
|
||||
model: trimmed.length > 0 ? trimmed : null,
|
||||
pipelineWebhookUrl: webhookTrimmed.length > 0 ? webhookTrimmed : null,
|
||||
jobCompleteWebhookUrl: jobCompleteTrimmed.length > 0 ? jobCompleteTrimmed : null,
|
||||
resumeProjects: resumeProjectsOverride,
|
||||
})
|
||||
setSettings(updated)
|
||||
setModelDraft(updated.overrideModel ?? "")
|
||||
setPipelineWebhookUrlDraft(updated.overridePipelineWebhookUrl ?? "")
|
||||
setJobCompleteWebhookUrlDraft(updated.overrideJobCompleteWebhookUrl ?? "")
|
||||
setResumeProjectsDraft(updated.resumeProjects)
|
||||
toast.success("Settings saved")
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to save settings"
|
||||
@@ -107,11 +143,17 @@ export const SettingsPage: React.FC = () => {
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
setIsSaving(true)
|
||||
const updated = await api.updateSettings({ model: null, pipelineWebhookUrl: null, jobCompleteWebhookUrl: null })
|
||||
const updated = await api.updateSettings({
|
||||
model: null,
|
||||
pipelineWebhookUrl: null,
|
||||
jobCompleteWebhookUrl: null,
|
||||
resumeProjects: null,
|
||||
})
|
||||
setSettings(updated)
|
||||
setModelDraft("")
|
||||
setPipelineWebhookUrlDraft("")
|
||||
setJobCompleteWebhookUrlDraft("")
|
||||
setResumeProjectsDraft(updated.resumeProjects)
|
||||
toast.success("Reset to default")
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to reset settings"
|
||||
@@ -230,6 +272,122 @@ export const SettingsPage: React.FC = () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Resume Projects</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Max projects included</div>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={lockedCount}
|
||||
max={maxProjectsTotal}
|
||||
value={resumeProjectsDraft?.maxProjects ?? 0}
|
||||
onChange={(event) => {
|
||||
if (!resumeProjectsDraft) return
|
||||
const next = Number(event.target.value)
|
||||
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
|
||||
setResumeProjectsDraft({ ...resumeProjectsDraft, maxProjects: clamped })
|
||||
}}
|
||||
disabled={isLoading || isSaving || !resumeProjectsDraft}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Locked projects always count towards this cap. Locked: {lockedCount} · AI pool:{" "}
|
||||
{resumeProjectsDraft?.aiSelectableProjectIds.length ?? 0} · Total projects: {maxProjectsTotal}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead className="w-[110px]">Base visible</TableHead>
|
||||
<TableHead className="w-[90px]">Locked</TableHead>
|
||||
<TableHead className="w-[140px]">AI selectable</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{profileProjects.map((project) => {
|
||||
const locked = Boolean(resumeProjectsDraft?.lockedProjectIds.includes(project.id))
|
||||
const aiSelectable = Boolean(resumeProjectsDraft?.aiSelectableProjectIds.includes(project.id))
|
||||
const excluded = !locked && !aiSelectable
|
||||
|
||||
return (
|
||||
<TableRow key={project.id}>
|
||||
<TableCell>
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-medium">{project.name || project.id}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{[project.description, project.date].filter(Boolean).join(" · ")}
|
||||
{excluded ? " · Excluded" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked}
|
||||
disabled={isLoading || isSaving || !resumeProjectsDraft}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!resumeProjectsDraft) return
|
||||
const isChecked = checked === true
|
||||
const lockedIds = resumeProjectsDraft.lockedProjectIds.slice()
|
||||
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
|
||||
|
||||
if (isChecked) {
|
||||
if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
|
||||
const nextSelectable = selectableIds.filter((id) => id !== project.id)
|
||||
const minCap = lockedIds.length
|
||||
setResumeProjectsDraft({
|
||||
...resumeProjectsDraft,
|
||||
lockedProjectIds: lockedIds,
|
||||
aiSelectableProjectIds: nextSelectable,
|
||||
maxProjects: Math.max(resumeProjectsDraft.maxProjects, minCap),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const nextLocked = lockedIds.filter((id) => id !== project.id)
|
||||
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
|
||||
setResumeProjectsDraft({
|
||||
...resumeProjectsDraft,
|
||||
lockedProjectIds: nextLocked,
|
||||
aiSelectableProjectIds: selectableIds,
|
||||
maxProjects: clampInt(resumeProjectsDraft.maxProjects, nextLocked.length, maxProjectsTotal),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked ? true : aiSelectable}
|
||||
disabled={locked || isLoading || isSaving || !resumeProjectsDraft}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!resumeProjectsDraft) return
|
||||
const isChecked = checked === true
|
||||
const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
|
||||
const nextSelectable = isChecked
|
||||
? selectableIds.includes(project.id)
|
||||
? selectableIds
|
||||
: [...selectableIds, project.id]
|
||||
: selectableIds.filter((id) => id !== project.id)
|
||||
setResumeProjectsDraft({ ...resumeProjectsDraft, aiSelectableProjectIds: nextSelectable })
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={handleSave} disabled={isLoading || isSaving || !canSave}>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
|
||||
Reference in New Issue
Block a user