Added a notes section for jobs
This commit is contained in:
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 });
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user