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"
import { ProjectSkillRouterService } from "./services/project-skill-router.js";
import { ProjectWorkspaceExecutorService } from "./services/project-workspace-executor.js";
import { TaskPanelService } from "./services/task-panel-service.js";
import { AutomationTaskService } from "./services/automation-task-service.js";
import { StartupLogger } from "./services/startup-logger.js";
interface RendererSmokeState {
......@@ -2303,6 +2304,7 @@ async function bootstrap(): Promise<void> {
await traceBootstrap("runtime-configure-done");
const projectWorkspaceExecutor = new ProjectWorkspaceExecutorService(runtimeManager);
const taskPanelService = new TaskPanelService(systemSummary.userDataPath);
const automationTaskService = new AutomationTaskService(systemSummary.userDataPath, { autoStart: false });
const runtimeStatus = await runtimeManager.status();
const runtimeGatewayConnection = await runtimeManager.getGatewayConnection();
if (systemSummary.isPackaged && runtimeStatus.payloadState !== "ready") {
......@@ -2391,10 +2393,12 @@ async function bootstrap(): Promise<void> {
projectExecutionRouter,
projectWorkspaceExecutor,
taskPanelService,
automationTaskService,
startupLogger: startupLogger!,
systemSummary,
localOpenClawConfig
});
await automationTaskService.start();
let beforeQuitHandled = false;
app.on("before-quit", (event) => {
......@@ -2406,6 +2410,7 @@ async function bootstrap(): Promise<void> {
event.preventDefault();
void (async () => {
await runtimeCloudSupervisor.stop("app-before-quit");
automationTaskService.stop();
await dailyReportService.stop();
await runtimeManager.stop();
await runtimeSkillBridge.clearManagedSkills().catch(() => undefined);
......@@ -2509,4 +2514,3 @@ if (!hasSingleInstanceLock) {
});
}
......@@ -5,6 +5,8 @@ import path from "node:path";
import {
IPC_CHANNELS,
type AppConfig,
type AutomationTaskRun,
type CreateAutomationTaskInput,
type ChatAttachment,
type ChatCancelStreamResult,
type ChatMessage,
......@@ -22,6 +24,7 @@ import {
type SaveConfigInput,
type SignInInput,
type SystemSummary,
type UpdateAutomationTaskInput,
type ProjectExecutionDecision,
type WorkspaceSummary,
type WorkspaceWarmupResult
......@@ -60,6 +63,7 @@ import type { ProjectExecutionRouter } from "./services/project-execution-router
import type { ProjectSkillRouterService } from "./services/project-skill-router.js";
import type { ProjectWorkspaceExecutorService } from "./services/project-workspace-executor.js";
import type { TaskPanelService } from "./services/task-panel-service.js";
import type { AutomationTaskService } from "./services/automation-task-service.js";
import {
buildProjectModelRuntime,
materializeProjectModelRuntime
......@@ -105,6 +109,7 @@ interface MainServices {
projectExecutionRouter: ProjectExecutionRouter;
projectWorkspaceExecutor: ProjectWorkspaceExecutorService;
taskPanelService: TaskPanelService;
automationTaskService: AutomationTaskService;
startupLogger: StartupLogger;
appVersion: string;
systemSummary: SystemSummary;
......@@ -535,6 +540,7 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
projectExecutionRouter,
projectWorkspaceExecutor,
taskPanelService,
automationTaskService,
startupLogger,
systemSummary,
localOpenClawConfig
......@@ -2809,6 +2815,41 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
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.workspaceWarmup, async () => queueWorkspaceWarmup("workspace-warmup", { action: "init" }));
ipcMain.handle(IPC_CHANNELS.windowMinimize, async (event) => {
......@@ -2903,6 +2944,12 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
ipcMain.handle(IPC_CHANNELS.modelConfigGetSummary, async () => modelConfigClient.getSummary());
ipcMain.handle(IPC_CHANNELS.systemGetSummary, async () => systemSummary);
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.projectsList, async () => projectStore.listProjects());
......@@ -3039,6 +3086,14 @@ export function registerDesktopIpc(services: MainServices): RegisteredDesktopIpc
tasks: {
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: {
listSessions: async () => {
const sessions = await listSessionsForActiveProject(projectStore);
......
This diff is collapsed.
import { contextBridge, ipcRenderer } from "electron";
import {
IPC_CHANNELS,
type CreateAutomationTaskInput,
type ChatAttachment,
type ChatStreamListener,
type ConfigSecretId,
type DesktopApi,
type RuntimeCloudFetchAction,
type SaveConfigInput,
type SignInInput
type SignInInput,
type UpdateAutomationTaskInput
} from "@qjclaw/shared-types";
const desktopApi: DesktopApi = {
......@@ -83,6 +85,14 @@ const desktopApi: DesktopApi = {
tasks: {
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: {
listSessions: () => ipcRenderer.invoke(IPC_CHANNELS.chatListSessions),
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";
import { useSessionMessageStore } from "./features/chat/useSessionMessageStore";
import { KnowledgeView } from "./features/knowledge/KnowledgeView";
import { TaskPanelView } from "./features/tasks/TaskPanelView";
import { AutomationTasksView } from "./features/automation/AutomationTasksView";
import { getPluginCopy, getPluginStatusLabel, getPluginTone, groupPluginsByStatus } from "./features/plugins/pluginDisplay";
import { PluginsView } from "./features/plugins/PluginsView";
import { AppSidebar } from "./features/shell/AppSidebar";
......@@ -111,7 +112,7 @@ import {
import { desktopApi, isMockDesktopApi, smokeEnabled } from "./lib/desktop-api";
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 MessageReaction = "up" | "down";
......@@ -1611,6 +1612,9 @@ export default function App() {
{viewMode === "tasks" ? (
<TaskPanelView />
) : null}
{viewMode === "automation" ? (
<AutomationTasksView projects={projects} />
) : null}
{viewMode === "settings" ? (
<SettingsView statusHint={settingsStatusHint}>
<SettingsPanels {...settingsPanelsProps} />
......
......@@ -234,7 +234,7 @@ export function getIntentSuggestionIcon(platform?: string): ReactNode {
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) {
case "chat":
return (
......@@ -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" />
</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":
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
......
......@@ -13,7 +13,7 @@ import type {
WorkspaceSummary
} from "@qjclaw/shared-types"
type ViewMode = "chat" | "experts" | "tasks" | "plugins" | "settings" | "knowledge"
type ViewMode = "chat" | "experts" | "tasks" | "automation" | "plugins" | "settings" | "knowledge"
interface BootstrapSkill {
id: string
......
This diff is collapsed.
......@@ -22,7 +22,7 @@ interface ChatComposerProps {
canSend: boolean
isDragOver: boolean
isResizeActive: boolean
viewMode: "chat" | "experts" | "tasks" | "plugins" | "settings" | "knowledge"
viewMode: "chat" | "experts" | "tasks" | "automation" | "plugins" | "settings" | "knowledge"
shellStyle: CSSProperties
attachmentInputRef: RefObject<HTMLInputElement | null>
skillMenuRef: RefObject<HTMLDivElement | null>
......
......@@ -19,7 +19,7 @@ import { ConversationStatusNotice, HomeIntentSuggestionNotice } from "./Conversa
import { MessageList, type ExpertKey, type MessageListMessage } from "./MessageList"
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"
interface HomeStarterPrompt {
......
......@@ -4,7 +4,7 @@ import { desktopApi } from "../../lib/desktop-api"
import { getTraceDisplayLines, getTraceLineClassName, getTraceStripTitle } from "./messageTraceDisplay"
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"
export type ExpertKey = "xiaohongshu" | "douyin" | "browser" | "general"
......
......@@ -3,7 +3,7 @@ import type { DesktopApi, WorkspaceSummary } from "@qjclaw/shared-types"
import { EMPTY_SESSION_ID, HOME_CHAT_PROJECT_ID } from "../../lib/constants"
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 {
desktopApi: DesktopApi
......
......@@ -6,7 +6,7 @@ import { TYPEWRITER_CHARS_PER_FRAME } from "../../lib/constants"
import { canExchangeMessages } from "../../lib/workspace-state"
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"
interface ActiveStreamState {
requestId: string
......
......@@ -4,7 +4,7 @@ import type { SubmitPromptOptions } from "./useChatStreamingController"
import { resolvePreferredSessionId } from "../../lib/chat-utils"
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 {
suggestion: ProjectIntentSuggestion
......
......@@ -4,7 +4,7 @@ import type { SubmitPromptOptions } from "./useChatStreamingController"
import { HOME_CHAT_PROJECT_ID, HOME_EXPERT_SUGGESTION_PROJECT_IDS } from "../../lib/constants"
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 {
skipHomeIntentSuggestion?: boolean
......
......@@ -4,7 +4,7 @@ import { Sidebar } from "./Sidebar"
import { ExpertTree, type ExpertCategoryId, type ExpertVisualKey, type SidebarExpertEntry } from "./ExpertTree"
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"
interface AppSidebarProps {
......@@ -76,6 +76,7 @@ export function AppSidebar({
{ id: "chat" as const, label: "对话" },
{ id: "tasks" as const, label: "工作台" },
{ id: "knowledge" as const, label: ui.knowledge },
{ id: "automation" as const, label: "自动化任务" },
{ id: "settings" as const, label: ui.settings }
].map((item) => (
<button
......
......@@ -104,7 +104,7 @@ function expertMatchesCategory(entry: SidebarExpertEntry, categoryId: ExpertCate
interface ExpertTreeProps {
entries: SidebarExpertEntry[]
expandedCategories: Record<string, boolean>
viewMode: "chat" | "experts" | "tasks" | "plugins" | "settings" | "knowledge"
viewMode: "chat" | "experts" | "tasks" | "automation" | "plugins" | "settings" | "knowledge"
prompt: string
activeProjectId?: string
onToggleCategory(categoryId: string): void
......
......@@ -3,7 +3,7 @@ import type { DesktopApi, ExpertDefinition, WorkspaceSummary } from "@qjclaw/sha
import { EMPTY_SESSION_ID, HOME_CHAT_PROJECT_ID } from "../../lib/constants"
import type { SidebarExpertEntry } from "./ExpertTree"
type ViewMode = "chat" | "experts" | "tasks" | "plugins" | "settings" | "knowledge"
type ViewMode = "chat" | "experts" | "tasks" | "automation" | "plugins" | "settings" | "knowledge"
interface UseHomeNavigationDeps {
desktopApi: DesktopApi
......
......@@ -14,7 +14,7 @@ import {
buildStandaloneExpertEntries
} from "./expertEntries"
type ViewMode = "chat" | "experts" | "tasks" | "plugins" | "settings" | "knowledge"
type ViewMode = "chat" | "experts" | "tasks" | "automation" | "plugins" | "settings" | "knowledge"
interface UseSidebarModelOptions {
workspace: WorkspaceSummary | null
......
......@@ -4,7 +4,7 @@ import { HOME_CHAT_PROJECT_ID } from "../../lib/constants"
import type { SubmitPromptOptions } from "../chat/useChatStreamingController"
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 SidebarExpertEntry = {
definition: ExpertDefinition
......
import { useEffect } from "react"
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) {
const started = Date.now()
......
......@@ -15,7 +15,7 @@ import type {
import { isMockDesktopApi, smokeEnabled } from "../../lib/desktop-api"
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 {
shellReady: boolean
......
import type {
AutomationTask,
AutomationTaskRun,
ChatAttachment,
ChatStreamEvent,
ChatStreamListener,
CreateAutomationTaskInput,
DesktopApi,
DiagnosticsExportResult,
ExpertDefinition,
ProjectIntentSuggestion,
SaveConfigInput,
UpdateAutomationTaskInput,
WorkspaceSummary
} from "@qjclaw/shared-types"
import {
......@@ -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 {
const normalized = prompt.trim().toLowerCase();
if (!normalized) {
......@@ -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" }) },
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 () => [] },
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: {
listSessions: async () => getMockSessions(),
listSessionsByProject: async (projectId: string) => getMockSessions(projectId),
......
......@@ -6,4 +6,5 @@
@import "./styles/plugins.css";
@import "./styles/knowledge.css";
@import "./styles/tasks.css";
@import "./styles/automation.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 @@
expertsList: "experts:list",
modelConfigGetSummary: "model-config: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;
export type GatewayState = "unknown" | "connecting" | "connected" | "disconnected" | "error";
......@@ -81,6 +87,9 @@ export type SkillDownloadState = "pending" | "downloading" | "ready" | "failed"
export type ExpertEntryMode = "standalone" | "home-chat-shortcut";
export type DailyReportDeliveryState = "draft" | "sent" | "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 =
| "lobsterKey"
| "copywritingModelApiKey"
......@@ -905,6 +914,70 @@ export interface TaskPanelItem {
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 {
workspace: {
getSummary(): Promise<WorkspaceSummary>;
......@@ -978,6 +1051,14 @@ export interface DesktopApi {
tasks: {
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: {
listSessions(): 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