From a6e3b18e8a6f5fb2ca13bbb749f9a9b051b64003 Mon Sep 17 00:00:00 2001 From: Jaret Burkett Date: Sat, 19 Sep 2026 10:28:29 -0600 Subject: [PATCH] Added a notes section for jobs --- ui/src/app/api/jobs/[jobID]/notes/route.ts | 59 +++++++++++ ui/src/app/jobs/[jobID]/page.tsx | 12 ++- ui/src/components/JobNotes.tsx | 111 +++++++++++++++++++++ 3 files changed, 180 insertions(+), 2 deletions(-) create mode 100644 ui/src/app/api/jobs/[jobID]/notes/route.ts create mode 100644 ui/src/components/JobNotes.tsx diff --git a/ui/src/app/api/jobs/[jobID]/notes/route.ts b/ui/src/app/api/jobs/[jobID]/notes/route.ts new file mode 100644 index 0000000..13c1061 --- /dev/null +++ b/ui/src/app/api/jobs/[jobID]/notes/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from 'next/server'; +import prisma from '@/server/prisma'; +import path from 'path'; +import fs from 'fs'; +import { getTrainingFolder } from '@/server/settings'; + +const getNotesPath = async (jobID: string): Promise => { + const job = await prisma.job.findUnique({ + where: { id: jobID }, + }); + + if (!job) { + return null; + } + + const trainingFolder = await getTrainingFolder(); + return path.join(trainingFolder, job.name, 'notes.md'); +}; + +export async function GET(request: NextRequest, { params }: { params: { jobID: string } }) { + const { jobID } = await params; + const notesPath = await getNotesPath(jobID); + + if (!notesPath) { + return NextResponse.json({ error: 'Job not found' }, { status: 404 }); + } + + let content = ''; + try { + content = await fs.promises.readFile(notesPath, 'utf-8'); + } catch { + // no notes yet, start empty + } + + return NextResponse.json({ content }); +} + +export async function POST(request: NextRequest, { params }: { params: { jobID: string } }) { + const { jobID } = await params; + const notesPath = await getNotesPath(jobID); + + if (!notesPath) { + return NextResponse.json({ error: 'Job not found' }, { status: 404 }); + } + + const body = await request.json(); + const content = typeof body?.content === 'string' ? body.content : ''; + + try { + // the job folder may not exist yet if the job has never run + await fs.promises.mkdir(path.dirname(notesPath), { recursive: true }); + await fs.promises.writeFile(notesPath, content, 'utf-8'); + } catch (error) { + console.error('Error saving notes file:', error); + return NextResponse.json({ error: 'Error saving notes file' }, { status: 500 }); + } + + return NextResponse.json({ success: true }); +} diff --git a/ui/src/app/jobs/[jobID]/page.tsx b/ui/src/app/jobs/[jobID]/page.tsx index 7199c38..607787d 100644 --- a/ui/src/app/jobs/[jobID]/page.tsx +++ b/ui/src/app/jobs/[jobID]/page.tsx @@ -2,7 +2,7 @@ import { useState, use } from 'react'; import { FaChevronLeft } from 'react-icons/fa'; -import { MdDashboard, MdImage, MdShowChart, MdCode, MdExtension } from 'react-icons/md'; +import { MdDashboard, MdImage, MdShowChart, MdCode, MdExtension, MdNotes } from 'react-icons/md'; import { Button } from '@headlessui/react'; import { TopBar, MainContent } from '@/components/layout'; import useJob from '@/hooks/useJob'; @@ -14,10 +14,11 @@ import JobActionBar from '@/components/JobActionBar'; import JobConfigViewer from '@/components/JobConfigViewer'; import JobLossGraph from '@/components/JobLossGraph'; import JobPlugin from '@/components/JobPlugin'; +import JobNotes from '@/components/JobNotes'; import { Job } from '@prisma/client'; import { apiClient } from '@/utils/api'; -type PageKey = 'overview' | 'samples' | 'config' | 'loss_log' | 'plugin'; +type PageKey = 'overview' | 'samples' | 'config' | 'loss_log' | 'notes' | 'plugin'; interface Page { name: string; @@ -61,6 +62,13 @@ const pages: Page[] = [ component: JobConfigViewer, mainCss: 'pt-[80px] px-0 pb-0', }, + { + name: 'Notes', + value: 'notes', + icon: MdNotes, + component: JobNotes, + mainCss: 'pt-24 pb-4', + }, { name: 'Plugin', value: 'plugin', diff --git a/ui/src/components/JobNotes.tsx b/ui/src/components/JobNotes.tsx new file mode 100644 index 0000000..579f2ed --- /dev/null +++ b/ui/src/components/JobNotes.tsx @@ -0,0 +1,111 @@ +'use client'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Job } from '@prisma/client'; +import { apiClient } from '@/utils/api'; + +interface Props { + job: Job; +} + +const AUTOSAVE_INTERVAL = 5000; + +export default function JobNotes({ job }: Props) { + const [content, setContent] = useState(''); + const [isLoading, setIsLoading] = useState(true); + // refs so the unmount flush and interval always see the latest values + const contentRef = useRef(''); + const savedRef = useRef(''); + const isSavingRef = useRef(false); + const loadedRef = useRef(false); + + useEffect(() => { + let cancelled = false; + apiClient + .get(`/api/jobs/${job.id}/notes`) + .then(res => { + if (cancelled) return; + const loaded = res.data?.content ?? ''; + contentRef.current = loaded; + savedRef.current = loaded; + loadedRef.current = true; + setContent(loaded); + setIsLoading(false); + }) + .catch(() => { + if (cancelled) return; + setIsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [job.id]); + + const save = useCallback(async () => { + if (!loadedRef.current || isSavingRef.current) return; + const toSave = contentRef.current; + if (toSave === savedRef.current) return; + isSavingRef.current = true; + try { + await apiClient.post(`/api/jobs/${job.id}/notes`, { content: toSave }); + savedRef.current = toSave; + } catch (error) { + console.error('Error saving notes:', error); + } finally { + isSavingRef.current = false; + } + }, [job.id]); + + // periodic autosave + useEffect(() => { + const interval = setInterval(() => { + save(); + }, AUTOSAVE_INTERVAL); + return () => clearInterval(interval); + }, [save]); + + // flush on unmount (tab change / navigation) and on page unload. keepalive so + // the request survives the component/page going away before it completes. + useEffect(() => { + const flush = () => { + if (!loadedRef.current || contentRef.current === savedRef.current) return; + const token = localStorage.getItem('AI_TOOLKIT_AUTH'); + fetch(`/api/jobs/${job.id}/notes`, { + method: 'POST', + keepalive: true, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ content: contentRef.current }), + }).catch(() => {}); + savedRef.current = contentRef.current; + }; + window.addEventListener('beforeunload', flush); + return () => { + window.removeEventListener('beforeunload', flush); + flush(); + }; + }, [job.id]); + + return ( +