Commit 5f36e0b1 authored by edy's avatar edy

feat(automation): add scheduled task management

parent b1d4353e
...@@ -36,6 +36,7 @@ import { ProjectIntentRouterService } from "./services/project-intent-router.js" ...@@ -36,6 +36,7 @@ import { ProjectIntentRouterService } from "./services/project-intent-router.js"
import { ProjectSkillRouterService } from "./services/project-skill-router.js"; import { ProjectSkillRouterService } from "./services/project-skill-router.js";
import { ProjectWorkspaceExecutorService } from "./services/project-workspace-executor.js"; import { ProjectWorkspaceExecutorService } from "./services/project-workspace-executor.js";
import { TaskPanelService } from "./services/task-panel-service.js"; import { TaskPanelService } from "./services/task-panel-service.js";
import { AutomationTaskService } from "./services/automation-task-service.js";
import { StartupLogger } from "./services/startup-logger.js"; import { StartupLogger } from "./services/startup-logger.js";
interface RendererSmokeState { interface RendererSmokeState {
...@@ -2303,6 +2304,7 @@ async function bootstrap(): Promise<void> { ...@@ -2303,6 +2304,7 @@ async function bootstrap(): Promise<void> {
await traceBootstrap("runtime-configure-done"); await traceBootstrap("runtime-configure-done");
const projectWorkspaceExecutor = new ProjectWorkspaceExecutorService(runtimeManager); const projectWorkspaceExecutor = new ProjectWorkspaceExecutorService(runtimeManager);
const taskPanelService = new TaskPanelService(systemSummary.userDataPath); const taskPanelService = new TaskPanelService(systemSummary.userDataPath);
const automationTaskService = new AutomationTaskService(systemSummary.userDataPath, { autoStart: false });
const runtimeStatus = await runtimeManager.status(); const runtimeStatus = await runtimeManager.status();
const runtimeGatewayConnection = await runtimeManager.getGatewayConnection(); const runtimeGatewayConnection = await runtimeManager.getGatewayConnection();
if (systemSummary.isPackaged && runtimeStatus.payloadState !== "ready") { if (systemSummary.isPackaged && runtimeStatus.payloadState !== "ready") {
...@@ -2391,10 +2393,12 @@ async function bootstrap(): Promise<void> { ...@@ -2391,10 +2393,12 @@ async function bootstrap(): Promise<void> {
projectExecutionRouter, projectExecutionRouter,
projectWorkspaceExecutor, projectWorkspaceExecutor,
taskPanelService, taskPanelService,
automationTaskService,
startupLogger: startupLogger!, startupLogger: startupLogger!,
systemSummary, systemSummary,
localOpenClawConfig localOpenClawConfig
}); });
await automationTaskService.start();
let beforeQuitHandled = false; let beforeQuitHandled = false;
app.on("before-quit", (event) => { app.on("before-quit", (event) => {
...@@ -2406,6 +2410,7 @@ async function bootstrap(): Promise<void> { ...@@ -2406,6 +2410,7 @@ async function bootstrap(): Promise<void> {
event.preventDefault(); event.preventDefault();
void (async () => { void (async () => {
await runtimeCloudSupervisor.stop("app-before-quit"); await runtimeCloudSupervisor.stop("app-before-quit");
automationTaskService.stop();
await dailyReportService.stop(); await dailyReportService.stop();
await runtimeManager.stop(); await runtimeManager.stop();
await runtimeSkillBridge.clearManagedSkills().catch(() => undefined); await runtimeSkillBridge.clearManagedSkills().catch(() => undefined);
...@@ -2509,4 +2514,3 @@ if (!hasSingleInstanceLock) { ...@@ -2509,4 +2514,3 @@ if (!hasSingleInstanceLock) {
}); });
} }
...@@ -5,6 +5,8 @@ import path from "node:path"; ...@@ -5,6 +5,8 @@ import path from "node:path";
import { import {
IPC_CHANNELS, IPC_CHANNELS,
type AppConfig, type AppConfig,
type AutomationTaskRun,
type CreateAutomationTaskInput,
type ChatAttachment, type ChatAttachment,
type ChatCancelStreamResult, type ChatCancelStreamResult,
type ChatMessage, type ChatMessage,
...@@ -22,6 +24,7 @@ import { ...@@ -22,6 +24,7 @@ import {
type SaveConfigInput, type SaveConfigInput,
type SignInInput, type SignInInput,
type SystemSummary, type SystemSummary,
type UpdateAutomationTaskInput,
type ProjectExecutionDecision, type ProjectExecutionDecision,
type WorkspaceSummary, type WorkspaceSummary,
type WorkspaceWarmupResult type WorkspaceWarmupResult
...@@ -60,6 +63,7 @@ import type { ProjectExecutionRouter } from "./services/project-execution-router ...@@ -60,6 +63,7 @@ import type { ProjectExecutionRouter } from "./services/project-execution-router
import type { ProjectSkillRouterService } from "./services/project-skill-router.js"; import type { ProjectSkillRouterService } from "./services/project-skill-router.js";
import type { ProjectWorkspaceExecutorService } from "./services/project-workspace-executor.js"; import type { ProjectWorkspaceExecutorService } from "./services/project-workspace-executor.js";
import type { TaskPanelService } from "./services/task-panel-service.js"; import type { TaskPanelService } from "./services/task-panel-service.js";
import type { AutomationTaskService } from "./services/automation-task-service.js";
import { import {
buildProjectModelRuntime, buildProjectModelRuntime,
materializeProjectModelRuntime materializeProjectModelRuntime
...@@ -105,6 +109,7 @@ interface MainServices { ...@@ -105,6 +109,7 @@ interface MainServices {
projectExecutionRouter: ProjectExecutionRouter; projectExecutionRouter: ProjectExecutionRouter;
projectWorkspaceExecutor: ProjectWorkspaceExecutorService; projectWorkspaceExecutor: ProjectWorkspaceExecutorService;
taskPanelService: TaskPanelService; taskPanelService: TaskPanelService;
automationTaskService: AutomationTaskService;
startupLogger: StartupLogger; startupLogger: StartupLogger;
appVersion: string; appVersion: string;
systemSummary: SystemSummary; systemSummary: SystemSummary;
...@@ -535,6 +540,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -535,6 +540,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
projectExecutionRouter, projectExecutionRouter,
projectWorkspaceExecutor, projectWorkspaceExecutor,
taskPanelService, taskPanelService,
automationTaskService,
startupLogger, startupLogger,
systemSummary, systemSummary,
localOpenClawConfig localOpenClawConfig
...@@ -2809,6 +2815,41 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -2809,6 +2815,41 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
return result; return result;
}; };
automationTaskService.setExecutor(async ({ task, run }) => {
const projectId = task.expertId?.trim() || BUILTIN_HOME_PROJECT_ID;
const projects = await projectStore.listProjects().catch(() => []);
const project = projects.find((candidate) => candidate.id === projectId);
const expertName = task.expertName?.trim()
|| project?.displayName?.trim()
|| project?.name?.trim()
|| (projectId === BUILTIN_HOME_PROJECT_ID ? "千匠问天" : projectId);
const session = await projectStore.createSession(`[自动化] ${task.title}`, projectId);
runtimeCloudSupervisor.noteSessions([session.id]);
const result = await sendPrompt(session.id, task.prompt);
await taskPanelService.recordWorkspaceExecution({
sessionId: result.sessionId,
runId: run.id,
expertName,
taskTitle: task.title,
completedAt: new Date().toISOString(),
messageCount: 2,
artifacts: []
}).catch((error) => startupLogger.warn("diagnostics", "automation-task.archive-failed", "Failed to archive automation task output.", {
taskId: task.id,
runId: run.id,
error: error instanceof Error ? error.message : String(error)
}));
return {
sessionId: result.sessionId,
runId: result.reply.id,
replyText: result.reply.content,
artifacts: []
};
});
ipcMain.handle(IPC_CHANNELS.workspaceGetSummary, async () => buildWorkspaceSummary()); ipcMain.handle(IPC_CHANNELS.workspaceGetSummary, async () => buildWorkspaceSummary());
ipcMain.handle(IPC_CHANNELS.workspaceWarmup, async () => queueWorkspaceWarmup("workspace-warmup", { action: "init" })); ipcMain.handle(IPC_CHANNELS.workspaceWarmup, async () => queueWorkspaceWarmup("workspace-warmup", { action: "init" }));
ipcMain.handle(IPC_CHANNELS.windowMinimize, async (event) => { ipcMain.handle(IPC_CHANNELS.windowMinimize, async (event) => {
...@@ -2903,6 +2944,12 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -2903,6 +2944,12 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
ipcMain.handle(IPC_CHANNELS.modelConfigGetSummary, async () => modelConfigClient.getSummary()); ipcMain.handle(IPC_CHANNELS.modelConfigGetSummary, async () => modelConfigClient.getSummary());
ipcMain.handle(IPC_CHANNELS.systemGetSummary, async () => systemSummary); ipcMain.handle(IPC_CHANNELS.systemGetSummary, async () => systemSummary);
ipcMain.handle(IPC_CHANNELS.tasksListByDate, async (_event, date: string) => taskPanelService.listByDate(date)); ipcMain.handle(IPC_CHANNELS.tasksListByDate, async (_event, date: string) => taskPanelService.listByDate(date));
ipcMain.handle(IPC_CHANNELS.automationTasksList, async () => automationTaskService.list());
ipcMain.handle(IPC_CHANNELS.automationTasksCreate, async (_event, input: CreateAutomationTaskInput) => automationTaskService.create(input));
ipcMain.handle(IPC_CHANNELS.automationTasksUpdate, async (_event, taskId: string, input: UpdateAutomationTaskInput) => automationTaskService.update(taskId, input));
ipcMain.handle(IPC_CHANNELS.automationTasksDelete, async (_event, taskId: string) => automationTaskService.delete(taskId));
ipcMain.handle(IPC_CHANNELS.automationTasksRunNow, async (_event, taskId: string): Promise<AutomationTaskRun> => automationTaskService.runNow(taskId));
ipcMain.handle(IPC_CHANNELS.automationTasksListRuns, async (_event, taskId?: string) => automationTaskService.listRuns(taskId));
ipcMain.handle(IPC_CHANNELS.skillCatalogList, async () => skillCatalogService.listForActiveProject()); ipcMain.handle(IPC_CHANNELS.skillCatalogList, async () => skillCatalogService.listForActiveProject());
ipcMain.handle(IPC_CHANNELS.projectsList, async () => projectStore.listProjects()); ipcMain.handle(IPC_CHANNELS.projectsList, async () => projectStore.listProjects());
...@@ -3039,6 +3086,14 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc ...@@ -3039,6 +3086,14 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
tasks: { tasks: {
listByDate: (date: string) => taskPanelService.listByDate(date) listByDate: (date: string) => taskPanelService.listByDate(date)
}, },
automationTasks: {
list: () => automationTaskService.list(),
create: (input: CreateAutomationTaskInput) => automationTaskService.create(input),
update: (taskId: string, input: UpdateAutomationTaskInput) => automationTaskService.update(taskId, input),
delete: (taskId: string) => automationTaskService.delete(taskId),
runNow: (taskId: string) => automationTaskService.runNow(taskId),
listRuns: (taskId?: string) => automationTaskService.listRuns(taskId)
},
chat: { chat: {
listSessions: async () => { listSessions: async () => {
const sessions = await listSessionsForActiveProject(projectStore); const sessions = await listSessionsForActiveProject(projectStore);
......
import { randomUUID } from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
import type {
AutomationTask,
AutomationTaskRun,
AutomationTaskSchedule,
CreateAutomationTaskInput,
TaskPanelArtifact,
UpdateAutomationTaskInput
} from "@qjclaw/shared-types";
interface AutomationTaskState {
tasks: AutomationTask[];
runs: AutomationTaskRun[];
}
export interface AutomationTaskExecutorInput {
task: AutomationTask;
run: AutomationTaskRun;
}
export interface AutomationTaskExecutorResult {
sessionId?: string;
runId?: string;
replyText?: string;
artifacts?: TaskPanelArtifact[];
}
export type AutomationTaskExecutor = (input: AutomationTaskExecutorInput) => Promise<AutomationTaskExecutorResult>;
export interface AutomationTaskServiceOptions {
now?: () => Date;
autoStart?: boolean;
}
const MAX_TIMER_DELAY_MS = 60_000;
const VALID_TIME_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/;
function createEmptyState(): AutomationTaskState {
return {
tasks: [],
runs: []
};
}
function isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function isAutomationTaskState(value: unknown): value is AutomationTaskState {
return Boolean(
isObject(value)
&& Array.isArray(value.tasks)
&& Array.isArray(value.runs)
);
}
function parseValidDate(value?: string): Date | null {
if (!value) {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function parseTime(value: string): { hour: number; minute: number } | null {
const match = value.match(VALID_TIME_PATTERN);
if (!match) {
return null;
}
return {
hour: Number(match[1]),
minute: Number(match[2])
};
}
function localCandidate(from: Date, offsetDays: number, time: { hour: number; minute: number }): Date {
return new Date(
from.getFullYear(),
from.getMonth(),
from.getDate() + offsetDays,
time.hour,
time.minute,
0,
0
);
}
export function computeAutomationNextRunAt(schedule: AutomationTaskSchedule, from = new Date()): string | undefined {
if (schedule.kind === "once") {
const runAt = parseValidDate(schedule.runAt);
return runAt && runAt.getTime() > from.getTime() ? runAt.toISOString() : undefined;
}
const time = parseTime(schedule.time);
if (!time) {
return undefined;
}
if (schedule.kind === "daily") {
const today = localCandidate(from, 0, time);
return (today.getTime() > from.getTime() ? today : localCandidate(from, 1, time)).toISOString();
}
const weekdays = [...new Set(schedule.weekdays)]
.filter((day) => Number.isInteger(day) && day >= 0 && day <= 6)
.sort((left, right) => left - right);
if (!weekdays.length) {
return undefined;
}
for (let offset = 0; offset <= 7; offset += 1) {
const candidate = localCandidate(from, offset, time);
if (weekdays.includes(candidate.getDay()) && candidate.getTime() > from.getTime()) {
return candidate.toISOString();
}
}
return undefined;
}
function normalizeTitle(value: string): string {
const trimmed = value.trim();
return trimmed || "未命名自动化任务";
}
function normalizePrompt(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
throw new Error("Automation task prompt is required.");
}
return trimmed;
}
function normalizeSchedule(schedule: AutomationTaskSchedule): AutomationTaskSchedule {
if (schedule.kind === "once") {
const runAt = parseValidDate(schedule.runAt);
if (!runAt) {
throw new Error("One-time automation task runAt is invalid.");
}
return {
kind: "once",
runAt: runAt.toISOString()
};
}
if (!parseTime(schedule.time)) {
throw new Error("Automation task time must use HH:mm.");
}
if (schedule.kind === "daily") {
return {
kind: "daily",
time: schedule.time
};
}
const weekdays = [...new Set(schedule.weekdays)]
.filter((day) => Number.isInteger(day) && day >= 0 && day <= 6)
.sort((left, right) => left - right);
if (!weekdays.length) {
throw new Error("Weekly automation task requires at least one weekday.");
}
return {
kind: "weekly",
time: schedule.time,
weekdays
};
}
function assertEnabledScheduleCanRun(schedule: AutomationTaskSchedule, enabled: boolean, from: Date): void {
if (enabled && schedule.kind === "once" && !computeAutomationNextRunAt(schedule, from)) {
throw new Error("Enabled one-time automation task runAt must be in the future.");
}
}
export class AutomationTaskService {
private readonly statePath: string;
private readonly now: () => Date;
private executor: AutomationTaskExecutor | null = null;
private timer: NodeJS.Timeout | null = null;
private writeChain: Promise<unknown> = Promise.resolve();
constructor(userDataPath: string, options: AutomationTaskServiceOptions = {}) {
this.statePath = path.join(userDataPath, "automation-tasks", "state.json");
this.now = options.now ?? (() => new Date());
if (options.autoStart !== false) {
void this.start();
}
}
setExecutor(executor: AutomationTaskExecutor): void {
this.executor = executor;
}
async start(): Promise<void> {
await this.recoverMissedRuns();
this.scheduleNextTimer();
}
stop(): void {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
}
async list(): Promise<AutomationTask[]> {
const state = await this.loadState();
return [...state.tasks].sort((left, right) => right.createdAt.localeCompare(left.createdAt));
}
async listRuns(taskId?: string): Promise<AutomationTaskRun[]> {
const normalizedTaskId = taskId?.trim();
const state = await this.loadState();
return state.runs
.filter((run) => !normalizedTaskId || run.taskId === normalizedTaskId)
.sort((left, right) => (right.startedAt ?? right.completedAt ?? "").localeCompare(left.startedAt ?? left.completedAt ?? ""));
}
async create(input: CreateAutomationTaskInput): Promise<AutomationTask> {
return this.enqueueWrite(async () => {
const state = await this.loadState();
const now = this.now();
const nowIso = now.toISOString();
const schedule = normalizeSchedule(input.schedule);
const enabled = Boolean(input.enabled);
assertEnabledScheduleCanRun(schedule, enabled, now);
const task: AutomationTask = {
id: randomUUID(),
title: normalizeTitle(input.title),
prompt: normalizePrompt(input.prompt),
expertId: input.expertId?.trim() || undefined,
expertName: input.expertName?.trim() || undefined,
enabled,
schedule,
nextRunAt: enabled ? computeAutomationNextRunAt(schedule, now) : undefined,
createdAt: nowIso,
updatedAt: nowIso
};
state.tasks.push(task);
await this.saveState(state);
this.scheduleNextTimerFromState(state);
return task;
});
}
async update(taskId: string, input: UpdateAutomationTaskInput): Promise<AutomationTask | null> {
return this.enqueueWrite(async () => {
const state = await this.loadState();
const index = state.tasks.findIndex((task) => task.id === taskId);
if (index < 0) {
return null;
}
const current = state.tasks[index]!;
const schedule = input.schedule ? normalizeSchedule(input.schedule) : current.schedule;
const enabled = input.enabled ?? current.enabled;
const now = this.now();
assertEnabledScheduleCanRun(schedule, enabled, now);
const next: AutomationTask = {
...current,
title: input.title !== undefined ? normalizeTitle(input.title) : current.title,
prompt: input.prompt !== undefined ? normalizePrompt(input.prompt) : current.prompt,
expertId: input.expertId === null ? undefined : input.expertId?.trim() || current.expertId,
expertName: input.expertName === null ? undefined : input.expertName?.trim() || current.expertName,
enabled,
schedule,
nextRunAt: enabled ? computeAutomationNextRunAt(schedule, now) : undefined,
updatedAt: now.toISOString()
};
state.tasks[index] = next;
await this.saveState(state);
this.scheduleNextTimerFromState(state);
return next;
});
}
async delete(taskId: string): Promise<boolean> {
return this.enqueueWrite(async () => {
const state = await this.loadState();
const nextTasks = state.tasks.filter((task) => task.id !== taskId);
const deleted = nextTasks.length !== state.tasks.length;
if (!deleted) {
return false;
}
state.tasks = nextTasks;
state.runs = state.runs.filter((run) => run.taskId !== taskId);
await this.saveState(state);
this.scheduleNextTimerFromState(state);
return true;
});
}
async runNow(taskId: string): Promise<AutomationTaskRun> {
const state = await this.loadState();
const task = state.tasks.find((candidate) => candidate.id === taskId);
if (!task) {
throw new Error("Automation task not found.");
}
return this.executeTask(task, "manual");
}
async recoverMissedRuns(): Promise<void> {
await this.enqueueWrite(async () => {
const state = await this.loadState();
const now = this.now();
let changed = false;
state.tasks = state.tasks.map((task) => {
if (!task.enabled || !task.nextRunAt) {
return task;
}
const nextRun = parseValidDate(task.nextRunAt);
if (!nextRun || nextRun.getTime() > now.getTime()) {
return task;
}
const completedAt = now.toISOString();
state.runs.push({
id: randomUUID(),
taskId: task.id,
trigger: "startup",
status: "missed",
scheduledFor: task.nextRunAt,
startedAt: completedAt,
completedAt,
error: "应用未运行,已跳过本次计划执行。",
artifacts: []
});
changed = true;
const nextRunAt = task.schedule.kind === "once"
? undefined
: computeAutomationNextRunAt(task.schedule, now);
return {
...task,
enabled: task.schedule.kind === "once" ? false : task.enabled,
nextRunAt,
lastRunAt: completedAt,
lastRunStatus: "missed" as const,
lastRunError: "应用未运行,已跳过本次计划执行。",
updatedAt: completedAt
};
});
if (changed) {
await this.saveState(state);
}
this.scheduleNextTimerFromState(state);
});
}
private async runDueTasks(): Promise<void> {
const state = await this.loadState();
const now = this.now();
const dueTasks = state.tasks.filter((task) => {
const nextRun = parseValidDate(task.nextRunAt);
return task.enabled && nextRun && nextRun.getTime() <= now.getTime();
});
for (const task of dueTasks) {
await this.executeTask(task, "schedule", task.nextRunAt);
}
this.scheduleNextTimer();
}
private async executeTask(
task: AutomationTask,
trigger: AutomationTaskRun["trigger"],
scheduledFor?: string
): Promise<AutomationTaskRun> {
const executor = this.executor;
if (!executor) {
throw new Error("Automation task executor is not configured.");
}
const startedAt = this.now().toISOString();
const run: AutomationTaskRun = {
id: randomUUID(),
taskId: task.id,
trigger,
status: "running",
scheduledFor,
startedAt,
artifacts: []
};
await this.enqueueWrite(async () => {
const state = await this.loadState();
const taskIndex = state.tasks.findIndex((candidate) => candidate.id === task.id);
if (taskIndex >= 0 && trigger === "schedule") {
const current = state.tasks[taskIndex]!;
const nextRunAt = current.schedule.kind === "once"
? undefined
: computeAutomationNextRunAt(current.schedule, parseValidDate(scheduledFor) ?? this.now());
state.tasks[taskIndex] = {
...current,
enabled: current.schedule.kind === "once" ? false : current.enabled,
nextRunAt,
updatedAt: startedAt
};
}
state.runs.push(run);
await this.saveState(state);
});
try {
const result = await executor({ task, run });
const completed: AutomationTaskRun = {
...run,
status: "completed",
completedAt: this.now().toISOString(),
sessionId: result.sessionId,
runId: result.runId,
replyText: result.replyText,
artifacts: result.artifacts ?? []
};
await this.finishRun(completed);
return completed;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const failed: AutomationTaskRun = {
...run,
status: "failed",
completedAt: this.now().toISOString(),
error: message,
artifacts: []
};
await this.finishRun(failed);
return failed;
}
}
private async finishRun(run: AutomationTaskRun): Promise<void> {
await this.enqueueWrite(async () => {
const state = await this.loadState();
state.runs = state.runs.map((candidate) => candidate.id === run.id ? run : candidate);
state.tasks = state.tasks.map((task) => task.id === run.taskId
? {
...task,
lastRunAt: run.completedAt ?? run.startedAt,
lastRunStatus: run.status,
lastRunError: run.error,
updatedAt: run.completedAt ?? this.now().toISOString()
}
: task
);
await this.saveState(state);
this.scheduleNextTimerFromState(state);
});
}
private async enqueueWrite<T>(operation: () => Promise<T>): Promise<T> {
const next = this.writeChain
.catch(() => undefined)
.then(operation);
this.writeChain = next;
return next;
}
private scheduleNextTimer(): void {
void this.loadState().then((state) => this.scheduleNextTimerFromState(state)).catch(() => undefined);
}
private scheduleNextTimerFromState(state: AutomationTaskState): void {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
const nowMs = this.now().getTime();
const nextRunMs = state.tasks
.filter((task) => task.enabled)
.map((task) => parseValidDate(task.nextRunAt)?.getTime())
.filter((value): value is number => typeof value === "number")
.sort((left, right) => left - right)[0];
if (nextRunMs === undefined) {
return;
}
const delay = Math.max(0, Math.min(nextRunMs - nowMs, MAX_TIMER_DELAY_MS));
this.timer = setTimeout(() => {
void this.runDueTasks().catch(() => this.scheduleNextTimer());
}, delay);
this.timer.unref?.();
}
private async loadState(): Promise<AutomationTaskState> {
try {
const raw = await readFile(this.statePath, "utf8");
const parsed = JSON.parse(raw) as unknown;
return isAutomationTaskState(parsed) ? parsed : createEmptyState();
} catch {
return createEmptyState();
}
}
private async saveState(state: AutomationTaskState): Promise<void> {
await mkdir(path.dirname(this.statePath), { recursive: true });
const tempPath = this.statePath + ".tmp";
await writeFile(tempPath, JSON.stringify(state, null, 2), "utf8");
await rename(tempPath, this.statePath);
}
}
import { contextBridge, ipcRenderer } from "electron"; import { contextBridge, ipcRenderer } from "electron";
import { import {
IPC_CHANNELS, IPC_CHANNELS,
type CreateAutomationTaskInput,
type ChatAttachment, type ChatAttachment,
type ChatStreamListener, type ChatStreamListener,
type ConfigSecretId, type ConfigSecretId,
type DesktopApi, type DesktopApi,
type RuntimeCloudFetchAction, type RuntimeCloudFetchAction,
type SaveConfigInput, type SaveConfigInput,
type SignInInput type SignInInput,
type UpdateAutomationTaskInput
} from "@qjclaw/shared-types"; } from "@qjclaw/shared-types";
const desktopApi: DesktopApi = { const desktopApi: DesktopApi = {
...@@ -83,6 +85,14 @@ const desktopApi: DesktopApi = { ...@@ -83,6 +85,14 @@ const desktopApi: DesktopApi = {
tasks: { tasks: {
listByDate: (date: string) => ipcRenderer.invoke(IPC_CHANNELS.tasksListByDate, date) listByDate: (date: string) => ipcRenderer.invoke(IPC_CHANNELS.tasksListByDate, date)
}, },
automationTasks: {
list: () => ipcRenderer.invoke(IPC_CHANNELS.automationTasksList),
create: (input: CreateAutomationTaskInput) => ipcRenderer.invoke(IPC_CHANNELS.automationTasksCreate, input),
update: (taskId: string, input: UpdateAutomationTaskInput) => ipcRenderer.invoke(IPC_CHANNELS.automationTasksUpdate, taskId, input),
delete: (taskId: string) => ipcRenderer.invoke(IPC_CHANNELS.automationTasksDelete, taskId),
runNow: (taskId: string) => ipcRenderer.invoke(IPC_CHANNELS.automationTasksRunNow, taskId),
listRuns: (taskId?: string) => ipcRenderer.invoke(IPC_CHANNELS.automationTasksListRuns, taskId)
},
chat: { chat: {
listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.chatListSessions), listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.chatListSessions),
listSessionsByProject: (projectId: string) => ipcRenderer.invoke(IPC_CHANNELS.chatListSessionsByProject, projectId), listSessionsByProject: (projectId: string) => ipcRenderer.invoke(IPC_CHANNELS.chatListSessionsByProject, projectId),
......
import test from "node:test"
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
const sharedTypesSource = readFileSync(new URL("../../../packages/shared-types/src/index.ts", import.meta.url), "utf8")
const ipcSource = readFileSync(new URL("../src/main/ipc.ts", import.meta.url), "utf8")
const indexSource = readFileSync(new URL("../src/main/index.ts", import.meta.url), "utf8")
const preloadSource = readFileSync(new URL("../src/preload/index.ts", import.meta.url), "utf8")
test("shared automation task channels and desktop API are declared", () => {
assert.match(sharedTypesSource, /automationTasksList:\s*"automation-tasks:list"/)
assert.match(sharedTypesSource, /automationTasksCreate:\s*"automation-tasks:create"/)
assert.match(sharedTypesSource, /automationTasksUpdate:\s*"automation-tasks:update"/)
assert.match(sharedTypesSource, /automationTasksDelete:\s*"automation-tasks:delete"/)
assert.match(sharedTypesSource, /automationTasksRunNow:\s*"automation-tasks:run-now"/)
assert.match(sharedTypesSource, /automationTasksListRuns:\s*"automation-tasks:list-runs"/)
assert.match(sharedTypesSource, /automationTasks:\s*\{/)
})
test("desktop IPC wires automation tasks to AutomationTaskService", () => {
assert.match(indexSource, /new AutomationTaskService\(systemSummary\.userDataPath/)
assert.match(indexSource, /automationTaskService\.start\(\)/)
assert.match(ipcSource, /automationTaskService\.setExecutor/)
assert.match(ipcSource, /ipcMain\.handle\(IPC_CHANNELS\.automationTasksList/)
assert.match(ipcSource, /ipcMain\.handle\(IPC_CHANNELS\.automationTasksRunNow/)
assert.match(preloadSource, /automationTasks:\s*\{/)
assert.match(preloadSource, /IPC_CHANNELS\.automationTasksCreate/)
})
import test from "node:test"
import assert from "node:assert/strict"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import {
AutomationTaskService,
computeAutomationNextRunAt
} from "../src/main/services/automation-task-service.ts"
async function withService<T>(
run: (service: AutomationTaskService, userDataPath: string) => Promise<T>,
now: Date | (() => Date) = new Date(2026, 4, 19, 9, 0, 0, 0)
) {
const userDataPath = await mkdtemp(path.join(tmpdir(), "qjc-automation-"))
try {
const getNow = () => typeof now === "function" ? now() : now
const service = new AutomationTaskService(userDataPath, {
now: () => new Date(getNow()),
autoStart: false
})
service.setExecutor(async ({ task, run: taskRun }) => ({
sessionId: `session-${task.id}`,
runId: taskRun.id,
replyText: `done:${task.prompt}`,
artifacts: []
}))
return await run(service, userDataPath)
} finally {
await rm(userDataPath, { recursive: true, force: true })
}
}
test("computes next run for one-time, daily, and weekly schedules", () => {
const from = new Date(2026, 4, 19, 9, 30, 0, 0)
assert.equal(
computeAutomationNextRunAt({ kind: "once", runAt: "2026-05-19T10:00:00.000Z" }, from),
"2026-05-19T10:00:00.000Z"
)
assert.equal(
computeAutomationNextRunAt({ kind: "once", runAt: new Date(2026, 4, 19, 9, 0, 0, 0).toISOString() }, from),
undefined
)
assert.equal(
computeAutomationNextRunAt({ kind: "daily", time: "10:15" }, from),
new Date(2026, 4, 19, 10, 15, 0, 0).toISOString()
)
assert.equal(
computeAutomationNextRunAt({ kind: "daily", time: "08:15" }, from),
new Date(2026, 4, 20, 8, 15, 0, 0).toISOString()
)
assert.equal(
computeAutomationNextRunAt({ kind: "weekly", time: "10:00", weekdays: [2] }, from),
new Date(2026, 4, 19, 10, 0, 0, 0).toISOString()
)
assert.equal(
computeAutomationNextRunAt({ kind: "weekly", time: "08:00", weekdays: [2] }, from),
new Date(2026, 4, 26, 8, 0, 0, 0).toISOString()
)
})
test("creates, updates, deletes, and persists automation tasks", async () => {
await withService(async (service, userDataPath) => {
const created = await service.create({
title: "每日复盘",
prompt: "整理昨天内容表现",
expertId: "zhihu",
expertName: "知乎专家",
enabled: true,
schedule: { kind: "daily", time: "10:00" }
})
assert.equal(created.title, "每日复盘")
assert.equal(created.nextRunAt, new Date(2026, 4, 19, 10, 0, 0, 0).toISOString())
const reloaded = new AutomationTaskService(userDataPath, {
now: () => new Date(2026, 4, 19, 9, 0, 0, 0),
autoStart: false
})
assert.equal((await reloaded.list()).length, 1)
const updated = await service.update(created.id, {
enabled: false,
title: "每日复盘更新"
})
assert.equal(updated?.enabled, false)
assert.equal(updated?.nextRunAt, undefined)
assert.equal(await service.delete(created.id), true)
assert.deepEqual(await service.list(), [])
})
})
test("rejects enabled one-time tasks scheduled in the past", async () => {
await withService(async (service) => {
await assert.rejects(
service.create({
title: "过期一次性任务",
prompt: "不应启用",
enabled: true,
schedule: { kind: "once", runAt: new Date(2026, 4, 18, 9, 0, 0, 0).toISOString() }
}),
/future/
)
const disabledPastTask = await service.create({
title: "已停用过期任务",
prompt: "允许保存",
enabled: false,
schedule: { kind: "once", runAt: new Date(2026, 4, 18, 9, 0, 0, 0).toISOString() }
})
await assert.rejects(
service.update(disabledPastTask.id, { enabled: true }),
/future/
)
})
})
test("marks missed app-runtime schedules without auto backfill", async () => {
await withService(async (service, userDataPath) => {
await service.create({
title: "早报",
prompt: "生成早报",
enabled: true,
schedule: { kind: "daily", time: "08:00" }
})
const restarted = new AutomationTaskService(userDataPath, {
now: () => new Date(2026, 4, 19, 9, 0, 0, 0),
autoStart: false
})
await restarted.recoverMissedRuns()
const [task] = await restarted.list()
assert.equal(task?.lastRunStatus, "missed")
assert.equal(task?.nextRunAt, new Date(2026, 4, 20, 8, 0, 0, 0).toISOString())
const runs = await restarted.listRuns(task!.id)
assert.equal(runs.length, 1)
assert.equal(runs[0]?.status, "missed")
}, new Date(2026, 4, 18, 7, 0, 0, 0))
})
test("manual runNow executes the configured executor and records output", async () => {
await withService(async (service) => {
const task = await service.create({
title: "线索整理",
prompt: "整理线索",
enabled: false,
schedule: { kind: "once", runAt: "2026-05-20T09:00:00.000Z" }
})
const run = await service.runNow(task.id)
assert.equal(run.status, "completed")
assert.equal(run.replyText, "done:整理线索")
assert.equal(run.sessionId, `session-${task.id}`)
const runs = await service.listRuns(task.id)
assert.equal(runs[0]?.id, run.id)
assert.equal((await service.list())[0]?.lastRunStatus, "completed")
})
})
test("scheduled one-time task disables itself after completing", async () => {
let now = new Date(2026, 4, 19, 8, 0, 0, 0)
await withService(async (service) => {
const task = await service.create({
title: "一次性整理",
prompt: "执行一次",
enabled: true,
schedule: { kind: "once", runAt: new Date(2026, 4, 19, 9, 0, 0, 0).toISOString() }
})
now = new Date(2026, 4, 19, 9, 0, 0, 0)
await service["runDueTasks"]()
const [updated] = await service.list()
assert.equal(updated?.id, task.id)
assert.equal(updated?.enabled, false)
assert.equal(updated?.nextRunAt, undefined)
assert.equal(updated?.lastRunStatus, "completed")
}, () => now)
})
test("missed one-time task disables itself and keeps a missed run", async () => {
await withService(async (service, userDataPath) => {
const task = await service.create({
title: "错过的一次性任务",
prompt: "错过后保留",
enabled: true,
schedule: { kind: "once", runAt: new Date(2026, 4, 18, 9, 0, 0, 0).toISOString() }
})
const restarted = new AutomationTaskService(userDataPath, {
now: () => new Date(2026, 4, 19, 9, 0, 0, 0),
autoStart: false
})
await restarted.recoverMissedRuns()
const [updated] = await restarted.list()
assert.equal(updated?.id, task.id)
assert.equal(updated?.enabled, false)
assert.equal(updated?.nextRunAt, undefined)
assert.equal(updated?.lastRunStatus, "missed")
const runs = await restarted.listRuns(task.id)
assert.equal(runs.length, 1)
assert.equal(runs[0]?.status, "missed")
}, new Date(2026, 4, 18, 7, 0, 0, 0))
})
...@@ -50,6 +50,7 @@ import { usePromptSubmission } from "./features/chat/usePromptSubmission"; ...@@ -50,6 +50,7 @@ import { usePromptSubmission } from "./features/chat/usePromptSubmission";
import { useSessionMessageStore } from "./features/chat/useSessionMessageStore"; import { useSessionMessageStore } from "./features/chat/useSessionMessageStore";
import { KnowledgeView } from "./features/knowledge/KnowledgeView"; import { KnowledgeView } from "./features/knowledge/KnowledgeView";
import { TaskPanelView } from "./features/tasks/TaskPanelView"; import { TaskPanelView } from "./features/tasks/TaskPanelView";
import { AutomationTasksView } from "./features/automation/AutomationTasksView";
import { getPluginCopy, getPluginStatusLabel, getPluginTone, groupPluginsByStatus } from "./features/plugins/pluginDisplay"; import { getPluginCopy, getPluginStatusLabel, getPluginTone, groupPluginsByStatus } from "./features/plugins/pluginDisplay";
import { PluginsView } from "./features/plugins/PluginsView"; import { PluginsView } from "./features/plugins/PluginsView";
import { AppSidebar } from "./features/shell/AppSidebar"; import { AppSidebar } from "./features/shell/AppSidebar";
...@@ -111,7 +112,7 @@ import { ...@@ -111,7 +112,7 @@ import {
import { desktopApi, isMockDesktopApi, smokeEnabled } from "./lib/desktop-api"; import { desktopApi, isMockDesktopApi, smokeEnabled } from "./lib/desktop-api";
import { getHiddenMessageIds, persistHiddenMessageId } from "./lib/hiddenMessages"; import { getHiddenMessageIds, persistHiddenMessageId } from "./lib/hiddenMessages";
type ViewMode = "chat" | "experts" | "tasks" | "plugins" | "settings" | "knowledge"; type ViewMode = "chat" | "experts" | "tasks" | "automation" | "plugins" | "settings" | "knowledge";
type SendPhase = "idle" | "preparing" | "streaming" | "finalizing"; type SendPhase = "idle" | "preparing" | "streaming" | "finalizing";
type MessageReaction = "up" | "down"; type MessageReaction = "up" | "down";
...@@ -1611,6 +1612,9 @@ export default function App() { ...@@ -1611,6 +1612,9 @@ export default function App() {
{viewMode === "tasks" ? ( {viewMode === "tasks" ? (
<TaskPanelView /> <TaskPanelView />
) : null} ) : null}
{viewMode === "automation" ? (
<AutomationTasksView projects={projects} />
) : null}
{viewMode === "settings" ? ( {viewMode === "settings" ? (
<SettingsView statusHint={settingsStatusHint}> <SettingsView statusHint={settingsStatusHint}>
<SettingsPanels {...settingsPanelsProps} /> <SettingsPanels {...settingsPanelsProps} />
......
...@@ -234,7 +234,7 @@ export function getIntentSuggestionIcon(platform?: string): ReactNode { ...@@ -234,7 +234,7 @@ export function getIntentSuggestionIcon(platform?: string): ReactNode {
return <BrowserExpertIcon />; return <BrowserExpertIcon />;
} }
export function NavIcon({ kind }: { kind: "chat" | "experts" | "tasks" | "plugins" | "settings" | "knowledge" }) { export function NavIcon({ kind }: { kind: "chat" | "experts" | "tasks" | "automation" | "plugins" | "settings" | "knowledge" }) {
switch (kind) { switch (kind) {
case "chat": case "chat":
return ( return (
...@@ -258,6 +258,14 @@ export function NavIcon({ kind }: { kind: "chat" | "experts" | "tasks" | "plugin ...@@ -258,6 +258,14 @@ export function NavIcon({ kind }: { kind: "chat" | "experts" | "tasks" | "plugin
<path d="M13.05 16.95h2.8" fill="none" stroke="#2563EB" strokeLinecap="round" strokeWidth="1.35" /> <path d="M13.05 16.95h2.8" fill="none" stroke="#2563EB" strokeLinecap="round" strokeWidth="1.35" />
</svg> </svg>
); );
case "automation":
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M5.25 7.25A2.25 2.25 0 0 1 7.5 5h9A2.25 2.25 0 0 1 18.75 7.25v9.5A2.25 2.25 0 0 1 16.5 19h-9a2.25 2.25 0 0 1-2.25-2.25v-9.5Z" fill="#FEF3C7" stroke="#D97706" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.45" />
<path d="M8 4v3M16 4v3M5.75 9.25h12.5" fill="none" stroke="#B45309" strokeLinecap="round" strokeWidth="1.35" />
<path d="M8.4 13.05h2.65v2.65H8.4zM12.95 12.45l2.6 1.55-2.6 1.55v-3.1Z" fill="#2563EB" />
</svg>
);
case "plugins": case "plugins":
return ( return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"> <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
......
...@@ -13,7 +13,7 @@ import type { ...@@ -13,7 +13,7 @@ import type {
WorkspaceSummary WorkspaceSummary
} from "@qjclaw/shared-types" } from "@qjclaw/shared-types"
type ViewMode = "chat" | "experts" | "tasks" | "plugins" | "settings" | "knowledge" type ViewMode = "chat" | "experts" | "tasks" | "automation" | "plugins" | "settings" | "knowledge"
interface BootstrapSkill { interface BootstrapSkill {
id: string id: string
......
import { useEffect, useMemo, useState } from "react"
import type {
AutomationTask,
AutomationTaskRun,
AutomationTaskSchedule,
ProjectSummary,
UpdateAutomationTaskInput
} from "@qjclaw/shared-types"
import { Button } from "../../components/ui/Button"
import { Panel } from "../../components/ui/Panel"
import { ScrollArea } from "../../components/ui/ScrollArea"
import { StatusChip } from "../../components/ui/StatusChip"
import { RefreshIcon, TrashIcon } from "../../components/icons/AppIcons"
import { desktopApi } from "../../lib/desktop-api"
import { HOME_CHAT_PROJECT_ID } from "../../lib/constants"
import { renderChatMessageContent } from "../chat/renderChatMessageContent"
type FilterMode = "all" | "enabled" | "disabled"
type FormScheduleKind = AutomationTaskSchedule["kind"]
interface AutomationTasksViewProps {
projects: ProjectSummary[]
}
interface AutomationTaskFormState {
id?: string
title: string
prompt: string
expertId: string
enabled: boolean
scheduleKind: FormScheduleKind
runAt: string
time: string
weekdays: number[]
}
const weekdayLabels = ["日", "一", "二", "三", "四", "五", "六"]
function toDateTimeLocalValue(date: Date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, "0")
const day = String(date.getDate()).padStart(2, "0")
const hour = String(date.getHours()).padStart(2, "0")
const minute = String(date.getMinutes()).padStart(2, "0")
return `${year}-${month}-${day}T${hour}:${minute}`
}
function createDefaultForm(projects: ProjectSummary[]): AutomationTaskFormState {
const nextHour = new Date(Date.now() + 60 * 60 * 1000)
return {
title: "",
prompt: "",
expertId: projects[0]?.id ?? "",
enabled: true,
scheduleKind: "daily",
runAt: toDateTimeLocalValue(nextHour),
time: "09:00",
weekdays: [new Date().getDay()]
}
}
function formFromTask(task: AutomationTask): AutomationTaskFormState {
const base = createDefaultForm([])
const schedule = task.schedule
return {
id: task.id,
title: task.title,
prompt: task.prompt,
expertId: task.expertId ?? "",
enabled: task.enabled,
scheduleKind: schedule.kind,
runAt: schedule.kind === "once" ? toDateTimeLocalValue(new Date(schedule.runAt)) : base.runAt,
time: schedule.kind === "once" ? base.time : schedule.time,
weekdays: schedule.kind === "weekly" ? schedule.weekdays : base.weekdays
}
}
function scheduleFromForm(form: AutomationTaskFormState): AutomationTaskSchedule {
if (form.scheduleKind === "once") {
return {
kind: "once",
runAt: new Date(form.runAt).toISOString()
}
}
if (form.scheduleKind === "weekly") {
return {
kind: "weekly",
time: form.time,
weekdays: form.weekdays.length ? form.weekdays : [new Date().getDay()]
}
}
return {
kind: "daily",
time: form.time
}
}
function formatDateTime(value?: string) {
if (!value) {
return "未安排"
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
})
}
function formatSchedule(schedule: AutomationTaskSchedule) {
if (schedule.kind === "once") {
return `一次性 ${formatDateTime(schedule.runAt)}`
}
if (schedule.kind === "daily") {
return `每天 ${schedule.time}`
}
return `每周${schedule.weekdays.map((day) => weekdayLabels[day]).join("、")} ${schedule.time}`
}
function getRunStatusLabel(status?: AutomationTaskRun["status"]) {
switch (status) {
case "completed":
return "已完成"
case "running":
return "执行中"
case "failed":
return "失败"
case "missed":
return "已错过"
case "queued":
return "排队中"
default:
return "未运行"
}
}
function getStatusTone(status?: AutomationTaskRun["status"]) {
if (status === "completed") {
return "positive" as const
}
if (status === "running" || status === "queued") {
return "info" as const
}
if (status === "failed" || status === "missed") {
return "warning" as const
}
return "info" as const
}
function getTaskLifecycleLabel(task: AutomationTask) {
if (task.enabled) {
return "启用"
}
if (task.schedule.kind === "once" && task.lastRunStatus === "completed") {
return "已完成,已停用"
}
if (task.schedule.kind === "once" && task.lastRunStatus === "missed") {
return "已错过,已停用"
}
return "已停用"
}
function getTaskNextRunLabel(task: AutomationTask) {
return task.enabled ? formatDateTime(task.nextRunAt) : getTaskLifecycleLabel(task)
}
export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
const [tasks, setTasks] = useState<AutomationTask[]>([])
const [runs, setRuns] = useState<AutomationTaskRun[]>([])
const [selectedTaskId, setSelectedTaskId] = useState("")
const [filterMode, setFilterMode] = useState<FilterMode>("all")
const [form, setForm] = useState<AutomationTaskFormState | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [errorText, setErrorText] = useState("")
const expertOptions = useMemo(() => [
{ id: "", label: "通用助手" },
...projects.filter((project) => project.id !== HOME_CHAT_PROJECT_ID).map((project) => ({
id: project.id,
label: project.displayName || project.name || project.id
}))
], [projects])
const selectedTask = useMemo(
() => tasks.find((task) => task.id === selectedTaskId) ?? tasks[0],
[selectedTaskId, tasks]
)
const filteredTasks = useMemo(() => tasks.filter((task) => {
if (filterMode === "enabled") {
return task.enabled
}
if (filterMode === "disabled") {
return !task.enabled
}
return true
}), [filterMode, tasks])
const loadTasks = async () => {
setLoading(true)
setErrorText("")
try {
const nextTasks = await desktopApi.automationTasks.list()
setTasks(nextTasks)
setSelectedTaskId((current) => current && nextTasks.some((task) => task.id === current) ? current : nextTasks[0]?.id ?? "")
} catch (error) {
setErrorText(error instanceof Error ? error.message : "自动化任务加载失败")
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadTasks()
}, [])
useEffect(() => {
let active = true
if (!selectedTask?.id) {
setRuns([])
return
}
void desktopApi.automationTasks.listRuns(selectedTask.id)
.then((nextRuns) => {
if (active) {
setRuns(nextRuns)
}
})
.catch((error) => {
if (active) {
setErrorText(error instanceof Error ? error.message : "运行记录加载失败")
}
})
return () => {
active = false
}
}, [selectedTask?.id])
const startCreate = () => {
setForm(createDefaultForm(projects))
}
const startEdit = (task: AutomationTask) => {
setForm(formFromTask(task))
}
const submitForm = async () => {
if (!form) {
return
}
const title = form.title.trim()
const prompt = form.prompt.trim()
if (!title || !prompt) {
setErrorText("请填写标题和任务内容")
return
}
setSaving(true)
setErrorText("")
try {
const selectedExpert = expertOptions.find((option) => option.id === form.expertId)
const expertId = form.expertId || (form.id ? null : undefined)
const expertName = form.expertId ? selectedExpert?.label : (form.id ? null : undefined)
const input = {
title,
prompt,
expertId,
expertName,
enabled: form.enabled,
schedule: scheduleFromForm(form)
}
const saved = form.id
? await desktopApi.automationTasks.update(form.id, input satisfies UpdateAutomationTaskInput)
: await desktopApi.automationTasks.create({
title,
prompt,
expertId: form.expertId || undefined,
expertName: form.expertId ? selectedExpert?.label : undefined,
enabled: form.enabled,
schedule: scheduleFromForm(form)
})
if (saved) {
setSelectedTaskId(saved.id)
}
setForm(null)
await loadTasks()
} catch (error) {
setErrorText(error instanceof Error ? error.message : "自动化任务保存失败")
} finally {
setSaving(false)
}
}
const deleteTask = async (taskId: string) => {
const task = tasks.find((candidate) => candidate.id === taskId)
const confirmed = window.confirm(`确认删除自动化任务「${task?.title ?? "未命名任务"}」?`)
if (!confirmed) {
return
}
setSaving(true)
setErrorText("")
try {
await desktopApi.automationTasks.delete(taskId)
setForm(null)
await loadTasks()
} catch (error) {
setErrorText(error instanceof Error ? error.message : "自动化任务删除失败")
} finally {
setSaving(false)
}
}
const runTaskNow = async (taskId: string) => {
setSaving(true)
setErrorText("")
try {
const run = await desktopApi.automationTasks.runNow(taskId)
setRuns((current) => [run, ...current.filter((item) => item.id !== run.id)])
await loadTasks()
} catch (error) {
setErrorText(error instanceof Error ? error.message : "立即执行失败")
} finally {
setSaving(false)
}
}
const copyRunMarkdownCode = async (_token: string, text: string) => {
await navigator.clipboard?.writeText(text)
}
return (
<div className="automation-page-stack">
<Panel
className="automation-page"
bodyClassName="automation-page-body"
header={(
<div className="automation-header">
<div>
<h1>自动化任务</h1>
<p>应用运行时执行,错过的计划会保留为记录。</p>
</div>
<div className="automation-header-actions">
<Button variant="secondary" size="sm" className="automation-action-button automation-button-secondary" onClick={() => void loadTasks()} disabled={loading || saving}>
<RefreshIcon />
<span>刷新</span>
</Button>
<Button size="sm" className="automation-action-button automation-button-primary" onClick={startCreate}>新建任务</Button>
</div>
</div>
)}
>
{errorText ? <div className="notice error" role="alert">{errorText}</div> : null}
<div className="automation-layout">
<aside className="automation-list-pane" aria-label="自动化任务列表">
<div className="automation-filter-row">
{([
["all", "全部"],
["enabled", "启用"],
["disabled", "停用"]
] as const).map(([key, label]) => (
<button
key={key}
type="button"
className={filterMode === key ? "active" : ""}
onClick={() => setFilterMode(key)}
>
{label}
</button>
))}
</div>
<ScrollArea className="automation-list-scroll">
<div className="automation-task-list">
{loading ? (
<div className="automation-empty">正在加载</div>
) : filteredTasks.length ? filteredTasks.map((task) => (
<article
key={task.id}
className={"automation-task-row" + (selectedTask?.id === task.id ? " active" : "")}
>
<button
type="button"
className="automation-task-select"
onClick={() => setSelectedTaskId(task.id)}
>
<strong>{task.title}</strong>
<small>{task.prompt}</small>
</button>
</article>
)) : (
<div className="automation-empty">暂无任务</div>
)}
</div>
</ScrollArea>
</aside>
<section className="automation-detail-pane" aria-label="自动化任务详情">
{form ? (
<div className="automation-form">
<div className="automation-form-grid">
<label>
<span>标题</span>
<input value={form.title} onChange={(event) => setForm({ ...form, title: event.target.value })} />
</label>
<label>
<span>执行专家</span>
<select value={form.expertId} onChange={(event) => setForm({ ...form, expertId: event.target.value })}>
{expertOptions.map((option) => (
<option key={option.id || "home"} value={option.id}>{option.label}</option>
))}
</select>
</label>
<label className="automation-form-full">
<span>任务内容</span>
<textarea rows={5} value={form.prompt} onChange={(event) => setForm({ ...form, prompt: event.target.value })} />
</label>
<label>
<span>规则</span>
<select value={form.scheduleKind} onChange={(event) => setForm({ ...form, scheduleKind: event.target.value as FormScheduleKind })}>
<option value="once">一次性</option>
<option value="daily">每天</option>
<option value="weekly">每周</option>
</select>
</label>
{form.scheduleKind === "once" ? (
<label>
<span>执行时间</span>
<input type="datetime-local" value={form.runAt} onChange={(event) => setForm({ ...form, runAt: event.target.value })} />
</label>
) : (
<label>
<span>具体时间</span>
<input type="time" value={form.time} onChange={(event) => setForm({ ...form, time: event.target.value })} />
</label>
)}
{form.scheduleKind === "weekly" ? (
<div className="automation-weekdays automation-form-full">
{weekdayLabels.map((label, day) => (
<label key={day}>
<input
type="checkbox"
checked={form.weekdays.includes(day)}
onChange={(event) => setForm({
...form,
weekdays: event.target.checked
? [...form.weekdays, day].sort((left, right) => left - right)
: form.weekdays.filter((value) => value !== day)
})}
/>
<span>{label}</span>
</label>
))}
</div>
) : null}
<label className="automation-enabled-toggle">
<input type="checkbox" checked={form.enabled} onChange={(event) => setForm({ ...form, enabled: event.target.checked })} />
<span>启用任务</span>
</label>
</div>
<div className="automation-form-actions">
<Button variant="secondary" size="sm" onClick={() => setForm(null)} disabled={saving}>取消</Button>
<Button size="sm" onClick={() => void submitForm()} disabled={saving}>{saving ? "保存中" : "保存"}</Button>
</div>
</div>
) : selectedTask ? (
<>
<div className="automation-detail-head">
<div>
<span className="automation-kicker">{selectedTask.expertName || "通用助手"}</span>
<h2>{selectedTask.title}</h2>
<p className="automation-detail-prompt">{selectedTask.prompt}</p>
</div>
<div className="automation-detail-actions">
<Button variant="secondary" size="sm" className="automation-action-button automation-button-secondary" onClick={() => startEdit(selectedTask)} disabled={saving}>编辑</Button>
<Button size="sm" className="automation-action-button automation-button-accent" onClick={() => void runTaskNow(selectedTask.id)} disabled={saving}>
{saving ? <span>执行中</span> : <span>立即执行</span>}
</Button>
<button
type="button"
className="button button-sm automation-action-button automation-delete-button"
onClick={() => void deleteTask(selectedTask.id)}
disabled={saving}
>
<TrashIcon />
<span>删除</span>
</button>
</div>
</div>
<div className="automation-meta-grid">
<div>
<span>定时规则</span>
<strong>{formatSchedule(selectedTask.schedule)}</strong>
</div>
<div>
<span>下次执行</span>
<strong>{getTaskNextRunLabel(selectedTask)}</strong>
</div>
<div>
<span>最近状态</span>
<strong>{getTaskLifecycleLabel(selectedTask)}</strong>
</div>
</div>
<div className="automation-runs">
<h3>运行记录</h3>
<div className="automation-run-list">
{runs.length ? runs.map((run) => (
<article key={run.id} className="automation-run-item">
<div className="automation-run-meta">
<StatusChip tone={getStatusTone(run.status)}>{getRunStatusLabel(run.status)}</StatusChip>
<span>{formatDateTime(run.completedAt ?? run.startedAt ?? run.scheduledFor)}</span>
</div>
{run.replyText ? (
<div className="markdown-body automation-run-markdown">
{renderChatMessageContent(run.replyText, {
messageId: `automation-run-${run.id}`,
copiedToken: "",
onCopy: copyRunMarkdownCode
})}
</div>
) : null}
{run.error ? <p className="automation-run-error">{run.error}</p> : null}
</article>
)) : (
<div className="automation-empty">暂无运行记录</div>
)}
</div>
</div>
</>
) : (
<div className="automation-empty">暂无任务</div>
)}
</section>
</div>
</Panel>
</div>
)
}
...@@ -22,7 +22,7 @@ interface ChatComposerProps { ...@@ -22,7 +22,7 @@ interface ChatComposerProps {
canSend: boolean canSend: boolean
isDragOver: boolean isDragOver: boolean
isResizeActive: boolean isResizeActive: boolean
viewMode: "chat" | "experts" | "tasks" | "plugins" | "settings" | "knowledge" viewMode: "chat" | "experts" | "tasks" | "automation" | "plugins" | "settings" | "knowledge"
shellStyle: CSSProperties shellStyle: CSSProperties
attachmentInputRef: RefObject<HTMLInputElement | null> attachmentInputRef: RefObject<HTMLInputElement | null>
skillMenuRef: RefObject<HTMLDivElement | null> skillMenuRef: RefObject<HTMLDivElement | null>
......
...@@ -19,7 +19,7 @@ import { ConversationStatusNotice, HomeIntentSuggestionNotice } from "./Conversa ...@@ -19,7 +19,7 @@ import { ConversationStatusNotice, HomeIntentSuggestionNotice } from "./Conversa
import { MessageList, type ExpertKey, type MessageListMessage } from "./MessageList" import { MessageList, type ExpertKey, type MessageListMessage } from "./MessageList"
import type { MessageTraceState } from "./useMessageTraces" import type { MessageTraceState } from "./useMessageTraces"
type ViewMode = "chat" | "experts" | "tasks" | "plugins" | "settings" | "knowledge" type ViewMode = "chat" | "experts" | "tasks" | "automation" | "plugins" | "settings" | "knowledge"
type MessageReaction = "up" | "down" type MessageReaction = "up" | "down"
interface HomeStarterPrompt { interface HomeStarterPrompt {
......
...@@ -4,7 +4,7 @@ import { desktopApi } from "../../lib/desktop-api" ...@@ -4,7 +4,7 @@ import { desktopApi } from "../../lib/desktop-api"
import { getTraceDisplayLines, getTraceLineClassName, getTraceStripTitle } from "./messageTraceDisplay" import { getTraceDisplayLines, getTraceLineClassName, getTraceStripTitle } from "./messageTraceDisplay"
import type { MessageTraceState } from "./useMessageTraces" import type { MessageTraceState } from "./useMessageTraces"
type ViewMode = "chat" | "experts" | "tasks" | "plugins" | "settings" | "knowledge" type ViewMode = "chat" | "experts" | "tasks" | "automation" | "plugins" | "settings" | "knowledge"
type MessageReaction = "up" | "down" type MessageReaction = "up" | "down"
export type ExpertKey = "xiaohongshu" | "douyin" | "browser" | "general" export type ExpertKey = "xiaohongshu" | "douyin" | "browser" | "general"
......
...@@ -3,7 +3,7 @@ import type { DesktopApi, WorkspaceSummary } from "@qjclaw/shared-types" ...@@ -3,7 +3,7 @@ import type { DesktopApi, WorkspaceSummary } from "@qjclaw/shared-types"
import { EMPTY_SESSION_ID, HOME_CHAT_PROJECT_ID } from "../../lib/constants" import { EMPTY_SESSION_ID, HOME_CHAT_PROJECT_ID } from "../../lib/constants"
import { resolvePreferredSessionId } from "../../lib/chat-utils" import { resolvePreferredSessionId } from "../../lib/chat-utils"
type ViewMode = "chat" | "experts" | "tasks" | "plugins" | "settings" | "knowledge" type ViewMode = "chat" | "experts" | "tasks" | "automation" | "plugins" | "settings" | "knowledge"
interface UseChatSessionsControllerDeps { interface UseChatSessionsControllerDeps {
desktopApi: DesktopApi desktopApi: DesktopApi
......
...@@ -6,7 +6,7 @@ import { TYPEWRITER_CHARS_PER_FRAME } from "../../lib/constants" ...@@ -6,7 +6,7 @@ import { TYPEWRITER_CHARS_PER_FRAME } from "../../lib/constants"
import { canExchangeMessages } from "../../lib/workspace-state" import { canExchangeMessages } from "../../lib/workspace-state"
import type { SmokeStreamSnapshot } from "../smoke/types" import type { SmokeStreamSnapshot } from "../smoke/types"
type ViewMode = "chat" | "experts" | "tasks" | "plugins" | "settings" | "knowledge" type ViewMode = "chat" | "experts" | "tasks" | "automation" | "plugins" | "settings" | "knowledge"
type SendPhase = "idle" | "preparing" | "streaming" | "finalizing" type SendPhase = "idle" | "preparing" | "streaming" | "finalizing"
interface ActiveStreamState { interface ActiveStreamState {
requestId: string requestId: string
......
...@@ -4,7 +4,7 @@ import type { SubmitPromptOptions } from "./useChatStreamingController" ...@@ -4,7 +4,7 @@ import type { SubmitPromptOptions } from "./useChatStreamingController"
import { resolvePreferredSessionId } from "../../lib/chat-utils" import { resolvePreferredSessionId } from "../../lib/chat-utils"
import { HOME_CHAT_PROJECT_ID } from "../../lib/constants" import { HOME_CHAT_PROJECT_ID } from "../../lib/constants"
type ViewMode = "chat" | "experts" | "tasks" | "plugins" | "settings" | "knowledge" type ViewMode = "chat" | "experts" | "tasks" | "automation" | "plugins" | "settings" | "knowledge"
export interface PendingHomeIntentSuggestion { export interface PendingHomeIntentSuggestion {
suggestion: ProjectIntentSuggestion suggestion: ProjectIntentSuggestion
......
...@@ -4,7 +4,7 @@ import type { SubmitPromptOptions } from "./useChatStreamingController" ...@@ -4,7 +4,7 @@ import type { SubmitPromptOptions } from "./useChatStreamingController"
import { HOME_CHAT_PROJECT_ID, HOME_EXPERT_SUGGESTION_PROJECT_IDS } from "../../lib/constants" import { HOME_CHAT_PROJECT_ID, HOME_EXPERT_SUGGESTION_PROJECT_IDS } from "../../lib/constants"
import { shouldOfferHomeExpertSwitch } from "../../lib/chat-utils" import { shouldOfferHomeExpertSwitch } from "../../lib/chat-utils"
type ViewMode = "chat" | "experts" | "tasks" | "plugins" | "settings" | "knowledge" type ViewMode = "chat" | "experts" | "tasks" | "automation" | "plugins" | "settings" | "knowledge"
interface ResumePromptOptions { interface ResumePromptOptions {
skipHomeIntentSuggestion?: boolean skipHomeIntentSuggestion?: boolean
......
...@@ -4,7 +4,7 @@ import { Sidebar } from "./Sidebar" ...@@ -4,7 +4,7 @@ import { Sidebar } from "./Sidebar"
import { ExpertTree, type ExpertCategoryId, type ExpertVisualKey, type SidebarExpertEntry } from "./ExpertTree" import { ExpertTree, type ExpertCategoryId, type ExpertVisualKey, type SidebarExpertEntry } from "./ExpertTree"
import { SessionList } from "./SessionList" import { SessionList } from "./SessionList"
type ViewMode = "chat" | "experts" | "tasks" | "plugins" | "settings" | "knowledge" type ViewMode = "chat" | "experts" | "tasks" | "automation" | "plugins" | "settings" | "knowledge"
type SendPhase = "idle" | "preparing" | "streaming" | "finalizing" type SendPhase = "idle" | "preparing" | "streaming" | "finalizing"
interface AppSidebarProps { interface AppSidebarProps {
...@@ -76,6 +76,7 @@ export function AppSidebar({ ...@@ -76,6 +76,7 @@ export function AppSidebar({
{ id: "chat" as const, label: "对话" }, { id: "chat" as const, label: "对话" },
{ id: "tasks" as const, label: "工作台" }, { id: "tasks" as const, label: "工作台" },
{ id: "knowledge" as const, label: ui.knowledge }, { id: "knowledge" as const, label: ui.knowledge },
{ id: "automation" as const, label: "自动化任务" },
{ id: "settings" as const, label: ui.settings } { id: "settings" as const, label: ui.settings }
].map((item) => ( ].map((item) => (
<button <button
......
...@@ -104,7 +104,7 @@ function expertMatchesCategory(entry: SidebarExpertEntry, categoryId: ExpertCate ...@@ -104,7 +104,7 @@ function expertMatchesCategory(entry: SidebarExpertEntry, categoryId: ExpertCate
interface ExpertTreeProps { interface ExpertTreeProps {
entries: SidebarExpertEntry[] entries: SidebarExpertEntry[]
expandedCategories: Record<string, boolean> expandedCategories: Record<string, boolean>
viewMode: "chat" | "experts" | "tasks" | "plugins" | "settings" | "knowledge" viewMode: "chat" | "experts" | "tasks" | "automation" | "plugins" | "settings" | "knowledge"
prompt: string prompt: string
activeProjectId?: string activeProjectId?: string
onToggleCategory(categoryId: string): void onToggleCategory(categoryId: string): void
......
...@@ -3,7 +3,7 @@ import type { DesktopApi, ExpertDefinition, WorkspaceSummary } from "@qjclaw/sha ...@@ -3,7 +3,7 @@ import type { DesktopApi, ExpertDefinition, WorkspaceSummary } from "@qjclaw/sha
import { EMPTY_SESSION_ID, HOME_CHAT_PROJECT_ID } from "../../lib/constants" import { EMPTY_SESSION_ID, HOME_CHAT_PROJECT_ID } from "../../lib/constants"
import type { SidebarExpertEntry } from "./ExpertTree" import type { SidebarExpertEntry } from "./ExpertTree"
type ViewMode = "chat" | "experts" | "tasks" | "plugins" | "settings" | "knowledge" type ViewMode = "chat" | "experts" | "tasks" | "automation" | "plugins" | "settings" | "knowledge"
interface UseHomeNavigationDeps { interface UseHomeNavigationDeps {
desktopApi: DesktopApi desktopApi: DesktopApi
......
...@@ -14,7 +14,7 @@ import { ...@@ -14,7 +14,7 @@ import {
buildStandaloneExpertEntries buildStandaloneExpertEntries
} from "./expertEntries" } from "./expertEntries"
type ViewMode = "chat" | "experts" | "tasks" | "plugins" | "settings" | "knowledge" type ViewMode = "chat" | "experts" | "tasks" | "automation" | "plugins" | "settings" | "knowledge"
interface UseSidebarModelOptions { interface UseSidebarModelOptions {
workspace: WorkspaceSummary | null workspace: WorkspaceSummary | null
......
...@@ -4,7 +4,7 @@ import { HOME_CHAT_PROJECT_ID } from "../../lib/constants" ...@@ -4,7 +4,7 @@ import { HOME_CHAT_PROJECT_ID } from "../../lib/constants"
import type { SubmitPromptOptions } from "../chat/useChatStreamingController" import type { SubmitPromptOptions } from "../chat/useChatStreamingController"
import { waitForSmokeUiReady } from "./useSmokeActions" import { waitForSmokeUiReady } from "./useSmokeActions"
type ViewMode = "chat" | "experts" | "tasks" | "plugins" | "settings" | "knowledge" type ViewMode = "chat" | "experts" | "tasks" | "automation" | "plugins" | "settings" | "knowledge"
type ExpertProject = WorkspaceSummary["projects"][number] type ExpertProject = WorkspaceSummary["projects"][number]
type SidebarExpertEntry = { type SidebarExpertEntry = {
definition: ExpertDefinition definition: ExpertDefinition
......
import { useEffect } from "react" import { useEffect } from "react"
import { smokeEnabled } from "../../lib/desktop-api" import { smokeEnabled } from "../../lib/desktop-api"
type ViewMode = "chat" | "experts" | "tasks" | "plugins" | "settings" | "knowledge" type ViewMode = "chat" | "experts" | "tasks" | "automation" | "plugins" | "settings" | "knowledge"
export async function waitForSmokeUiReady(targetView: ViewMode, timeoutMs = 10000) { export async function waitForSmokeUiReady(targetView: ViewMode, timeoutMs = 10000) {
const started = Date.now() const started = Date.now()
......
...@@ -15,7 +15,7 @@ import type { ...@@ -15,7 +15,7 @@ import type {
import { isMockDesktopApi, smokeEnabled } from "../../lib/desktop-api" import { isMockDesktopApi, smokeEnabled } from "../../lib/desktop-api"
import type { SmokeStreamSnapshot } from "./types" import type { SmokeStreamSnapshot } from "./types"
type ViewMode = "chat" | "experts" | "tasks" | "plugins" | "settings" | "knowledge" type ViewMode = "chat" | "experts" | "tasks" | "automation" | "plugins" | "settings" | "knowledge"
export interface SmokeUiSnapshot { export interface SmokeUiSnapshot {
shellReady: boolean shellReady: boolean
......
import type { import type {
AutomationTask,
AutomationTaskRun,
ChatAttachment, ChatAttachment,
ChatStreamEvent, ChatStreamEvent,
ChatStreamListener, ChatStreamListener,
CreateAutomationTaskInput,
DesktopApi, DesktopApi,
DiagnosticsExportResult, DiagnosticsExportResult,
ExpertDefinition, ExpertDefinition,
ProjectIntentSuggestion, ProjectIntentSuggestion,
SaveConfigInput, SaveConfigInput,
UpdateAutomationTaskInput,
WorkspaceSummary WorkspaceSummary
} from "@qjclaw/shared-types" } from "@qjclaw/shared-types"
import { import {
...@@ -163,6 +167,59 @@ const mockProjectIntentSuggestions: Record<string, ProjectIntentSuggestion> = { ...@@ -163,6 +167,59 @@ const mockProjectIntentSuggestions: Record<string, ProjectIntentSuggestion> = {
} }
}; };
let mockAutomationTasks: AutomationTask[] = [
{
id: "mock-automation-daily-report",
title: "每日内容复盘",
prompt: "整理昨天内容表现,并给出今天的优化建议。",
expertId: "content-account-planner",
expertName: "内容账号规划专家",
enabled: true,
schedule: { kind: "daily", time: "09:30" },
nextRunAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
lastRunStatus: "completed",
lastRunAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
createdAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
updatedAt: new Date().toISOString()
}
];
let mockAutomationRuns: AutomationTaskRun[] = [
{
id: "mock-automation-run-1",
taskId: "mock-automation-daily-report",
trigger: "schedule",
status: "completed",
scheduledFor: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
startedAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
completedAt: new Date(Date.now() - 24 * 60 * 60 * 1000 + 5000).toISOString(),
sessionId: "project:content-account-planner:auto",
runId: "mock-chat-run-1",
replyText: "已完成昨日内容复盘,并整理了今日优化建议。",
artifacts: []
}
];
function getMockAutomationNextRunAt(enabled: boolean) {
return enabled ? new Date(Date.now() + 60 * 60 * 1000).toISOString() : undefined
}
function createMockAutomationTask(input: CreateAutomationTaskInput): AutomationTask {
const createdAt = new Date().toISOString()
return {
id: createClientMessageId("automation-task"),
title: input.title.trim() || "未命名自动化任务",
prompt: input.prompt.trim(),
expertId: input.expertId,
expertName: input.expertName,
enabled: input.enabled,
schedule: input.schedule,
nextRunAt: getMockAutomationNextRunAt(input.enabled),
createdAt,
updatedAt: createdAt
}
}
function resolveMockProjectIntentSuggestion(prompt: string): ProjectIntentSuggestion | null { function resolveMockProjectIntentSuggestion(prompt: string): ProjectIntentSuggestion | null {
const normalized = prompt.trim().toLowerCase(); const normalized = prompt.trim().toLowerCase();
if (!normalized) { if (!normalized) {
...@@ -382,6 +439,67 @@ export const mockDesktopApi = { ...@@ -382,6 +439,67 @@ export const mockDesktopApi = {
modelConfig: { getSummary: async () => ({ source: "cloud", updatedAt: new Date().toISOString(), fetchedAt: new Date().toISOString(), routingMode: "platform-managed", fallbackMode: "cloud-required", defaultChatModelId: "gpt-5.4-mini", defaultChatModelLabel: "GPT-5.4 Mini", items: [], skillBindings: [], message: "mock" }) }, modelConfig: { getSummary: async () => ({ source: "cloud", updatedAt: new Date().toISOString(), fetchedAt: new Date().toISOString(), routingMode: "platform-managed", fallbackMode: "cloud-required", defaultChatModelId: "gpt-5.4-mini", defaultChatModelLabel: "GPT-5.4 Mini", items: [], skillBindings: [], message: "mock" }) },
system: { getSummary: async () => ({ appName: "千匠问天", appVersion: "0.1.0", isPackaged: false, platform: "win32", arch: "x64", appPath: "D:/qjclaw/apps/desktop", resourcesPath: "D:/qjclaw/apps/desktop/dist", userDataPath: "D:/qjclaw/.tmp/user-data", logsPath: "D:/qjclaw/.tmp/logs" }) }, system: { getSummary: async () => ({ appName: "千匠问天", appVersion: "0.1.0", isPackaged: false, platform: "win32", arch: "x64", appPath: "D:/qjclaw/apps/desktop", resourcesPath: "D:/qjclaw/apps/desktop/dist", userDataPath: "D:/qjclaw/.tmp/user-data", logsPath: "D:/qjclaw/.tmp/logs" }) },
tasks: { listByDate: async () => [] }, tasks: { listByDate: async () => [] },
automationTasks: {
list: async () => [...mockAutomationTasks],
create: async (input: CreateAutomationTaskInput) => {
const task = createMockAutomationTask(input)
mockAutomationTasks = [task, ...mockAutomationTasks]
return task
},
update: async (taskId: string, input: UpdateAutomationTaskInput) => {
const index = mockAutomationTasks.findIndex((task) => task.id === taskId)
if (index < 0) {
return null
}
const current = mockAutomationTasks[index]!
const enabled = input.enabled ?? current.enabled
const updated: AutomationTask = {
...current,
title: input.title ?? current.title,
prompt: input.prompt ?? current.prompt,
expertId: input.expertId === null ? undefined : input.expertId ?? current.expertId,
expertName: input.expertName === null ? undefined : input.expertName ?? current.expertName,
enabled,
schedule: input.schedule ?? current.schedule,
nextRunAt: getMockAutomationNextRunAt(enabled),
updatedAt: new Date().toISOString()
}
mockAutomationTasks[index] = updated
return updated
},
delete: async (taskId: string) => {
const before = mockAutomationTasks.length
mockAutomationTasks = mockAutomationTasks.filter((task) => task.id !== taskId)
mockAutomationRuns = mockAutomationRuns.filter((run) => run.taskId !== taskId)
return mockAutomationTasks.length !== before
},
runNow: async (taskId: string) => {
const task = mockAutomationTasks.find((candidate) => candidate.id === taskId)
if (!task) {
throw new Error("Automation task not found")
}
const now = new Date().toISOString()
const run: AutomationTaskRun = {
id: createClientMessageId("automation-run"),
taskId,
trigger: "manual",
status: "completed",
startedAt: now,
completedAt: now,
sessionId: `project:${task.expertId ?? HOME_CHAT_PROJECT_ID}:auto`,
runId: createClientMessageId("mock-run"),
replyText: "Mock: " + task.prompt,
artifacts: []
}
mockAutomationRuns = [run, ...mockAutomationRuns]
mockAutomationTasks = mockAutomationTasks.map((candidate) => candidate.id === taskId
? { ...candidate, lastRunAt: now, lastRunStatus: "completed", updatedAt: now }
: candidate
)
return run
},
listRuns: async (taskId?: string) => mockAutomationRuns.filter((run) => !taskId || run.taskId === taskId)
},
chat: { chat: {
listSessions: async () => getMockSessions(), listSessions: async () => getMockSessions(),
listSessionsByProject: async (projectId: string) => getMockSessions(projectId), listSessionsByProject: async (projectId: string) => getMockSessions(projectId),
......
...@@ -6,4 +6,5 @@ ...@@ -6,4 +6,5 @@
@import "./styles/plugins.css"; @import "./styles/plugins.css";
@import "./styles/knowledge.css"; @import "./styles/knowledge.css";
@import "./styles/tasks.css"; @import "./styles/tasks.css";
@import "./styles/automation.css";
@import "./styles/theme-openclaw.css"; @import "./styles/theme-openclaw.css";
.automation-page-stack {
height: 100%;
display: flex;
min-height: 0;
}
.automation-page {
flex: 1;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
min-height: 0;
overflow: hidden;
border-radius: 18px;
}
.automation-page-body {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 14px;
min-height: 0;
padding: 0;
}
.automation-header,
.automation-header-actions,
.automation-detail-actions,
.automation-form-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-width: 0;
}
.automation-header-actions,
.automation-detail-actions,
.automation-form-actions {
flex-wrap: wrap;
}
.automation-detail-actions {
justify-content: flex-end;
}
.automation-action-button,
.automation-delete-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
white-space: nowrap;
}
.automation-action-button svg,
.automation-delete-button svg {
width: 15px;
height: 15px;
}
.automation-button-primary {
border: 1px solid rgba(37, 99, 235, 0.22);
background: #2563eb;
color: #ffffff;
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.16);
}
.automation-button-primary:hover:not(:disabled),
.automation-button-primary:focus-visible {
background: #1d4ed8;
}
.automation-button-secondary {
border: 1px solid rgba(203, 213, 225, 0.92);
background: #ffffff;
color: #1f2937;
}
.automation-button-secondary:hover:not(:disabled),
.automation-button-secondary:focus-visible {
border-color: rgba(147, 197, 253, 0.95);
background: #eff6ff;
color: #1d4ed8;
}
.automation-button-accent {
border: 1px solid rgba(7, 193, 96, 0.42);
background: #07C160;
color: #ffffff;
box-shadow: 0 6px 14px rgba(7, 193, 96, 0.18);
}
.automation-button-accent:hover:not(:disabled),
.automation-button-accent:focus-visible {
border-color: rgba(6, 174, 86, 0.54);
background: #06AD56;
color: #ffffff;
}
.automation-delete-button {
border: 1px solid rgba(248, 113, 113, 0.42);
background: rgba(254, 242, 242, 0.96);
color: #dc2626;
cursor: pointer;
}
.automation-delete-button:hover:not(:disabled),
.automation-delete-button:focus-visible {
border-color: rgba(220, 38, 38, 0.52);
background: #fee2e2;
}
.automation-header h1,
.automation-detail-head h2,
.automation-runs h3 {
margin: 0;
color: #111827;
letter-spacing: 0;
}
.automation-header h1 {
font-size: 20px;
line-height: 1.2;
}
.automation-header p,
.automation-detail-head p,
.automation-run-item p {
margin: 5px 0 0;
color: #64748b;
font-size: 13px;
line-height: 1.5;
}
.automation-layout {
display: grid;
grid-template-columns: minmax(260px, 0.8fr) minmax(0, 1.6fr);
gap: 14px;
min-height: 0;
}
.automation-list-pane,
.automation-detail-pane,
.automation-form {
min-width: 0;
min-height: 0;
border: 1px solid rgba(203, 213, 225, 0.86);
border-radius: 16px;
background: rgba(255, 255, 255, 0.82);
}
.automation-list-pane {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 10px;
padding: 12px;
}
.automation-filter-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.automation-filter-row button {
min-height: 32px;
border: 1px solid rgba(203, 213, 225, 0.88);
border-radius: 8px;
background: #ffffff;
color: #475569;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.automation-filter-row button.active {
border-color: #93c5fd;
background: #eff6ff;
color: #1d4ed8;
}
.automation-list-scroll {
min-height: 0;
}
.automation-task-list,
.automation-run-list {
display: grid;
gap: 8px;
}
.automation-task-row {
min-height: 70px;
padding: 12px;
border: 1px solid rgba(226, 232, 240, 0.9);
border-radius: 12px;
background: #ffffff;
color: inherit;
text-align: left;
}
.automation-task-row.active {
border-color: #60a5fa;
background: linear-gradient(180deg, #eff6ff, #ffffff);
}
.automation-task-select {
display: grid;
gap: 6px;
width: 100%;
min-height: 46px;
min-width: 0;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.automation-task-select:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline-offset: 3px;
}
.automation-task-select strong,
.automation-task-select small {
overflow: hidden;
}
.automation-task-select strong {
color: #0f172a;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.automation-task-select small {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
color: #64748b;
font-size: 12px;
line-height: 1.35;
}
.automation-detail-pane {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 12px;
padding: 16px;
overflow: hidden;
}
.automation-detail-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
}
.automation-detail-head h2 {
margin-top: 6px;
font-size: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.automation-detail-prompt {
display: -webkit-box;
max-width: 100%;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.automation-kicker {
display: inline-flex;
max-width: 100%;
min-height: 24px;
align-items: center;
padding: 0 9px;
border-radius: 999px;
background: #fef3c7;
color: #92400e;
font-size: 12px;
font-weight: 800;
}
.automation-meta-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.automation-meta-grid div {
display: grid;
gap: 4px;
min-width: 0;
padding: 12px;
border-radius: 12px;
background: #f8fafc;
border: 1px solid rgba(226, 232, 240, 0.9);
}
.automation-meta-grid span {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.automation-meta-grid strong {
overflow: hidden;
color: #1f2937;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.automation-runs {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 8px;
min-height: 0;
}
.automation-runs h3 {
font-size: 15px;
}
.automation-run-list {
overflow: auto;
min-height: 0;
padding-right: 2px;
}
.automation-run-item {
padding: 12px;
border: 1px solid rgba(226, 232, 240, 0.9);
border-radius: 12px;
background: #ffffff;
}
.automation-run-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: #64748b;
font-size: 12px;
}
.automation-run-error {
color: #b45309 !important;
}
.automation-run-markdown {
margin-top: 8px;
color: #334155;
font-size: 13px;
line-height: 1.5;
}
.automation-run-markdown > * {
margin-top: 6px;
margin-bottom: 0;
}
.automation-run-markdown > :first-child {
margin-top: 0;
}
.automation-run-markdown .markdown-code-block {
margin-top: 8px;
}
.automation-empty {
display: grid;
min-height: 120px;
place-items: center;
color: #64748b;
font-size: 13px;
font-weight: 700;
}
.automation-form {
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
gap: 12px;
padding: 14px;
}
.automation-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
min-width: 0;
overflow: auto;
}
.automation-form-grid label {
display: grid;
gap: 6px;
min-width: 0;
}
.automation-form-grid label > span,
.automation-weekdays > label span,
.automation-enabled-toggle span {
color: #475569;
font-size: 12px;
font-weight: 750;
}
.automation-form-grid input,
.automation-form-grid select,
.automation-form-grid textarea {
width: 100%;
min-width: 0;
border: 1px solid rgba(203, 213, 225, 0.95);
border-radius: 10px;
background: #ffffff;
color: #111827;
font: inherit;
font-size: 13px;
}
.automation-form-grid input,
.automation-form-grid select {
height: 38px;
padding: 0 10px;
}
.automation-form-grid textarea {
resize: vertical;
min-height: 120px;
padding: 10px;
line-height: 1.5;
}
.automation-form-full {
grid-column: 1 / -1;
}
.automation-weekdays {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 6px;
}
.automation-weekdays label,
.automation-enabled-toggle {
display: inline-flex !important;
align-items: center;
justify-content: center;
min-height: 34px;
padding: 0 8px;
border: 1px solid rgba(203, 213, 225, 0.9);
border-radius: 10px;
background: #ffffff;
}
.automation-weekdays input,
.automation-enabled-toggle input {
width: 14px;
height: 14px;
}
.automation-enabled-toggle {
justify-content: flex-start;
gap: 8px !important;
}
@media (max-width: 980px) {
.automation-layout,
.automation-detail-head {
grid-template-columns: 1fr;
}
.automation-meta-grid,
.automation-form-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.automation-header,
.automation-detail-actions {
align-items: stretch;
flex-direction: column;
}
.automation-header-actions,
.automation-detail-actions,
.automation-form-actions {
align-items: stretch;
}
.automation-action-button,
.automation-delete-button {
width: 100%;
}
.automation-task-row {
grid-template-columns: 1fr;
}
.automation-weekdays {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
import test from "node:test"
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
const appSource = readFileSync(new URL("../src/App.tsx", import.meta.url), "utf8")
const sidebarSource = readFileSync(new URL("../src/features/shell/AppSidebar.tsx", import.meta.url), "utf8")
const mockApiSource = readFileSync(new URL("../src/lib/mock-desktop-api.ts", import.meta.url), "utf8")
const desktopApiSource = readFileSync(new URL("../src/lib/desktop-api.ts", import.meta.url), "utf8")
const automationStyles = readFileSync(new URL("../src/styles/automation.css", import.meta.url), "utf8")
test("automation tasks view is available from the sidebar above settings", () => {
assert.match(appSource, /AutomationTasksView/)
assert.match(appSource, /viewMode === "automation"/)
assert.match(sidebarSource, /id: "automation" as const, label: "自动化任务"/)
assert.match(sidebarSource, /id: "settings" as const/)
assert.ok(sidebarSource.indexOf('id: "automation"') < sidebarSource.indexOf('id: "settings"'))
})
test("mock desktop API exposes automation task methods for UI development", () => {
assert.match(desktopApiSource, /mockDesktopApi/)
assert.match(mockApiSource, /automationTasks:\s*\{/)
assert.match(mockApiSource, /listRuns/)
assert.match(mockApiSource, /runNow/)
})
test("automation task destructive and utility actions are explicit", () => {
const viewSource = readFileSync(new URL("../src/features/automation/AutomationTasksView.tsx", import.meta.url), "utf8")
assert.match(viewSource, /确认删除自动化任务/)
assert.match(viewSource, />刷新</)
assert.match(viewSource, />新建任务</)
assert.match(viewSource, />编辑</)
assert.match(viewSource, />立即执行</)
assert.match(viewSource, />删除</)
assert.doesNotMatch(viewSource, /automation-button-toggle/)
assert.doesNotMatch(viewSource, /aria-label="删除自动化任务" onClick=\{\(\) => void deleteTask/)
})
test("automation task view exposes lifecycle text without quick enable and disable controls", () => {
const viewSource = readFileSync(new URL("../src/features/automation/AutomationTasksView.tsx", import.meta.url), "utf8")
assert.doesNotMatch(viewSource, /toggleTaskEnabled/)
assert.doesNotMatch(viewSource, /automationTasks\.update\(task\.id,\s*\{\s*enabled: !task\.enabled\s*\}\)/s)
assert.doesNotMatch(viewSource, /automation-task-row-actions/)
assert.doesNotMatch(viewSource, /automation-row-toggle-button/)
assert.match(viewSource, /已完成,已停用/)
assert.match(viewSource, /已错过,已停用/)
assert.match(viewSource, /已停用/)
})
test("automation task list shows only task title and prompt", () => {
const viewSource = readFileSync(new URL("../src/features/automation/AutomationTasksView.tsx", import.meta.url), "utf8")
assert.match(viewSource, /<strong>\{task\.title\}<\/strong>/)
assert.match(viewSource, /<small>\{task\.prompt\}<\/small>/)
assert.doesNotMatch(viewSource, /下次执行:\{getTaskNextRunLabel\(task\)\}/)
assert.doesNotMatch(viewSource, /<StatusChip tone=\{getTaskLifecycleTone\(task\)\}>\{getTaskLifecycleLabel\(task\)\}<\/StatusChip>/)
})
test("automation task edit can clear the selected expert", () => {
const viewSource = readFileSync(new URL("../src/features/automation/AutomationTasksView.tsx", import.meta.url), "utf8")
assert.match(viewSource, /const expertId = form\.expertId \|\| \(form\.id \? null : undefined\)/)
assert.match(viewSource, /const expertName = form\.expertId \? selectedExpert\?\.label : \(form\.id \? null : undefined\)/)
})
test("automation run replies use shared markdown rendering", () => {
const viewSource = readFileSync(new URL("../src/features/automation/AutomationTasksView.tsx", import.meta.url), "utf8")
assert.match(viewSource, /renderChatMessageContent/)
assert.match(viewSource, /markdown-body automation-run-markdown/)
assert.match(viewSource, /className="automation-run-meta"/)
assert.match(viewSource, /run\.replyText/)
assert.doesNotMatch(viewSource, /\{run\.replyText\}<\/p>/)
assert.match(automationStyles, /\.automation-run-list\s*\{[^}]*overflow:\s*auto/is)
assert.doesNotMatch(automationStyles, /\.automation-run-item\s*>\s*div\s*\{/)
assert.match(automationStyles, /\.automation-detail-pane\s*\{[^}]*grid-template-rows:\s*auto auto minmax\(0,\s*1fr\)/is)
assert.match(automationStyles, /\.automation-runs\s*\{[^}]*grid-template-rows:\s*auto minmax\(0,\s*1fr\)/is)
assert.match(automationStyles, /\.automation-page\s*\{[^}]*display:\s*grid[^}]*grid-template-rows:\s*auto minmax\(0,\s*1fr\)/is)
assert.match(automationStyles, /\.automation-run-meta\s*\{[^}]*display:\s*flex/is)
})
test("run-now action uses WeChat green accent", () => {
assert.match(automationStyles, /\.automation-button-accent\s*\{[^}]*#07C160/is)
})
...@@ -51,7 +51,13 @@ ...@@ -51,7 +51,13 @@
expertsList: "experts:list", expertsList: "experts:list",
modelConfigGetSummary: "model-config:get-summary", modelConfigGetSummary: "model-config:get-summary",
systemGetSummary: "system:get-summary", systemGetSummary: "system:get-summary",
tasksListByDate: "tasks:list-by-date" tasksListByDate: "tasks:list-by-date",
automationTasksList: "automation-tasks:list",
automationTasksCreate: "automation-tasks:create",
automationTasksUpdate: "automation-tasks:update",
automationTasksDelete: "automation-tasks:delete",
automationTasksRunNow: "automation-tasks:run-now",
automationTasksListRuns: "automation-tasks:list-runs"
} as const; } as const;
export type GatewayState = "unknown" | "connecting" | "connected" | "disconnected" | "error"; export type GatewayState = "unknown" | "connecting" | "connected" | "disconnected" | "error";
...@@ -81,6 +87,9 @@ export type SkillDownloadState = "pending" | "downloading" | "ready" | "failed" ...@@ -81,6 +87,9 @@ export type SkillDownloadState = "pending" | "downloading" | "ready" | "failed"
export type ExpertEntryMode = "standalone" | "home-chat-shortcut"; export type ExpertEntryMode = "standalone" | "home-chat-shortcut";
export type DailyReportDeliveryState = "draft" | "sent" | "failed"; export type DailyReportDeliveryState = "draft" | "sent" | "failed";
export type TaskPanelStatus = "pending" | "running" | "completed" | "failed"; export type TaskPanelStatus = "pending" | "running" | "completed" | "failed";
export type AutomationTaskScheduleKind = "once" | "daily" | "weekly";
export type AutomationTaskRunTrigger = "schedule" | "manual" | "startup";
export type AutomationTaskRunStatus = "queued" | "running" | "completed" | "failed" | "missed";
export type ConfigSecretId = export type ConfigSecretId =
| "lobsterKey" | "lobsterKey"
| "copywritingModelApiKey" | "copywritingModelApiKey"
...@@ -905,6 +914,70 @@ export interface TaskPanelItem { ...@@ -905,6 +914,70 @@ export interface TaskPanelItem {
artifacts: TaskPanelArtifact[]; artifacts: TaskPanelArtifact[];
} }
export type AutomationTaskSchedule =
| {
kind: "once";
runAt: string;
}
| {
kind: "daily";
time: string;
}
| {
kind: "weekly";
time: string;
weekdays: number[];
};
export interface AutomationTask {
id: string;
title: string;
prompt: string;
expertId?: string;
expertName?: string;
enabled: boolean;
schedule: AutomationTaskSchedule;
nextRunAt?: string;
lastRunAt?: string;
lastRunStatus?: AutomationTaskRunStatus;
lastRunError?: string;
createdAt: string;
updatedAt: string;
}
export interface AutomationTaskRun {
id: string;
taskId: string;
trigger: AutomationTaskRunTrigger;
status: AutomationTaskRunStatus;
scheduledFor?: string;
startedAt?: string;
completedAt?: string;
sessionId?: string;
runId?: string;
replyText?: string;
error?: string;
artifacts: TaskPanelArtifact[];
}
export interface CreateAutomationTaskInput {
title: string;
prompt: string;
expertId?: string;
expertName?: string;
enabled: boolean;
schedule: AutomationTaskSchedule;
}
export interface UpdateAutomationTaskInput {
title?: string;
prompt?: string;
expertId?: string | null;
expertName?: string | null;
enabled?: boolean;
schedule?: AutomationTaskSchedule;
}
export interface DesktopApi { export interface DesktopApi {
workspace: { workspace: {
getSummary(): Promise<WorkspaceSummary>; getSummary(): Promise<WorkspaceSummary>;
...@@ -978,6 +1051,14 @@ export interface DesktopApi { ...@@ -978,6 +1051,14 @@ export interface DesktopApi {
tasks: { tasks: {
listByDate(date: string): Promise<TaskPanelItem[]>; listByDate(date: string): Promise<TaskPanelItem[]>;
}; };
automationTasks: {
list(): Promise<AutomationTask[]>;
create(input: CreateAutomationTaskInput): Promise<AutomationTask>;
update(taskId: string, input: UpdateAutomationTaskInput): Promise<AutomationTask | null>;
delete(taskId: string): Promise<boolean>;
runNow(taskId: string): Promise<AutomationTaskRun>;
listRuns(taskId?: string): Promise<AutomationTaskRun[]>;
};
chat: { chat: {
listSessions(): Promise<ProjectSessionSummary[]>; listSessions(): Promise<ProjectSessionSummary[]>;
listSessionsByProject(projectId: string): Promise<ProjectSessionSummary[]>; listSessionsByProject(projectId: string): Promise<ProjectSessionSummary[]>;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment