feat: refactor HomePage to integrate agents store and update related components

feat: add runtime event handling for providers in ProvidersSection

feat: update routing to include Channels and Agents pages

feat: extend route types and navigation items for Channels and Agents

feat: implement agents store for managing agent data and interactions

fix: update chat store to utilize agents store for agent-related functionality

chore: export agents store from index

fix: enhance runtime types for better event handling

fix: update Vite config to handle dev server URL correctly
This commit is contained in:
duanshuwen
2026-04-18 14:56:32 +08:00
parent dfa4388087
commit ee72cf7261
52 changed files with 6626 additions and 189 deletions

View File

@@ -0,0 +1,417 @@
import { randomUUID } from 'node:crypto';
import * as fs from 'fs';
import * as path from 'path';
import type {
CronJob,
CronJobCreateInput,
CronJobDelivery,
CronJobLastRun,
CronJobUpdateInput,
CronSchedule,
} from '@src/lib/cron-types';
import { getUserDataDir } from './paths';
interface StoredCronJob extends CronJob {
agentId?: string | null;
}
interface CronStore {
jobs: StoredCronJob[];
}
const CRON_STORE_PATH = path.join(getUserDataDir(), 'cron', 'jobs.json');
const MAX_CRON_LOOKAHEAD_MINUTES = 366 * 24 * 60;
function readStore(): CronStore {
try {
if (fs.existsSync(CRON_STORE_PATH)) {
const parsed = JSON.parse(fs.readFileSync(CRON_STORE_PATH, 'utf-8')) as Partial<CronStore>;
return {
jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [],
};
}
} catch {
// Fall back to an empty store on malformed JSON.
}
return { jobs: [] };
}
function writeStore(store: CronStore): void {
fs.mkdirSync(path.dirname(CRON_STORE_PATH), { recursive: true });
fs.writeFileSync(CRON_STORE_PATH, JSON.stringify(store, null, 2), 'utf-8');
}
function normalizeString(value: unknown): string {
return String(value ?? '').trim();
}
function normalizeIsoDate(value: unknown, fallback?: string): string {
const raw = normalizeString(value);
const ms = Date.parse(raw);
if (Number.isFinite(ms)) {
return new Date(ms).toISOString();
}
if (fallback) {
return fallback;
}
return new Date().toISOString();
}
function normalizeDelivery(value: unknown): CronJobDelivery | undefined {
if (!value || typeof value !== 'object') {
return { mode: 'none' };
}
const input = value as Partial<CronJobDelivery>;
const mode = input.mode === 'announce' ? 'announce' : 'none';
if (mode === 'announce') {
const channel = normalizeString(input.channel);
const to = normalizeString(input.to);
const accountId = normalizeString(input.accountId);
return {
mode,
channel: channel || undefined,
to: to || undefined,
accountId: accountId || undefined,
};
}
return { mode: 'none' };
}
function normalizeLastRun(value: unknown): CronJobLastRun | undefined {
if (!value || typeof value !== 'object') return undefined;
const input = value as Partial<CronJobLastRun>;
const time = normalizeString(input.time);
if (!time) return undefined;
return {
time: normalizeIsoDate(time),
success: input.success !== false,
error: normalizeString(input.error) || undefined,
duration: typeof input.duration === 'number' && Number.isFinite(input.duration) ? input.duration : undefined,
};
}
function normalizeSchedule(value: unknown, fallback?: CronJob['schedule']): CronJob['schedule'] | null {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed || fallback || null;
}
if (!value || typeof value !== 'object') {
return fallback || null;
}
const input = value as Partial<CronSchedule>;
if (input.kind === 'at') {
const at = normalizeString(input.at);
return at ? { kind: 'at', at: normalizeIsoDate(at) } : fallback || null;
}
if (input.kind === 'every') {
const everyMs = typeof input.everyMs === 'number' && Number.isFinite(input.everyMs) ? input.everyMs : 0;
if (everyMs <= 0) return fallback || null;
return {
kind: 'every',
everyMs,
anchorMs: typeof input.anchorMs === 'number' && Number.isFinite(input.anchorMs) ? input.anchorMs : undefined,
};
}
if (input.kind === 'cron') {
const expr = normalizeString(input.expr);
return expr
? {
kind: 'cron',
expr,
tz: normalizeString(input.tz) || undefined,
}
: fallback || null;
}
return fallback || null;
}
function parseCronNumberToken(token: string, min: number, max: number, isDayOfWeek = false): number | null {
if (!/^\d+$/.test(token)) return null;
const parsed = Number(token);
if (!Number.isFinite(parsed)) return null;
if (isDayOfWeek && parsed === 7) return 0;
if (parsed < min || parsed > max) return null;
return parsed;
}
function matchesCronField(expression: string, value: number, min: number, max: number, isDayOfWeek = false): boolean {
const trimmed = expression.trim();
if (!trimmed) return false;
if (trimmed === '*') return true;
return trimmed.split(',').some((segment) => {
const part = segment.trim();
if (!part) return false;
if (part === '*') return true;
const [rangeExpression, stepExpression] = part.split('/');
const step = stepExpression ? Number(stepExpression) : 1;
if (!Number.isFinite(step) || step <= 0) return false;
if (rangeExpression === '*') {
return (value - min) % step === 0;
}
if (rangeExpression.includes('-')) {
const [startRaw, endRaw] = rangeExpression.split('-');
const start = parseCronNumberToken(startRaw.trim(), min, max, isDayOfWeek);
const end = parseCronNumberToken(endRaw.trim(), min, max, isDayOfWeek);
if (start == null || end == null || start > end) return false;
if (value < start || value > end) return false;
return (value - start) % step === 0;
}
const literal = parseCronNumberToken(rangeExpression.trim(), min, max, isDayOfWeek);
if (literal == null) return false;
return value === literal;
});
}
function estimateNextCronRun(expr: string, now = new Date()): string | undefined {
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) return undefined;
const [minuteExpr, hourExpr, dayOfMonthExpr, monthExpr, dayOfWeekExpr] = parts;
const candidate = new Date(now);
candidate.setSeconds(0, 0);
candidate.setMinutes(candidate.getMinutes() + 1);
for (let index = 0; index < MAX_CRON_LOOKAHEAD_MINUTES; index += 1) {
const minute = candidate.getMinutes();
const hour = candidate.getHours();
const dayOfMonth = candidate.getDate();
const month = candidate.getMonth() + 1;
const dayOfWeek = candidate.getDay();
if (
matchesCronField(minuteExpr, minute, 0, 59)
&& matchesCronField(hourExpr, hour, 0, 23)
&& matchesCronField(dayOfMonthExpr, dayOfMonth, 1, 31)
&& matchesCronField(monthExpr, month, 1, 12)
&& matchesCronField(dayOfWeekExpr, dayOfWeek, 0, 7, true)
) {
return candidate.toISOString();
}
candidate.setMinutes(candidate.getMinutes() + 1);
}
return undefined;
}
function estimateNextRun(schedule: CronJob['schedule'], enabled: boolean): string | undefined {
if (!enabled) return undefined;
if (typeof schedule === 'string') {
return estimateNextCronRun(schedule);
}
if (schedule.kind === 'at') {
const atMs = Date.parse(schedule.at);
if (!Number.isFinite(atMs) || atMs <= Date.now()) return undefined;
return new Date(atMs).toISOString();
}
if (schedule.kind === 'every') {
if (!Number.isFinite(schedule.everyMs) || schedule.everyMs <= 0) return undefined;
const nowMs = Date.now();
const anchorMs = typeof schedule.anchorMs === 'number' && Number.isFinite(schedule.anchorMs)
? schedule.anchorMs
: nowMs;
const delta = Math.max(nowMs - anchorMs, 0);
const steps = Math.floor(delta / schedule.everyMs) + 1;
return new Date(anchorMs + steps * schedule.everyMs).toISOString();
}
return estimateNextCronRun(schedule.expr);
}
function normalizeStoredJob(input: Partial<StoredCronJob> | null | undefined): StoredCronJob | null {
if (!input || typeof input !== 'object') return null;
const id = normalizeString(input.id);
const name = normalizeString(input.name);
const message = normalizeString(input.message);
const schedule = normalizeSchedule(input.schedule);
if (!id || !name || !message || !schedule) {
return null;
}
const createdAt = normalizeIsoDate(input.createdAt);
const updatedAt = normalizeIsoDate(input.updatedAt, createdAt);
const enabled = input.enabled !== false;
return {
id,
name,
message,
schedule,
delivery: normalizeDelivery(input.delivery),
enabled,
createdAt,
updatedAt,
lastRun: normalizeLastRun(input.lastRun),
nextRun: estimateNextRun(schedule, enabled),
agentId: normalizeString(input.agentId) || undefined,
};
}
function listNormalizedJobs(): StoredCronJob[] {
return readStore().jobs
.map((job) => normalizeStoredJob(job))
.filter((job): job is StoredCronJob => Boolean(job))
.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
}
function writeJobs(jobs: StoredCronJob[]): StoredCronJob[] {
writeStore({ jobs });
return jobs;
}
function ensureCreateInput(input: CronJobCreateInput): void {
if (!normalizeString(input.name)) {
throw new Error('name is required');
}
if (!normalizeString(input.message)) {
throw new Error('message is required');
}
if (!normalizeString(input.schedule)) {
throw new Error('schedule is required');
}
}
export function listCronJobs(): CronJob[] {
return listNormalizedJobs();
}
export function createCronJob(input: CronJobCreateInput & { agentId?: string | null }): CronJob {
ensureCreateInput(input);
const jobs = listNormalizedJobs();
const now = new Date().toISOString();
const schedule = normalizeSchedule(input.schedule);
if (!schedule) {
throw new Error('schedule is required');
}
const nextJob = normalizeStoredJob({
id: `cron-${randomUUID()}`,
name: input.name,
message: input.message,
schedule,
delivery: input.delivery,
enabled: input.enabled !== false,
createdAt: now,
updatedAt: now,
agentId: input.agentId,
});
if (!nextJob) {
throw new Error('Failed to create cron job');
}
writeJobs([...jobs, nextJob]);
return nextJob;
}
export function updateCronJob(jobId: string, input: CronJobUpdateInput & { agentId?: string | null }): CronJob {
const normalizedJobId = normalizeString(jobId);
if (!normalizedJobId) {
throw new Error('id is required');
}
const jobs = listNormalizedJobs();
const index = jobs.findIndex((job) => job.id === normalizedJobId);
if (index === -1) {
throw new Error(`Cron job "${normalizedJobId}" not found`);
}
const currentJob = jobs[index];
const schedule = normalizeSchedule(input.schedule, currentJob.schedule);
const nextJob = normalizeStoredJob({
...currentJob,
name: normalizeString(input.name) || currentJob.name,
message: normalizeString(input.message) || currentJob.message,
schedule,
delivery: typeof input.delivery === 'undefined' ? currentJob.delivery : input.delivery,
enabled: typeof input.enabled === 'boolean' ? input.enabled : currentJob.enabled,
updatedAt: new Date().toISOString(),
agentId: typeof input.agentId === 'undefined' ? currentJob.agentId : input.agentId,
});
if (!nextJob) {
throw new Error(`Cron job "${normalizedJobId}" could not be normalized`);
}
jobs[index] = nextJob;
writeJobs(jobs);
return nextJob;
}
export function deleteCronJob(jobId: string): { id: string } {
const normalizedJobId = normalizeString(jobId);
if (!normalizedJobId) {
throw new Error('id is required');
}
const jobs = listNormalizedJobs();
const nextJobs = jobs.filter((job) => job.id !== normalizedJobId);
if (nextJobs.length === jobs.length) {
throw new Error(`Cron job "${normalizedJobId}" not found`);
}
writeJobs(nextJobs);
return { id: normalizedJobId };
}
export function toggleCronJob(jobId: string, enabled: boolean): CronJob {
return updateCronJob(jobId, { enabled });
}
export function triggerCronJob(jobId: string): CronJob {
const normalizedJobId = normalizeString(jobId);
if (!normalizedJobId) {
throw new Error('id is required');
}
const jobs = listNormalizedJobs();
const index = jobs.findIndex((job) => job.id === normalizedJobId);
if (index === -1) {
throw new Error(`Cron job "${normalizedJobId}" not found`);
}
const currentJob = jobs[index];
const nextJob = normalizeStoredJob({
...currentJob,
updatedAt: new Date().toISOString(),
lastRun: {
time: new Date().toISOString(),
success: true,
},
});
if (!nextJob) {
throw new Error(`Cron job "${normalizedJobId}" could not be normalized`);
}
jobs[index] = nextJob;
writeJobs(jobs);
return nextJob;
}