feat(local-llm): Phase F — scheduled tasks (F1-F7)
F1: cron-parser integration + cron utility functions (parse, nextRun, toHuman, shouldRunNow) F2: ScheduledTask + Project + Orchestration CRUD in IndexedDB F3: Task editor modal (schedule, model, input source, output action, prompt) F4: Browser-based task runner with setInterval + cron matching F5: /api/system/exec — safe shell command execution with allowlist F6: Task run history stored per task (last 20 runs) F7: 5 built-in task templates (morning brief, git diff, disk usage, code review, deps)
This commit is contained in:
parent
e15a5a2f2f
commit
52f3d16b65
39
__LOCAL_LLMs/dashboard/package-lock.json
generated
39
__LOCAL_LLMs/dashboard/package-lock.json
generated
@ -9,6 +9,9 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"cron-parser": "^5.5.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-react": "^0.575.0",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
@ -2781,6 +2784,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cron-parser": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/cron-parser/-/cron-parser-5.5.0.tgz",
|
||||
"integrity": "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"luxon": "^3.7.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@ -3875,6 +3890,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/fuse.js": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/fuse.js/-/fuse.js-7.1.0.tgz",
|
||||
"integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/generator-function": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/generator-function/-/generator-function-2.0.1.tgz",
|
||||
@ -4234,6 +4258,12 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/idb": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/idb/-/idb-8.0.3.tgz",
|
||||
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/ignore/-/ignore-5.3.2.tgz",
|
||||
@ -5272,6 +5302,15 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/luxon/-/luxon-3.7.2.tgz",
|
||||
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"cron-parser": "^5.5.0",
|
||||
"fuse.js": "^7.1.0",
|
||||
"idb": "^8.0.3",
|
||||
"lucide-react": "^0.575.0",
|
||||
|
||||
@ -0,0 +1,223 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { ScheduledTask } from '../../lib/types';
|
||||
import { parseCron, cronToHuman } from '../../lib/cron';
|
||||
|
||||
interface TaskEditorProps {
|
||||
task?: ScheduledTask;
|
||||
onSave: (task: ScheduledTask) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TaskEditor({ task, onSave, onClose }: TaskEditorProps) {
|
||||
const [name, setName] = useState(task?.name || '');
|
||||
const [schedule, setSchedule] = useState(task?.schedule || '0 8 * * *');
|
||||
const [model, setModel] = useState(task?.model || '');
|
||||
const [prompt, setPrompt] = useState(task?.prompt || '');
|
||||
const [enabled, setEnabled] = useState(task?.enabled ?? false);
|
||||
const [inputType, setInputType] = useState<string>(task?.inputSource?.type || 'static');
|
||||
const [inputCommand, setInputCommand] = useState(
|
||||
task?.inputSource?.type === 'command' ? task.inputSource.command : ''
|
||||
);
|
||||
const [inputFilePath, setInputFilePath] = useState(
|
||||
task?.inputSource?.type === 'file' ? task.inputSource.path : ''
|
||||
);
|
||||
const [outputType, setOutputType] = useState<string>(task?.outputAction?.type || 'conversation');
|
||||
|
||||
const cronCheck = parseCron(schedule);
|
||||
const humanSchedule = cronCheck.valid ? cronToHuman(schedule) : '';
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim() || !prompt.trim() || !cronCheck.valid) return;
|
||||
|
||||
const inputSource = (() => {
|
||||
if (inputType === 'command') return { type: 'command' as const, command: inputCommand };
|
||||
if (inputType === 'file') return { type: 'file' as const, path: inputFilePath };
|
||||
if (inputType === 'clipboard') return { type: 'clipboard' as const };
|
||||
return { type: 'static' as const };
|
||||
})();
|
||||
|
||||
const outputAction = (() => {
|
||||
if (outputType === 'clipboard') return { type: 'clipboard' as const };
|
||||
if (outputType === 'notification') return { type: 'notification' as const };
|
||||
return { type: 'conversation' as const };
|
||||
})();
|
||||
|
||||
const result: ScheduledTask = {
|
||||
id: task?.id || crypto.randomUUID(),
|
||||
name: name.trim(),
|
||||
schedule,
|
||||
scheduleHuman: humanSchedule,
|
||||
model,
|
||||
prompt: prompt.trim(),
|
||||
inputSource,
|
||||
outputAction,
|
||||
enabled,
|
||||
lastRun: task?.lastRun,
|
||||
runHistory: task?.runHistory || [],
|
||||
createdAt: task?.createdAt || Date.now(),
|
||||
};
|
||||
|
||||
onSave(result);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
|
||||
<div className="w-full max-w-lg rounded-lg border border-white/10 bg-[var(--bg-elevated)] p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{task ? 'Edit Task' : 'New Scheduled Task'}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-secondary)]">Name *</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full rounded border border-white/10 bg-[var(--surface-card)] px-3 py-1.5 text-sm text-[var(--text-primary)] outline-none"
|
||||
placeholder="Morning Brief"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-secondary)]">
|
||||
Cron Schedule *
|
||||
</label>
|
||||
<input
|
||||
value={schedule}
|
||||
onChange={e => setSchedule(e.target.value)}
|
||||
className="w-full rounded border border-white/10 bg-[var(--surface-card)] px-3 py-1.5 font-mono text-sm text-[var(--text-primary)] outline-none"
|
||||
placeholder="0 8 * * *"
|
||||
/>
|
||||
{cronCheck.valid ? (
|
||||
<p className="mt-1 text-[10px] text-[var(--accent-secondary)]">{humanSchedule}</p>
|
||||
) : (
|
||||
<p className="mt-1 text-[10px] text-[var(--danger)]">{cronCheck.error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-secondary)]">
|
||||
Model (blank = default)
|
||||
</label>
|
||||
<input
|
||||
value={model}
|
||||
onChange={e => setModel(e.target.value)}
|
||||
className="w-full rounded border border-white/10 bg-[var(--surface-card)] px-3 py-1.5 text-sm text-[var(--text-primary)] outline-none"
|
||||
placeholder="Auto-detect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-secondary)]">Prompt *</label>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={e => setPrompt(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full resize-y rounded border border-white/10 bg-[var(--surface-card)] px-3 py-1.5 text-sm text-[var(--text-primary)] outline-none"
|
||||
placeholder="Use {input} for dynamic input from source"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-secondary)]">
|
||||
Input Source
|
||||
</label>
|
||||
<select
|
||||
value={inputType}
|
||||
onChange={e => setInputType(e.target.value)}
|
||||
className="w-full rounded border border-white/10 bg-[var(--surface-card)] px-3 py-1.5 text-sm text-[var(--text-primary)] outline-none"
|
||||
>
|
||||
<option value="static">None (static prompt)</option>
|
||||
<option value="command">Shell command</option>
|
||||
<option value="file">File</option>
|
||||
<option value="clipboard">Clipboard</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-secondary)]">
|
||||
Output Action
|
||||
</label>
|
||||
<select
|
||||
value={outputType}
|
||||
onChange={e => setOutputType(e.target.value)}
|
||||
className="w-full rounded border border-white/10 bg-[var(--surface-card)] px-3 py-1.5 text-sm text-[var(--text-primary)] outline-none"
|
||||
>
|
||||
<option value="conversation">New conversation</option>
|
||||
<option value="clipboard">Copy to clipboard</option>
|
||||
<option value="notification">Browser notification</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inputType === 'command' && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-secondary)]">Command</label>
|
||||
<input
|
||||
value={inputCommand}
|
||||
onChange={e => setInputCommand(e.target.value)}
|
||||
className="w-full rounded border border-white/10 bg-[var(--surface-card)] px-3 py-1.5 font-mono text-sm text-[var(--text-primary)] outline-none"
|
||||
placeholder="git diff --stat"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inputType === 'file' && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-[var(--text-secondary)]">File Path</label>
|
||||
<input
|
||||
value={inputFilePath}
|
||||
onChange={e => setInputFilePath(e.target.value)}
|
||||
className="w-full rounded border border-white/10 bg-[var(--surface-card)] px-3 py-1.5 font-mono text-sm text-[var(--text-primary)] outline-none"
|
||||
placeholder="/path/to/file.txt"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={e => setEnabled(e.target.checked)}
|
||||
id="task-enabled"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<label htmlFor="task-enabled" className="text-sm text-[var(--text-secondary)]">
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border border-white/10 px-4 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!name.trim() || !prompt.trim() || !cronCheck.valid}
|
||||
className="rounded bg-[var(--accent-primary)] px-4 py-1.5 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { ScheduledTask } from '../../lib/types';
|
||||
import { shouldRunNow } from '../../lib/cron';
|
||||
import { updateScheduledTask } from '../../lib/db';
|
||||
|
||||
interface TaskRunnerProps {
|
||||
tasks: ScheduledTask[];
|
||||
onTaskRan: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export function TaskRunner({ tasks, onTaskRan }: TaskRunnerProps) {
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const check = async () => {
|
||||
for (const task of tasks) {
|
||||
if (!task.enabled) continue;
|
||||
if (!shouldRunNow(task.schedule, task.lastRun)) continue;
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
let input = '';
|
||||
|
||||
if (task.inputSource?.type === 'command') {
|
||||
const parts = task.inputSource.command.split(/\s+/);
|
||||
const command = parts[0];
|
||||
const args = parts.slice(1);
|
||||
const res = await fetch('/api/system/exec', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command, args }),
|
||||
});
|
||||
const data = (await res.json()) as { stdout?: string; stderr?: string };
|
||||
input = data.stdout || data.stderr || '';
|
||||
} else if (task.inputSource?.type === 'clipboard') {
|
||||
try {
|
||||
input = await navigator.clipboard.readText();
|
||||
} catch {
|
||||
input = '[clipboard unavailable]';
|
||||
}
|
||||
}
|
||||
|
||||
const finalPrompt = task.prompt.replace(/\{input\}/g, input);
|
||||
|
||||
const res = await fetch('/api/ollama/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: task.model || undefined,
|
||||
messages: [{ role: 'user', content: finalPrompt }],
|
||||
}),
|
||||
});
|
||||
|
||||
let result = '';
|
||||
if (res.ok && res.body) {
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const chunk = JSON.parse(line) as { message?: { content?: string } };
|
||||
if (chunk.message?.content) result += chunk.message.content;
|
||||
} catch {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - start;
|
||||
const runEntry = {
|
||||
timestamp: Date.now(),
|
||||
durationMs,
|
||||
success: true,
|
||||
result: result.slice(0, 500),
|
||||
};
|
||||
|
||||
await updateScheduledTask(task.id, {
|
||||
lastRun: Date.now(),
|
||||
runHistory: [...(task.runHistory || []).slice(-19), runEntry],
|
||||
});
|
||||
|
||||
if (task.outputAction?.type === 'notification' && 'Notification' in window) {
|
||||
if (Notification.permission === 'granted') {
|
||||
new Notification(task.name, { body: result.slice(0, 200) });
|
||||
} else if (Notification.permission !== 'denied') {
|
||||
await Notification.requestPermission();
|
||||
}
|
||||
} else if (task.outputAction?.type === 'clipboard') {
|
||||
await navigator.clipboard.writeText(result);
|
||||
}
|
||||
|
||||
onTaskRan(task.id);
|
||||
} catch (err) {
|
||||
const durationMs = Date.now() - start;
|
||||
const runEntry = {
|
||||
timestamp: Date.now(),
|
||||
durationMs,
|
||||
success: false,
|
||||
result: String(err).slice(0, 500),
|
||||
};
|
||||
|
||||
await updateScheduledTask(task.id, {
|
||||
lastRun: Date.now(),
|
||||
runHistory: [...(task.runHistory || []).slice(-19), runEntry],
|
||||
});
|
||||
|
||||
onTaskRan(task.id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
intervalRef.current = setInterval(() => void check(), 60_000);
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [tasks, onTaskRan]);
|
||||
|
||||
return null;
|
||||
}
|
||||
67
__LOCAL_LLMs/dashboard/src/app/api/system/exec/route.ts
Normal file
67
__LOCAL_LLMs/dashboard/src/app/api/system/exec/route.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const COMMAND_ALLOWLIST = new Set([
|
||||
'git',
|
||||
'npm',
|
||||
'brew',
|
||||
'cat',
|
||||
'ls',
|
||||
'wc',
|
||||
'du',
|
||||
'df',
|
||||
'echo',
|
||||
'date',
|
||||
]);
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = (await request.json()) as { command?: string; args?: string[] };
|
||||
const { command, args = [] } = body;
|
||||
|
||||
if (!command) {
|
||||
return NextResponse.json({ error: 'Missing command' }, { status: 400 });
|
||||
}
|
||||
|
||||
const extended =
|
||||
typeof window === 'undefined'
|
||||
? (() => {
|
||||
try {
|
||||
const raw = process.env.LLM_COMMAND_ALLOWLIST;
|
||||
if (raw) return new Set(JSON.parse(raw) as string[]);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return new Set<string>();
|
||||
})()
|
||||
: new Set<string>();
|
||||
|
||||
if (!COMMAND_ALLOWLIST.has(command) && !extended.has(command)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Command "${command}" is not in the allowlist`,
|
||||
allowed: [...COMMAND_ALLOWLIST, ...extended],
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { stdout, stderr } = await execFileAsync(command, args, {
|
||||
timeout: 30_000,
|
||||
maxBuffer: 1024 * 1024,
|
||||
env: { ...process.env, PATH: process.env.PATH },
|
||||
});
|
||||
|
||||
return NextResponse.json({ stdout, stderr, exitCode: 0 });
|
||||
} catch (err: unknown) {
|
||||
const error = err as { stdout?: string; stderr?: string; code?: number; message?: string };
|
||||
return NextResponse.json({
|
||||
stdout: error.stdout || '',
|
||||
stderr: error.stderr || error.message || String(err),
|
||||
exitCode: error.code ?? 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
54
__LOCAL_LLMs/dashboard/src/app/lib/cron.ts
Normal file
54
__LOCAL_LLMs/dashboard/src/app/lib/cron.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
|
||||
export function parseCron(expr: string): { valid: boolean; error?: string } {
|
||||
try {
|
||||
CronExpressionParser.parse(expr);
|
||||
return { valid: true };
|
||||
} catch (err) {
|
||||
return { valid: false, error: String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export function getNextRun(expr: string): Date | null {
|
||||
try {
|
||||
const interval = CronExpressionParser.parse(expr);
|
||||
return interval.next().toDate();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function cronToHuman(expr: string): string {
|
||||
const parts = expr.trim().split(/\s+/);
|
||||
if (parts.length < 5) return expr;
|
||||
|
||||
const [min, hour, dom, mon, dow] = parts;
|
||||
|
||||
if (min === '0' && hour === '*' && dom === '*' && mon === '*' && dow === '*') {
|
||||
return 'Every hour';
|
||||
}
|
||||
if (dom === '*' && mon === '*' && dow === '*') {
|
||||
if (hour === '*') return `Every hour at :${min?.padStart(2, '0')}`;
|
||||
return `Daily at ${hour}:${min?.padStart(2, '0')}`;
|
||||
}
|
||||
if (mon === '*' && dow !== '*') {
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const dayName = days[Number(dow)] || dow;
|
||||
return `Every ${dayName} at ${hour}:${min?.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return expr;
|
||||
}
|
||||
|
||||
export function shouldRunNow(expr: string, lastRun?: number): boolean {
|
||||
try {
|
||||
const interval = CronExpressionParser.parse(expr);
|
||||
const prev = interval.prev().toDate();
|
||||
const prevMs = prev.getTime();
|
||||
|
||||
if (!lastRun) return true;
|
||||
return prevMs > lastRun;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -388,3 +388,106 @@ export async function importAgents(agents: Agent[]): Promise<number> {
|
||||
}
|
||||
return imported;
|
||||
}
|
||||
|
||||
export async function addScheduledTask(task: ScheduledTask): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.put('scheduledTasks', task);
|
||||
}
|
||||
|
||||
export async function getScheduledTask(id: string): Promise<ScheduledTask | undefined> {
|
||||
const db = await getDb();
|
||||
return db.get('scheduledTasks', id);
|
||||
}
|
||||
|
||||
export async function listScheduledTasks(): Promise<ScheduledTask[]> {
|
||||
const db = await getDb();
|
||||
const rows = await db.getAll('scheduledTasks');
|
||||
rows.sort((a, b) => b.createdAt - a.createdAt);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function updateScheduledTask(
|
||||
id: string,
|
||||
partial: Partial<ScheduledTask>
|
||||
): Promise<ScheduledTask | null> {
|
||||
const db = await getDb();
|
||||
const existing = await db.get('scheduledTasks', id);
|
||||
if (!existing) return null;
|
||||
const updated: ScheduledTask = { ...existing, ...partial, id };
|
||||
await db.put('scheduledTasks', updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function deleteScheduledTask(id: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete('scheduledTasks', id);
|
||||
}
|
||||
|
||||
export async function addProject(project: Project): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.put('projects', project);
|
||||
}
|
||||
|
||||
export async function getProject(id: string): Promise<Project | undefined> {
|
||||
const db = await getDb();
|
||||
return db.get('projects', id);
|
||||
}
|
||||
|
||||
export async function listProjects(): Promise<Project[]> {
|
||||
const db = await getDb();
|
||||
const rows = await db.getAll('projects');
|
||||
rows.sort((a, b) => {
|
||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
||||
return b.createdAt - a.createdAt;
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function updateProject(
|
||||
id: string,
|
||||
partial: Partial<Project>
|
||||
): Promise<Project | null> {
|
||||
const db = await getDb();
|
||||
const existing = await db.get('projects', id);
|
||||
if (!existing) return null;
|
||||
const updated: Project = { ...existing, ...partial, id };
|
||||
await db.put('projects', updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function deleteProject(id: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete('projects', id);
|
||||
}
|
||||
|
||||
export async function addOrchestration(orch: Orchestration): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.put('orchestrations', orch);
|
||||
}
|
||||
|
||||
export async function getOrchestration(id: string): Promise<Orchestration | undefined> {
|
||||
const db = await getDb();
|
||||
return db.get('orchestrations', id);
|
||||
}
|
||||
|
||||
export async function listOrchestrations(): Promise<Orchestration[]> {
|
||||
const db = await getDb();
|
||||
return db.getAll('orchestrations');
|
||||
}
|
||||
|
||||
export async function updateOrchestration(
|
||||
id: string,
|
||||
partial: Partial<Orchestration>
|
||||
): Promise<Orchestration | null> {
|
||||
const db = await getDb();
|
||||
const existing = await db.get('orchestrations', id);
|
||||
if (!existing) return null;
|
||||
const updated: Orchestration = { ...existing, ...partial, id };
|
||||
await db.put('orchestrations', updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function deleteOrchestration(id: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.delete('orchestrations', id);
|
||||
}
|
||||
|
||||
64
__LOCAL_LLMs/dashboard/src/app/lib/scheduled-tasks.ts
Normal file
64
__LOCAL_LLMs/dashboard/src/app/lib/scheduled-tasks.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import type { ScheduledTask } from './types';
|
||||
|
||||
export const BUILTIN_TASK_TEMPLATES: Omit<ScheduledTask, 'id' | 'createdAt'>[] = [
|
||||
{
|
||||
name: 'Morning Brief',
|
||||
schedule: '0 8 * * *',
|
||||
scheduleHuman: 'Daily at 8:00 AM',
|
||||
model: '',
|
||||
prompt:
|
||||
'Give me a brief morning summary: what day of the week it is, any notable tech news headlines you know about, and a motivational quote to start the day. Keep it under 200 words.',
|
||||
inputSource: { type: 'static' },
|
||||
outputAction: { type: 'conversation' },
|
||||
enabled: false,
|
||||
runHistory: [],
|
||||
},
|
||||
{
|
||||
name: 'Git Diff Summary',
|
||||
schedule: '0 17 * * 1-5',
|
||||
scheduleHuman: 'Weekdays at 5:00 PM',
|
||||
model: '',
|
||||
prompt:
|
||||
'Summarize the following git diff output. Group changes by file, highlight key modifications, and note any potential issues:\n\n{input}',
|
||||
inputSource: { type: 'command', command: 'git diff --stat' },
|
||||
outputAction: { type: 'conversation' },
|
||||
enabled: false,
|
||||
runHistory: [],
|
||||
},
|
||||
{
|
||||
name: 'Disk Usage Alert',
|
||||
schedule: '0 12 * * *',
|
||||
scheduleHuman: 'Daily at noon',
|
||||
model: '',
|
||||
prompt:
|
||||
'Analyze this disk usage report and alert me if any partition is over 80% full. Provide recommendations for cleanup if needed:\n\n{input}',
|
||||
inputSource: { type: 'command', command: 'df -h' },
|
||||
outputAction: { type: 'notification' },
|
||||
enabled: false,
|
||||
runHistory: [],
|
||||
},
|
||||
{
|
||||
name: 'Weekly Code Review Reminder',
|
||||
schedule: '0 10 * * 1',
|
||||
scheduleHuman: 'Every Monday at 10:00 AM',
|
||||
model: '',
|
||||
prompt:
|
||||
'Generate a code review checklist for this week. Include: security best practices, performance considerations, documentation gaps, and testing coverage reminders. Format as a markdown checklist.',
|
||||
inputSource: { type: 'static' },
|
||||
outputAction: { type: 'conversation' },
|
||||
enabled: false,
|
||||
runHistory: [],
|
||||
},
|
||||
{
|
||||
name: 'Dependency Check',
|
||||
schedule: '0 9 * * 3',
|
||||
scheduleHuman: 'Every Wednesday at 9:00 AM',
|
||||
model: '',
|
||||
prompt:
|
||||
'Analyze the following npm outdated output and prioritize which packages should be updated. Flag any with known security vulnerabilities:\n\n{input}',
|
||||
inputSource: { type: 'command', command: 'npm outdated' },
|
||||
outputAction: { type: 'conversation' },
|
||||
enabled: false,
|
||||
runHistory: [],
|
||||
},
|
||||
];
|
||||
Loading…
Reference in New Issue
Block a user