Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
Q
qjclaw-dmg
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
AI-甘富林
qjclaw-dmg
Commits
5f36e0b1
Commit
5f36e0b1
authored
May 19, 2026
by
edy
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat(automation): add scheduled task management
parent
b1d4353e
Changes
29
Hide whitespace changes
Inline
Side-by-side
Showing
29 changed files
with
2183 additions
and
20 deletions
+2183
-20
index.ts
apps/desktop/src/main/index.ts
+5
-1
ipc.ts
apps/desktop/src/main/ipc.ts
+55
-0
automation-task-service.ts
apps/desktop/src/main/services/automation-task-service.ts
+507
-0
index.ts
apps/desktop/src/preload/index.ts
+11
-1
automationTaskIpcSource.test.ts
apps/desktop/test/automationTaskIpcSource.test.ts
+28
-0
automationTaskService.test.ts
apps/desktop/test/automationTaskService.test.ts
+212
-0
App.tsx
apps/ui/src/App.tsx
+5
-1
AppIcons.tsx
apps/ui/src/components/icons/AppIcons.tsx
+9
-1
useAppBootstrap.ts
apps/ui/src/features/app/useAppBootstrap.ts
+1
-1
AutomationTasksView.tsx
apps/ui/src/features/automation/AutomationTasksView.tsx
+540
-0
ChatComposer.tsx
apps/ui/src/features/chat/ChatComposer.tsx
+1
-1
ConversationWorkspaceView.tsx
apps/ui/src/features/chat/ConversationWorkspaceView.tsx
+1
-1
MessageList.tsx
apps/ui/src/features/chat/MessageList.tsx
+1
-1
useChatSessionsController.ts
apps/ui/src/features/chat/useChatSessionsController.ts
+1
-1
useChatStreamingController.ts
apps/ui/src/features/chat/useChatStreamingController.ts
+1
-1
useHomeIntentSuggestion.ts
apps/ui/src/features/chat/useHomeIntentSuggestion.ts
+1
-1
usePromptSubmission.ts
apps/ui/src/features/chat/usePromptSubmission.ts
+1
-1
AppSidebar.tsx
apps/ui/src/features/shell/AppSidebar.tsx
+2
-1
ExpertTree.tsx
apps/ui/src/features/shell/ExpertTree.tsx
+1
-1
useHomeNavigation.ts
apps/ui/src/features/shell/useHomeNavigation.ts
+1
-1
useSidebarModel.ts
apps/ui/src/features/shell/useSidebarModel.ts
+1
-1
useSmokeActionHandlers.ts
apps/ui/src/features/smoke/useSmokeActionHandlers.ts
+1
-1
useSmokeActions.ts
apps/ui/src/features/smoke/useSmokeActions.ts
+1
-1
useSmokeSnapshot.ts
apps/ui/src/features/smoke/useSmokeSnapshot.ts
+1
-1
mock-desktop-api.ts
apps/ui/src/lib/mock-desktop-api.ts
+118
-0
styles.css
apps/ui/src/styles.css
+1
-0
automation.css
apps/ui/src/styles/automation.css
+509
-0
automationTasksSource.test.ts
apps/ui/test/automationTasksSource.test.ts
+85
-0
index.ts
packages/shared-types/src/index.ts
+82
-1
No files found.
apps/desktop/src/main/index.ts
View file @
5f36e0b1
...
@@ -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) {
});
});
}
}
apps/desktop/src/main/ipc.ts
View file @
5f36e0b1
...
@@ -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);
...
...
apps/desktop/src/main/services/automation-task-service.ts
0 → 100644
View file @
5f36e0b1
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
);
}
}
apps/desktop/src/preload/index.ts
View file @
5f36e0b1
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
),
...
...
apps/desktop/test/automationTaskIpcSource.test.ts
0 → 100644
View file @
5f36e0b1
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/
)
})
apps/desktop/test/automationTaskService.test.ts
0 → 100644
View file @
5f36e0b1
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
))
})
apps/ui/src/App.tsx
View file @
5f36e0b1
...
@@ -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
}
/>
...
...
apps/ui/src/components/icons/AppIcons.tsx
View file @
5f36e0b1
...
@@ -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"
>
...
...
apps/ui/src/features/app/useAppBootstrap.ts
View file @
5f36e0b1
...
@@ -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
...
...
apps/ui/src/features/automation/AutomationTasksView.tsx
0 → 100644
View file @
5f36e0b1
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>
)
}
apps/ui/src/features/chat/ChatComposer.tsx
View file @
5f36e0b1
...
@@ -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
>
...
...
apps/ui/src/features/chat/ConversationWorkspaceView.tsx
View file @
5f36e0b1
...
@@ -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
{
...
...
apps/ui/src/features/chat/MessageList.tsx
View file @
5f36e0b1
...
@@ -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"
...
...
apps/ui/src/features/chat/useChatSessionsController.ts
View file @
5f36e0b1
...
@@ -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
...
...
apps/ui/src/features/chat/useChatStreamingController.ts
View file @
5f36e0b1
...
@@ -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
...
...
apps/ui/src/features/chat/useHomeIntentSuggestion.ts
View file @
5f36e0b1
...
@@ -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
...
...
apps/ui/src/features/chat/usePromptSubmission.ts
View file @
5f36e0b1
...
@@ -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
...
...
apps/ui/src/features/shell/AppSidebar.tsx
View file @
5f36e0b1
...
@@ -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
...
...
apps/ui/src/features/shell/ExpertTree.tsx
View file @
5f36e0b1
...
@@ -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
...
...
apps/ui/src/features/shell/useHomeNavigation.ts
View file @
5f36e0b1
...
@@ -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
...
...
apps/ui/src/features/shell/useSidebarModel.ts
View file @
5f36e0b1
...
@@ -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
...
...
apps/ui/src/features/smoke/useSmokeActionHandlers.ts
View file @
5f36e0b1
...
@@ -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
...
...
apps/ui/src/features/smoke/useSmokeActions.ts
View file @
5f36e0b1
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
()
...
...
apps/ui/src/features/smoke/useSmokeSnapshot.ts
View file @
5f36e0b1
...
@@ -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
...
...
apps/ui/src/lib/mock-desktop-api.ts
View file @
5f36e0b1
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),
...
...
apps/ui/src/styles.css
View file @
5f36e0b1
...
@@ -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"
;
apps/ui/src/styles/automation.css
0 → 100644
View file @
5f36e0b1
.automation-page-stack
{
height
:
100%
;
display
:
flex
;
min-height
:
0
;
}
.automation-page
{
flex
:
1
;
display
:
grid
;
grid-template-rows
:
auto
minmax
(
0
,
1
fr
);
min-height
:
0
;
overflow
:
hidden
;
border-radius
:
18px
;
}
.automation-page-body
{
display
:
grid
;
grid-template-rows
:
auto
minmax
(
0
,
1
fr
);
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.8
fr
)
minmax
(
0
,
1.6
fr
);
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
,
1
fr
);
gap
:
10px
;
padding
:
12px
;
}
.automation-filter-row
{
display
:
grid
;
grid-template-columns
:
repeat
(
3
,
minmax
(
0
,
1
fr
));
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
,
1
fr
);
gap
:
12px
;
padding
:
16px
;
overflow
:
hidden
;
}
.automation-detail-head
{
display
:
grid
;
grid-template-columns
:
minmax
(
0
,
1
fr
)
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
,
1
fr
));
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
,
1
fr
);
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
,
1
fr
)
auto
;
gap
:
12px
;
padding
:
14px
;
}
.automation-form-grid
{
display
:
grid
;
grid-template-columns
:
repeat
(
2
,
minmax
(
0
,
1
fr
));
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
,
1
fr
));
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
:
1
fr
;
}
.automation-meta-grid
,
.automation-form-grid
{
grid-template-columns
:
1
fr
;
}
}
@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
:
1
fr
;
}
.automation-weekdays
{
grid-template-columns
:
repeat
(
4
,
minmax
(
0
,
1
fr
));
}
}
apps/ui/test/automationTasksSource.test.ts
0 → 100644
View file @
5f36e0b1
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/i
s
)
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
\)
/i
s
)
assert
.
match
(
automationStyles
,
/
\.
automation-runs
\s
*
\{[^
}
]
*grid-template-rows:
\s
*auto minmax
\(
0,
\s
*1fr
\)
/i
s
)
assert
.
match
(
automationStyles
,
/
\.
automation-page
\s
*
\{[^
}
]
*display:
\s
*grid
[^
}
]
*grid-template-rows:
\s
*auto minmax
\(
0,
\s
*1fr
\)
/i
s
)
assert
.
match
(
automationStyles
,
/
\.
automation-run-meta
\s
*
\{[^
}
]
*display:
\s
*flex/i
s
)
})
test
(
"run-now action uses WeChat green accent"
,
()
=>
{
assert
.
match
(
automationStyles
,
/
\.
automation-button-accent
\s
*
\{[^
}
]
*#07C160/i
s
)
})
packages/shared-types/src/index.ts
View file @
5f36e0b1
...
@@ -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
[]
>
;
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment