Added a notes section for jobs

This commit is contained in:
Jaret Burkett committed 2026-09-19 10:28:29 -06:00
1 parent cc98c3b690
commit a6e3b18e8a
3 files changed
+180 -2

No files matched your search

@@ -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<string | null> => {
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 });
}
+10 -2
View File
@@ -2,7 +2,7 @@
import { useState, use } from 'react'; import { useState, use } from 'react';
import { FaChevronLeft } from 'react-icons/fa'; 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 { Button } from '@headlessui/react';
import { TopBar, MainContent } from '@/components/layout'; import { TopBar, MainContent } from '@/components/layout';
import useJob from '@/hooks/useJob'; import useJob from '@/hooks/useJob';
@@ -14,10 +14,11 @@ import JobActionBar from '@/components/JobActionBar';
import JobConfigViewer from '@/components/JobConfigViewer'; import JobConfigViewer from '@/components/JobConfigViewer';
import JobLossGraph from '@/components/JobLossGraph'; import JobLossGraph from '@/components/JobLossGraph';
import JobPlugin from '@/components/JobPlugin'; import JobPlugin from '@/components/JobPlugin';
import JobNotes from '@/components/JobNotes';
import { Job } from '@prisma/client'; import { Job } from '@prisma/client';
import { apiClient } from '@/utils/api'; import { apiClient } from '@/utils/api';
type PageKey = 'overview' | 'samples' | 'config' | 'loss_log' | 'plugin'; type PageKey = 'overview' | 'samples' | 'config' | 'loss_log' | 'notes' | 'plugin';
interface Page { interface Page {
name: string; name: string;
@@ -61,6 +62,13 @@ const pages: Page[] = [
component: JobConfigViewer, component: JobConfigViewer,
mainCss: 'pt-[80px] px-0 pb-0', mainCss: 'pt-[80px] px-0 pb-0',
}, },
{
name: 'Notes',
value: 'notes',
icon: MdNotes,
component: JobNotes,
mainCss: 'pt-24 pb-4',
},
{ {
name: 'Plugin', name: 'Plugin',
value: 'plugin', value: 'plugin',
+111
View File
@@ -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 (
<textarea
value={content}
onChange={e => {
contentRef.current = e.target.value;
setContent(e.target.value);
}}
onBlur={() => save()}
onKeyDown={e => {
// ctrl/cmd+s saves immediately instead of hitting the browser save dialog
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
save();
}
}}
disabled={isLoading}
spellCheck={false}
placeholder="Notes for this job (markdown)..."
className="block w-full h-full resize-none bg-gray-900 text-gray-100 font-mono text-sm p-3 outline-none disabled:opacity-50"
/>
);
}