mirror of
https://github.com/larchanka/manbot.git
synced 2026-05-13 21:42:08 +00:00
feat: implement reminder skill and enhance executor with natural language time parsing
This commit is contained in:
committed by
Mikhail Larchanka
parent
06951c7892
commit
07d0798eec
@@ -4,4 +4,5 @@
|
||||
| --- | --- |
|
||||
| weather | ALWAYS use this skill to get the current weather and forecasts. NEVER use any other tool for this purpose. |
|
||||
| research | Deep web research using lynx. Use this for fact-checking, gathering news, or deep dives into specific topics. |
|
||||
| apple-notes | ALWAYS use this skill when the user mentions "notes", "Apple Notes", "memo", or asks to "list", "search", or "create" a note. It utilizes the `memo` CLI to interact with the local Apple Notes database. |
|
||||
| reminder | Set up one-time or recurring reminders (e.g., "remind me in 2 hours to drink water"). |
|
||||
| apple-notes | ALWAYS use this skill when the user mentions "notes", "Apple Notes", or asks to "list", "search", or "create" a note. It utilizes the `memo` CLI to interact with the local Apple Notes database. |
|
||||
|
||||
41
skills/reminder/SKILL.md
Normal file
41
skills/reminder/SKILL.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Reminder Skill
|
||||
|
||||
Set up one-time or recurring reminders for the user.
|
||||
|
||||
## When to Use
|
||||
|
||||
**USE this skill when the user asks to:**
|
||||
- "Remind me to [do something] [at/in/every/...] [time]"
|
||||
- "Set a reminder for [time] to [do something]"
|
||||
- "Don't forget to [do something] [time]"
|
||||
- "Notify me [time] about [something]"
|
||||
|
||||
**DON'T use this skill when:**
|
||||
- The user is asking to list current reminders (use core /reminders command instead).
|
||||
- The user is asking to cancel a reminder.
|
||||
- The user is asking to create a note (use apple-notes skill).
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Extract the Task**: Identify what the user wants to be reminded about.
|
||||
2. **Extract the Time**: Identify the temporal expression (e.g., "in 2 hours", "every day at 8am").
|
||||
3. **Schedule**: Call the `schedule_reminder` tool with the extracted time and message.
|
||||
|
||||
## Tool: schedule_reminder
|
||||
|
||||
**Arguments**:
|
||||
- `time`: (string) Natural language time expression (e.g., "in 5 minutes", "tomorrow at 3pm", "every Monday").
|
||||
- `message`: (string) The content of the reminder.
|
||||
|
||||
## Strategy
|
||||
|
||||
- Be precise with the `message`. If the user says "remind me to drink water", the message should be "Drink water".
|
||||
- If the user provides a vague time, ask for clarification if necessary, or use your best judgment (e.g., "later today" could be "in 4 hours").
|
||||
- The system will automatically handle parsing the natural language `time` string into a cron expression.
|
||||
|
||||
## Example Workflow
|
||||
|
||||
User Goal: "remind me to call Mom in 20 minutes"
|
||||
|
||||
1. Call `schedule_reminder(time="in 20 minutes", message="Call Mom")`.
|
||||
2. Respond to the user: "Sure! I'll remind you to Call Mom in 20 minutes."
|
||||
@@ -26,6 +26,8 @@ Web research is a multi-step process. Do not stop at the first page of search re
|
||||
4. **Recurse**: If a page contains a "References" section or promising links to deeper info, follow them.
|
||||
5. **Summarize**: Gather all key findings and provide a comprehensive response.
|
||||
|
||||
**!!! IMPORTANT !!!** If you think that you dont have enough information to answer the user's request, you can generate new search queries and repeat the process.
|
||||
|
||||
## Commands
|
||||
|
||||
### 1. Perform a Search
|
||||
|
||||
@@ -10,7 +10,7 @@ const MAX_CONCURRENT_NODES = 5;
|
||||
const MAX_REVISION_CYCLES = 3;
|
||||
const MAX_SKILL_TURNS = 5;
|
||||
|
||||
const SKILL_TOOLS = [
|
||||
const SKILL_TOOLS: any[] = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
@@ -39,6 +39,21 @@ const SKILL_TOOLS = [
|
||||
required: ["url"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "schedule_reminder",
|
||||
description: "Schedule a reminder for the user. Use natural language for the 'time' parameter.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
time: { type: "string", description: "When to remind (e.g., 'in 2 hours', 'every Monday at 9am', 'tomorrow at 3pm')" },
|
||||
message: { type: "string", description: "The content of the reminder (what to remind about)" }
|
||||
},
|
||||
required: ["time", "message"]
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -55,6 +70,7 @@ import { PROTOCOL_VERSION } from "../shared/protocol.js";
|
||||
import { responsePayloadSchema } from "../shared/protocol.js";
|
||||
import { getConfig } from "../shared/config.js";
|
||||
import { SkillManager } from "../services/skill-manager.js";
|
||||
import { parseTimeExpression } from "../services/time-parser.js";
|
||||
|
||||
const PLAN_EXECUTE = "plan.execute";
|
||||
const NODE_EXECUTE = "node.execute";
|
||||
@@ -483,7 +499,7 @@ export class ExecutorAgent extends BaseProcess {
|
||||
|
||||
// Execute each tool call
|
||||
for (const tc of result.tool_calls) {
|
||||
const toolResult = await this.callTool(taskId, node.id, tc.function.name, tc.function.arguments);
|
||||
const toolResult = await this.callTool(taskId, node.id, tc.function.name, tc.function.arguments, context);
|
||||
let content = typeof toolResult === "string" ? toolResult : JSON.stringify(toolResult);
|
||||
// Safety truncation for large web pages or shell outputs to stay within context limits
|
||||
if (content.length > 30000) {
|
||||
@@ -610,6 +626,19 @@ export class ExecutorAgent extends BaseProcess {
|
||||
// Extract cronExpr from input or dependency output
|
||||
let cronExpr = nodeInput.cronExpr as string | undefined;
|
||||
|
||||
// Support natural language 'time' input
|
||||
if (!cronExpr) {
|
||||
const time = (nodeInput.time as string | undefined) ?? (nodeInput.prompt as string | undefined);
|
||||
if (time) {
|
||||
try {
|
||||
const result = await parseTimeExpression(time);
|
||||
cronExpr = result.cronExpr;
|
||||
} catch (err) {
|
||||
// Fallback to extraction from deps if parsing fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if cronExpr is a placeholder string (should be ignored)
|
||||
const isPlaceholder = cronExpr && (
|
||||
cronExpr.includes("<output from") ||
|
||||
@@ -698,6 +727,7 @@ export class ExecutorAgent extends BaseProcess {
|
||||
const chatId = (nodeInput.chatId as number | string | undefined) ??
|
||||
(context._chatId as number | string | undefined);
|
||||
let reminderMessage = (nodeInput.reminderMessage as string | undefined) ??
|
||||
(nodeInput.message as string | undefined) ??
|
||||
(context.reminderMessage as string | undefined);
|
||||
const userId = (nodeInput.userId as number | string | undefined) ??
|
||||
(context._userId as number | string | undefined);
|
||||
@@ -874,7 +904,16 @@ export class ExecutorAgent extends BaseProcess {
|
||||
});
|
||||
}
|
||||
|
||||
private callTool(taskId: string, nodeId: string, name: string, args: any): Promise<any> {
|
||||
private callTool(taskId: string, nodeId: string, name: string, args: any, context: Record<string, unknown> = {}): Promise<any> {
|
||||
if (name === "schedule_reminder") {
|
||||
const node: CapabilityNode = {
|
||||
id: nodeId,
|
||||
type: "schedule_reminder",
|
||||
service: "cron-manager",
|
||||
input: args
|
||||
};
|
||||
return this.handleScheduleReminder(taskId, node, context);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = randomUUID();
|
||||
this.send({
|
||||
|
||||
@@ -126,6 +126,26 @@ User: "Deep dive into the current status of the RISC-V ecosystem."
|
||||
{ "from": "research-eco", "to": "final-report" }
|
||||
]
|
||||
}
|
||||
|
||||
## Example: Reminder
|
||||
User: "remind me to drink water in 2 hrs"
|
||||
{
|
||||
"taskId": "task-rem-01",
|
||||
"complexity": "small",
|
||||
"reflectionMode": "OFF",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "rem-node",
|
||||
"type": "skill",
|
||||
"service": "executor",
|
||||
"input": {
|
||||
"skillName": "reminder",
|
||||
"task": "remind me to drink water in 2 hrs"
|
||||
}
|
||||
}
|
||||
],
|
||||
"edges": []
|
||||
}
|
||||
</examples>`;
|
||||
|
||||
export interface PlannerPromptOptions {
|
||||
|
||||
@@ -186,6 +186,7 @@ const CSS = `
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
th {
|
||||
@@ -193,14 +194,16 @@ const CSS = `
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
padding: 8px 0;
|
||||
padding: 12px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 0;
|
||||
padding: 12px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 14px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.tag {
|
||||
@@ -298,183 +301,183 @@ const CSS = `
|
||||
`;
|
||||
|
||||
export class DashboardService extends BaseProcess {
|
||||
private readonly server: http.Server;
|
||||
private readonly server: http.Server;
|
||||
|
||||
constructor() {
|
||||
super({ processName: 'dashboard' });
|
||||
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
||||
}
|
||||
constructor() {
|
||||
super({ processName: 'dashboard' });
|
||||
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
||||
}
|
||||
|
||||
override start(): void {
|
||||
super.start();
|
||||
this.server.listen(PORT, () => {
|
||||
this.logEvent('info', `Dashboard server started on port ${PORT}`);
|
||||
});
|
||||
override start(): void {
|
||||
super.start();
|
||||
this.server.listen(PORT, () => {
|
||||
this.logEvent('info', `Dashboard server started on port ${PORT}`);
|
||||
});
|
||||
|
||||
// Send initial log announcement to orchestrator
|
||||
this.send({
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
from: 'dashboard',
|
||||
to: 'logger',
|
||||
type: 'event.system.dashboard_online',
|
||||
version: '1.0',
|
||||
payload: { port: PORT, status: 'online' }
|
||||
});
|
||||
}
|
||||
// Send initial log announcement to orchestrator
|
||||
this.send({
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
from: 'dashboard',
|
||||
to: 'logger',
|
||||
type: 'event.system.dashboard_online',
|
||||
version: '1.0',
|
||||
payload: { port: PORT, status: 'online' }
|
||||
});
|
||||
}
|
||||
|
||||
private logEvent(level: string, message: string) {
|
||||
this.send({
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
from: 'dashboard',
|
||||
to: 'logger',
|
||||
type: `event.dashboard.${level}`,
|
||||
version: '1.0',
|
||||
payload: { message }
|
||||
});
|
||||
}
|
||||
private logEvent(level: string, message: string) {
|
||||
this.send({
|
||||
id: randomUUID(),
|
||||
timestamp: Date.now(),
|
||||
from: 'dashboard',
|
||||
to: 'logger',
|
||||
type: `event.dashboard.${level}`,
|
||||
version: '1.0',
|
||||
payload: { message }
|
||||
});
|
||||
}
|
||||
|
||||
private handleRequest(req: http.IncomingMessage, res: http.ServerResponse) {
|
||||
if (req.url === '/api/stats') {
|
||||
const s = this.getStats();
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({
|
||||
...s,
|
||||
charts: {
|
||||
taskDonut: this.generateDonutChart(s.tasks),
|
||||
compBar: this.generateBarChart(Object.keys(s.complexity), Object.values(s.complexity))
|
||||
}
|
||||
}));
|
||||
return;
|
||||
private handleRequest(req: http.IncomingMessage, res: http.ServerResponse) {
|
||||
if (req.url === '/api/stats') {
|
||||
const s = this.getStats();
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({
|
||||
...s,
|
||||
charts: {
|
||||
taskDonut: this.generateDonutChart(s.tasks),
|
||||
compBar: this.generateBarChart(Object.keys(s.complexity), Object.values(s.complexity))
|
||||
}
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === '/') {
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.end(this.getHTML());
|
||||
return;
|
||||
if (req.url === '/') {
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.end(this.getHTML());
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url?.startsWith('/api/fail-task')) {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const taskId = url.searchParams.get('id');
|
||||
if (taskId) {
|
||||
try {
|
||||
const tdb = new Database(path.join(ROOT_DIR, 'data/tasks.sqlite'));
|
||||
tdb.prepare("UPDATE tasks SET status = 'failed', updated_at = ?, metadata = ? WHERE id = ?").run(Date.now(), JSON.stringify({ reason: 'Manually failed via dashboard' }), taskId);
|
||||
tdb.close();
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ status: 'success' }));
|
||||
return;
|
||||
} catch (e) {
|
||||
res.statusCode = 500;
|
||||
res.end(JSON.stringify({ status: 'error', message: String(e) }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url?.startsWith('/api/fail-task')) {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const taskId = url.searchParams.get('id');
|
||||
if (taskId) {
|
||||
try {
|
||||
const tdb = new Database(path.join(ROOT_DIR, 'data/tasks.sqlite'));
|
||||
tdb.prepare("UPDATE tasks SET status = 'failed', updated_at = ?, metadata = ? WHERE id = ?").run(Date.now(), JSON.stringify({ reason: 'Manually failed via dashboard' }), taskId);
|
||||
tdb.close();
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ status: 'success' }));
|
||||
return;
|
||||
} catch (e) {
|
||||
res.statusCode = 500;
|
||||
res.end(JSON.stringify({ status: 'error', message: String(e) }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.statusCode = 404;
|
||||
res.end('Not Found');
|
||||
}
|
||||
}
|
||||
|
||||
private getStats() {
|
||||
const stats: any = {
|
||||
tasks: {},
|
||||
complexity: { small: 0, medium: 0, large: 0, unknown: 0 },
|
||||
rag: 0,
|
||||
cron: 0,
|
||||
logs: [],
|
||||
maxNodes: 0,
|
||||
timing: { first: '-', last: '-', avg: '-' },
|
||||
models: {}
|
||||
};
|
||||
try {
|
||||
const configPath = path.join(ROOT_DIR, 'config.json');
|
||||
if (fs.existsSync(configPath)) {
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
stats.models = config.modelRouter || {};
|
||||
}
|
||||
} catch (e) { }
|
||||
res.statusCode = 404;
|
||||
res.end('Not Found');
|
||||
}
|
||||
|
||||
try {
|
||||
const tdb = new Database(path.join(ROOT_DIR, 'data/tasks.sqlite'), { readonly: true });
|
||||
tdb.prepare('SELECT status, count(*) as c FROM tasks GROUP BY status').all().forEach((r: any) => stats.tasks[r.status] = r.c);
|
||||
tdb.prepare('SELECT complexity, count(*) as c FROM tasks GROUP BY complexity').all().forEach((r: any) => {
|
||||
const key = r.complexity ? r.complexity.toLowerCase() : 'unknown';
|
||||
stats.complexity[key] = (stats.complexity[key] || 0) + r.c;
|
||||
});
|
||||
const peak = tdb.prepare('SELECT MAX(cnt) as m FROM (SELECT count(*) as cnt FROM task_nodes GROUP BY task_id)').get() as { m: number } | undefined;
|
||||
stats.maxNodes = peak ? peak.m : 0;
|
||||
private getStats() {
|
||||
const stats: any = {
|
||||
tasks: {},
|
||||
complexity: { small: 0, medium: 0, large: 0, unknown: 0 },
|
||||
rag: 0,
|
||||
cron: 0,
|
||||
logs: [],
|
||||
maxNodes: 0,
|
||||
timing: { first: '-', last: '-', avg: '-' },
|
||||
models: {}
|
||||
};
|
||||
try {
|
||||
const configPath = path.join(ROOT_DIR, 'config.json');
|
||||
if (fs.existsSync(configPath)) {
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
stats.models = config.modelRouter || {};
|
||||
}
|
||||
} catch (e) { }
|
||||
|
||||
const times = tdb.prepare('SELECT MIN(created_at) as first, MAX(updated_at) as last FROM tasks').get() as { first: number, last: number } | undefined;
|
||||
if (times?.first) {
|
||||
stats.timing.first = new Date(times.first).toLocaleDateString() + ' ' + new Date(times.first).toLocaleTimeString();
|
||||
stats.timing.last = new Date(times.last).toLocaleDateString() + ' ' + new Date(times.last).toLocaleTimeString();
|
||||
}
|
||||
try {
|
||||
const tdb = new Database(path.join(ROOT_DIR, 'data/tasks.sqlite'), { readonly: true });
|
||||
tdb.prepare('SELECT status, count(*) as c FROM tasks GROUP BY status').all().forEach((r: any) => stats.tasks[r.status] = r.c);
|
||||
tdb.prepare('SELECT complexity, count(*) as c FROM tasks GROUP BY complexity').all().forEach((r: any) => {
|
||||
const key = r.complexity ? r.complexity.toLowerCase() : 'unknown';
|
||||
stats.complexity[key] = (stats.complexity[key] || 0) + r.c;
|
||||
});
|
||||
const peak = tdb.prepare('SELECT MAX(cnt) as m FROM (SELECT count(*) as cnt FROM task_nodes GROUP BY task_id)').get() as { m: number } | undefined;
|
||||
stats.maxNodes = peak ? peak.m : 0;
|
||||
|
||||
const avg = tdb.prepare("SELECT AVG(updated_at - created_at) as a FROM tasks WHERE status = 'completed' AND updated_at > created_at").get() as { a: number } | undefined;
|
||||
if (avg?.a) {
|
||||
const sec = Math.round(avg.a / 1000);
|
||||
stats.timing.avg = sec > 60 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${sec}s`;
|
||||
}
|
||||
const times = tdb.prepare('SELECT MIN(created_at) as first, MAX(updated_at) as last FROM tasks').get() as { first: number, last: number } | undefined;
|
||||
if (times?.first) {
|
||||
stats.timing.first = new Date(times.first).toLocaleDateString() + ' ' + new Date(times.first).toLocaleTimeString();
|
||||
stats.timing.last = new Date(times.last).toLocaleDateString() + ' ' + new Date(times.last).toLocaleTimeString();
|
||||
}
|
||||
|
||||
stats.pendingTasks = tdb.prepare('SELECT id, goal, status, complexity, updated_at FROM tasks WHERE status IN (?, ?) ORDER BY updated_at DESC')
|
||||
.all('pending', 'running');
|
||||
const avg = tdb.prepare("SELECT AVG(updated_at - created_at) as a FROM tasks WHERE status = 'completed' AND updated_at > created_at").get() as { a: number } | undefined;
|
||||
if (avg?.a) {
|
||||
const sec = Math.round(avg.a / 1000);
|
||||
stats.timing.avg = sec > 60 ? `${Math.floor(sec / 60)}m ${sec % 60}s` : `${sec}s`;
|
||||
}
|
||||
|
||||
tdb.close();
|
||||
} catch (e) { }
|
||||
try {
|
||||
const rdb = new Database(path.join(ROOT_DIR, 'data/rag.sqlite'), { readonly: true });
|
||||
const row = rdb.prepare('SELECT count(*) as c FROM rag_documents').get() as { c: number } | undefined;
|
||||
stats.rag = row ? row.c : 0;
|
||||
rdb.close();
|
||||
} catch (e) { }
|
||||
try {
|
||||
const cdb = new Database(path.join(ROOT_DIR, 'data/cron.sqlite'), { readonly: true });
|
||||
const row = cdb.prepare('SELECT count(*) as c FROM cron_schedules WHERE enabled=1').get() as { c: number } | undefined;
|
||||
stats.cron = row ? row.c : 0;
|
||||
cdb.close();
|
||||
} catch (e) { }
|
||||
try {
|
||||
const logPath = path.join(ROOT_DIR, 'logs/events.log');
|
||||
if (fs.existsSync(logPath)) {
|
||||
stats.logs = fs.readFileSync(logPath, 'utf8').trim().split('\n').slice(-20).map(line => {
|
||||
try { return JSON.parse(line); } catch (e) { return { message: line }; }
|
||||
}).reverse();
|
||||
}
|
||||
} catch (e) { }
|
||||
return stats;
|
||||
}
|
||||
stats.pendingTasks = tdb.prepare('SELECT id, goal, status, complexity, updated_at FROM tasks WHERE status IN (?, ?) ORDER BY updated_at DESC')
|
||||
.all('pending', 'running');
|
||||
|
||||
private generateDonutChart(data: any, size = 200) {
|
||||
const total = Object.values(data).reduce((a: any, b: any) => a + b, 0) as number;
|
||||
if (!total) return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><text x="50%" y="50%" text-anchor="middle" fill="#8b8b8b">No Data</text></svg>`;
|
||||
const radius = 75, circumference = 2 * Math.PI * radius;
|
||||
let offset = 0;
|
||||
const colors: any = { completed: '#0b6e4f', failed: '#df2a5f', pending: '#d9730d', running: '#2383e2' };
|
||||
const slices = Object.entries(data).map(([k, v]: [string, any]) => {
|
||||
const p = v / total, dash = (p * circumference) + ' ' + circumference, currentOffset = -offset;
|
||||
offset += p * circumference;
|
||||
return `<circle cx="100" cy="100" r="${radius}" fill="transparent" stroke="${colors[k] || '#2383e2'}" stroke-width="25" stroke-dasharray="${dash}" stroke-dashoffset="${currentOffset}" transform="rotate(-90 100 100)"></circle>`;
|
||||
}).join('');
|
||||
return `<svg width="100%" height="100%" viewBox="0 0 200 200" preserveAspectRatio="xMidYMid meet">${slices}<text x="50%" y="50%" text-anchor="middle" dy=".3em" fill="currentColor" font-size="28" font-weight="700">${total}</text></svg>`;
|
||||
}
|
||||
tdb.close();
|
||||
} catch (e) { }
|
||||
try {
|
||||
const rdb = new Database(path.join(ROOT_DIR, 'data/rag.sqlite'), { readonly: true });
|
||||
const row = rdb.prepare('SELECT count(*) as c FROM rag_documents').get() as { c: number } | undefined;
|
||||
stats.rag = row ? row.c : 0;
|
||||
rdb.close();
|
||||
} catch (e) { }
|
||||
try {
|
||||
const cdb = new Database(path.join(ROOT_DIR, 'data/cron.sqlite'), { readonly: true });
|
||||
const row = cdb.prepare('SELECT count(*) as c FROM cron_schedules WHERE enabled=1').get() as { c: number } | undefined;
|
||||
stats.cron = row ? row.c : 0;
|
||||
cdb.close();
|
||||
} catch (e) { }
|
||||
try {
|
||||
const logPath = path.join(ROOT_DIR, 'logs/events.log');
|
||||
if (fs.existsSync(logPath)) {
|
||||
stats.logs = fs.readFileSync(logPath, 'utf8').trim().split('\n').slice(-20).map(line => {
|
||||
try { return JSON.parse(line); } catch (e) { return { message: line }; }
|
||||
}).reverse();
|
||||
}
|
||||
} catch (e) { }
|
||||
return stats;
|
||||
}
|
||||
|
||||
private generateBarChart(labels: string[], values: number[], width = 400, height = 200) {
|
||||
const max = Math.max(...values, 1), barWidth = (width / labels.length) * 0.5, gap = (width / labels.length) * 0.5;
|
||||
const bars = labels.map((l, i) => {
|
||||
const val = values[i] ?? 0;
|
||||
const h = (val / max) * 120, x = i * (barWidth + gap) + gap / 2;
|
||||
return `<rect x="${x}" y="${160 - h}" width="${barWidth}" height="${h}" fill="#2ea7ff" rx="4"></rect>` +
|
||||
`<text x="${x + barWidth / 2}" y="180" text-anchor="middle" fill="#8b8b8b" font-size="10">${l}</text>` +
|
||||
`<text x="${x + barWidth / 2}" y="${150 - h}" text-anchor="middle" fill="currentColor" font-size="10">${val}</text>`;
|
||||
}).join('');
|
||||
return `<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid meet">${bars}</svg>`;
|
||||
}
|
||||
private generateDonutChart(data: any, size = 200) {
|
||||
const total = Object.values(data).reduce((a: any, b: any) => a + b, 0) as number;
|
||||
if (!total) return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><text x="50%" y="50%" text-anchor="middle" fill="#8b8b8b">No Data</text></svg>`;
|
||||
const radius = 75, circumference = 2 * Math.PI * radius;
|
||||
let offset = 0;
|
||||
const colors: any = { completed: '#0b6e4f', failed: '#df2a5f', pending: '#d9730d', running: '#2383e2' };
|
||||
const slices = Object.entries(data).map(([k, v]: [string, any]) => {
|
||||
const p = v / total, dash = (p * circumference) + ' ' + circumference, currentOffset = -offset;
|
||||
offset += p * circumference;
|
||||
return `<circle cx="100" cy="100" r="${radius}" fill="transparent" stroke="${colors[k] || '#2383e2'}" stroke-width="25" stroke-dasharray="${dash}" stroke-dashoffset="${currentOffset}" transform="rotate(-90 100 100)"></circle>`;
|
||||
}).join('');
|
||||
return `<svg width="100%" height="100%" viewBox="0 0 200 200" preserveAspectRatio="xMidYMid meet">${slices}<text x="50%" y="50%" text-anchor="middle" dy=".3em" fill="currentColor" font-size="28" font-weight="700">${total}</text></svg>`;
|
||||
}
|
||||
|
||||
private getHTML() {
|
||||
return `<!DOCTYPE html>
|
||||
private generateBarChart(labels: string[], values: number[], width = 400, height = 200) {
|
||||
const max = Math.max(...values, 1), barWidth = (width / labels.length) * 0.5, gap = (width / labels.length) * 0.5;
|
||||
const bars = labels.map((l, i) => {
|
||||
const val = values[i] ?? 0;
|
||||
const h = (val / max) * 120, x = i * (barWidth + gap) + gap / 2;
|
||||
return `<rect x="${x}" y="${160 - h}" width="${barWidth}" height="${h}" fill="#2ea7ff" rx="4"></rect>` +
|
||||
`<text x="${x + barWidth / 2}" y="180" text-anchor="middle" fill="#8b8b8b" font-size="10">${l}</text>` +
|
||||
`<text x="${x + barWidth / 2}" y="${150 - h}" text-anchor="middle" fill="currentColor" font-size="10">${val}</text>`;
|
||||
}).join('');
|
||||
return `<svg width="100%" height="100%" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid meet">${bars}</svg>`;
|
||||
}
|
||||
|
||||
private getHTML() {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
@@ -547,11 +550,11 @@ export class DashboardService extends BaseProcess {
|
||||
<table id="queue-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="padding-left: 20px;">UPDATED</th>
|
||||
<th>STATUS</th>
|
||||
<th>COMPLEXITY</th>
|
||||
<th style="padding-left: 20px; width: 100px;">UPDATED</th>
|
||||
<th style="width: 100px;">STATUS</th>
|
||||
<th style="width: 120px;">COMPLEXITY</th>
|
||||
<th>GOAL</th>
|
||||
<th style="padding-right: 20px; text-align: right;">ACTION</th>
|
||||
<th style="padding-right: 20px; text-align: right; width: 80px;">ACTION</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="qt"></tbody>
|
||||
@@ -565,8 +568,8 @@ export class DashboardService extends BaseProcess {
|
||||
<table id="log-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="padding-left: 20px;">TIME</th>
|
||||
<th>TYPE</th>
|
||||
<th style="padding-left: 20px; width: 100px;">TIME</th>
|
||||
<th style="width: 120px;">TYPE</th>
|
||||
<th style="padding-right: 20px;">CONTENT</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -627,10 +630,10 @@ export class DashboardService extends BaseProcess {
|
||||
const tc = isRunning ? "running" : "warning";
|
||||
const indicator = isRunning ? '<div class="pulse"></div>' : '';
|
||||
return \`<tr>
|
||||
<td style="padding-left: 20px; color: var(--text-muted);">\${new Date(t.updated_at).toLocaleTimeString()}</td>
|
||||
<td><span class="tag \${tc}">\${indicator}\${t.status.toUpperCase()}</span></td>
|
||||
<td><span class="tag complexity-\${t.complexity || 'unknown'}">\${(t.complexity || 'unknown').toUpperCase()}</span></td>
|
||||
<td style="font-weight: 500;">\${t.goal}</td>
|
||||
<td style="padding-left: 20px; color: var(--text-muted); white-space: nowrap;">\${new Date(t.updated_at).toLocaleTimeString()}</td>
|
||||
<td style="white-space: nowrap;"><span class="tag \${tc}">\${indicator}\${t.status.toUpperCase()}</span></td>
|
||||
<td style="white-space: nowrap;"><span class="tag complexity-\${t.complexity || 'unknown'}">\${(t.complexity || 'unknown').toUpperCase()}</span></td>
|
||||
<td style="font-weight: 500; word-break: break-word;">\${t.goal}</td>
|
||||
<td style="padding-right: 20px; text-align: right;">
|
||||
<button onclick="failTask('\${t.id}')" style="cursor: pointer; font-size: 11px; padding: 2px 8px; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--error);">FAIL</button>
|
||||
</td>
|
||||
@@ -665,9 +668,9 @@ export class DashboardService extends BaseProcess {
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
new DashboardService().start();
|
||||
new DashboardService().start();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user