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 { 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',
|
||||
|
||||
@@ -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