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
143789ee
Commit
143789ee
authored
May 19, 2026
by
edy
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix(ui): guard automation run updates
parent
5f36e0b1
Pipeline
#18475
failed
Changes
6
Pipelines
1
Hide whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
306 additions
and
83 deletions
+306
-83
AppIcons.tsx
apps/ui/src/components/icons/AppIcons.tsx
+17
-0
AutomationTasksView.tsx
apps/ui/src/features/automation/AutomationTasksView.tsx
+82
-35
automation.css
apps/ui/src/styles/automation.css
+64
-6
automationTasksSource.test.ts
apps/ui/test/automationTasksSource.test.ts
+107
-37
index.ts
packages/gateway-client/src/index.ts
+19
-5
chatCancelSource.test.ts
packages/gateway-client/test/chatCancelSource.test.ts
+17
-0
No files found.
apps/ui/src/components/icons/AppIcons.tsx
View file @
143789ee
...
@@ -316,6 +316,23 @@ export function CheckIcon() {
...
@@ -316,6 +316,23 @@ export function CheckIcon() {
);
);
}
}
export
function
PlusIcon
()
{
return
(
<
svg
viewBox=
"0 0 24 24"
fill=
"none"
aria
-
hidden=
"true"
focusable=
"false"
>
<
path
d=
"M12 5v14M5 12h14"
stroke=
"currentColor"
strokeWidth=
"1.9"
strokeLinecap=
"round"
/>
</
svg
>
);
}
export
function
EditIcon
()
{
return
(
<
svg
viewBox=
"0 0 24 24"
fill=
"none"
aria
-
hidden=
"true"
focusable=
"false"
>
<
path
d=
"M5 19h4.4L18.2 10.2a2.2 2.2 0 0 0-3.1-3.1L6.3 15.9 5 19Z"
stroke=
"currentColor"
strokeWidth=
"1.7"
strokeLinecap=
"round"
strokeLinejoin=
"round"
/>
<
path
d=
"m13.7 8.5 3.1 3.1"
stroke=
"currentColor"
strokeWidth=
"1.7"
strokeLinecap=
"round"
/>
</
svg
>
);
}
export
function
TrashIcon
()
{
export
function
TrashIcon
()
{
return
(
return
(
<
svg
viewBox=
"0 0 24 24"
fill=
"none"
aria
-
hidden=
"true"
focusable=
"false"
>
<
svg
viewBox=
"0 0 24 24"
fill=
"none"
aria
-
hidden=
"true"
focusable=
"false"
>
...
...
apps/ui/src/features/automation/AutomationTasksView.tsx
View file @
143789ee
import
{
use
Effect
,
useMemo
,
useState
}
from
"react"
import
{
use
Callback
,
useEffect
,
useMemo
,
useRef
,
useState
}
from
"react"
import
type
{
import
type
{
AutomationTask
,
AutomationTask
,
AutomationTaskRun
,
AutomationTaskRun
,
...
@@ -10,7 +10,7 @@ import { Button } from "../../components/ui/Button"
...
@@ -10,7 +10,7 @@ import { Button } from "../../components/ui/Button"
import
{
Panel
}
from
"../../components/ui/Panel"
import
{
Panel
}
from
"../../components/ui/Panel"
import
{
ScrollArea
}
from
"../../components/ui/ScrollArea"
import
{
ScrollArea
}
from
"../../components/ui/ScrollArea"
import
{
StatusChip
}
from
"../../components/ui/StatusChip"
import
{
StatusChip
}
from
"../../components/ui/StatusChip"
import
{
RefreshIcon
,
TrashIcon
}
from
"../../components/icons/AppIcons"
import
{
EditIcon
,
PlusIcon
,
RefreshIcon
,
TrashIcon
}
from
"../../components/icons/AppIcons"
import
{
desktopApi
}
from
"../../lib/desktop-api"
import
{
desktopApi
}
from
"../../lib/desktop-api"
import
{
HOME_CHAT_PROJECT_ID
}
from
"../../lib/constants"
import
{
HOME_CHAT_PROJECT_ID
}
from
"../../lib/constants"
import
{
renderChatMessageContent
}
from
"../chat/renderChatMessageContent"
import
{
renderChatMessageContent
}
from
"../chat/renderChatMessageContent"
...
@@ -35,6 +35,7 @@ interface AutomationTaskFormState {
...
@@ -35,6 +35,7 @@ interface AutomationTaskFormState {
}
}
const
weekdayLabels
=
[
"日"
,
"一"
,
"二"
,
"三"
,
"四"
,
"五"
,
"六"
]
const
weekdayLabels
=
[
"日"
,
"一"
,
"二"
,
"三"
,
"四"
,
"五"
,
"六"
]
const
activeRunStatuses
=
new
Set
<
AutomationTaskRun
[
"status"
]
>
([
"queued"
,
"running"
])
function
toDateTimeLocalValue
(
date
:
Date
)
{
function
toDateTimeLocalValue
(
date
:
Date
)
{
const
year
=
date
.
getFullYear
()
const
year
=
date
.
getFullYear
()
...
@@ -168,6 +169,10 @@ function getTaskNextRunLabel(task: AutomationTask) {
...
@@ -168,6 +169,10 @@ function getTaskNextRunLabel(task: AutomationTask) {
return
task
.
enabled
?
formatDateTime
(
task
.
nextRunAt
)
:
getTaskLifecycleLabel
(
task
)
return
task
.
enabled
?
formatDateTime
(
task
.
nextRunAt
)
:
getTaskLifecycleLabel
(
task
)
}
}
function
truncateText
(
text
:
string
,
maxLength
:
number
)
{
return
text
.
length
>
maxLength
?
`
${
text
.
slice
(
0
,
maxLength
)}
...`
:
text
}
export
function
AutomationTasksView
({
projects
}:
AutomationTasksViewProps
)
{
export
function
AutomationTasksView
({
projects
}:
AutomationTasksViewProps
)
{
const
[
tasks
,
setTasks
]
=
useState
<
AutomationTask
[]
>
([])
const
[
tasks
,
setTasks
]
=
useState
<
AutomationTask
[]
>
([])
const
[
runs
,
setRuns
]
=
useState
<
AutomationTaskRun
[]
>
([])
const
[
runs
,
setRuns
]
=
useState
<
AutomationTaskRun
[]
>
([])
...
@@ -177,6 +182,8 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
...
@@ -177,6 +182,8 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
const
[
loading
,
setLoading
]
=
useState
(
true
)
const
[
loading
,
setLoading
]
=
useState
(
true
)
const
[
saving
,
setSaving
]
=
useState
(
false
)
const
[
saving
,
setSaving
]
=
useState
(
false
)
const
[
errorText
,
setErrorText
]
=
useState
(
""
)
const
[
errorText
,
setErrorText
]
=
useState
(
""
)
const
[
optimisticRunningTaskIds
,
setOptimisticRunningTaskIds
]
=
useState
<
Set
<
string
>>
(()
=>
new
Set
())
const
selectedTaskIdRef
=
useRef
(
""
)
const
expertOptions
=
useMemo
(()
=>
[
const
expertOptions
=
useMemo
(()
=>
[
{
id
:
""
,
label
:
"通用助手"
},
{
id
:
""
,
label
:
"通用助手"
},
...
@@ -200,7 +207,11 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
...
@@ -200,7 +207,11 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
return
true
return
true
}),
[
filterMode
,
tasks
])
}),
[
filterMode
,
tasks
])
const
loadTasks
=
async
()
=>
{
useEffect
(()
=>
{
selectedTaskIdRef
.
current
=
selectedTask
?.
id
??
""
},
[
selectedTask
?.
id
])
const
loadTasks
=
useCallback
(
async
()
=>
{
setLoading
(
true
)
setLoading
(
true
)
setErrorText
(
""
)
setErrorText
(
""
)
try
{
try
{
...
@@ -212,34 +223,34 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
...
@@ -212,34 +223,34 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
}
finally
{
}
finally
{
setLoading
(
false
)
setLoading
(
false
)
}
}
}
useEffect
(()
=>
{
void
loadTasks
()
},
[])
},
[])
useEffect
(
()
=>
{
const
loadSelectedTaskRuns
=
useCallback
(
async
()
=>
{
let
active
=
true
const
taskId
=
selectedTask
?.
id
if
(
!
selectedTask
?.
i
d
)
{
if
(
!
taskI
d
)
{
setRuns
([])
setRuns
([])
return
return
[]
}
}
void
desktopApi
.
automationTasks
.
listRuns
(
selectedTask
.
id
)
try
{
.
then
((
nextRuns
)
=>
{
const
nextRuns
=
await
desktopApi
.
automationTasks
.
listRuns
(
taskId
)
if
(
active
)
{
if
(
selectedTaskIdRef
.
current
===
taskId
)
{
setRuns
(
nextRuns
)
setRuns
(
nextRuns
)
}
}
})
return
nextRuns
.
catch
((
error
)
=>
{
}
catch
(
error
)
{
if
(
active
)
{
setErrorText
(
error
instanceof
Error
?
error
.
message
:
"运行记录加载失败"
)
setErrorText
(
error
instanceof
Error
?
error
.
message
:
"运行记录加载失败"
)
return
[]
}
})
return
()
=>
{
active
=
false
}
}
},
[
selectedTask
?.
id
])
},
[
selectedTask
?.
id
])
useEffect
(()
=>
{
void
loadTasks
()
},
[
loadTasks
])
useEffect
(()
=>
{
void
loadSelectedTaskRuns
()
},
[
loadSelectedTaskRuns
])
const
startCreate
=
()
=>
{
const
startCreate
=
()
=>
{
setForm
(
createDefaultForm
(
projects
))
setForm
(
createDefaultForm
(
projects
))
}
}
...
@@ -316,15 +327,23 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
...
@@ -316,15 +327,23 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
}
}
const runTaskNow = async (taskId: string) => {
const runTaskNow = async (taskId: string) => {
setOptimisticRunningTaskIds((current) => new Set(current).add(taskId))
setSaving(true)
setSaving(true)
setErrorText("")
setErrorText("")
try {
try {
const run = await desktopApi.automationTasks.runNow(taskId)
const run = await desktopApi.automationTasks.runNow(taskId)
setRuns((current) => [run, ...current.filter((item) => item.id !== run.id)])
if (selectedTaskIdRef.current === taskId) {
setRuns((current) => [run, ...current.filter((item) => item.id !== run.id)])
}
await loadTasks()
await loadTasks()
} catch (error) {
} catch (error) {
setErrorText(error instanceof Error ? error.message : "立即执行失败")
setErrorText(error instanceof Error ? error.message : "立即执行失败")
} finally {
} finally {
setOptimisticRunningTaskIds((current) => {
const next = new Set(current)
next.delete(taskId)
return next
})
setSaving(false)
setSaving(false)
}
}
}
}
...
@@ -333,6 +352,24 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
...
@@ -333,6 +352,24 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
await navigator.clipboard?.writeText(text)
await navigator.clipboard?.writeText(text)
}
}
const hasActiveSelectedRun = runs.some((run) => run.taskId === selectedTask?.id && activeRunStatuses.has(run.status))
const hasOptimisticSelectedRun = optimisticRunningTaskIds.has(selectedTask?.id ?? "")
const isRunningSelectedTask = hasActiveSelectedRun || hasOptimisticSelectedRun
const shouldPollSelectedRuns = hasActiveSelectedRun || hasOptimisticSelectedRun
useEffect(() => {
if (!shouldPollSelectedRuns) {
return
}
const pollTimer = window.setInterval(() => {
void loadSelectedTaskRuns()
void loadTasks()
}, 2000)
return () => {
window.clearInterval(pollTimer)
}
}, [shouldPollSelectedRuns, loadSelectedTaskRuns, loadTasks])
return (
return (
<div className="automation-page-stack">
<div className="automation-page-stack">
<Panel
<Panel
...
@@ -342,14 +379,16 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
...
@@ -342,14 +379,16 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
<div className="automation-header">
<div className="automation-header">
<div>
<div>
<h1>自动化任务</h1>
<h1>自动化任务</h1>
<p>应用运行时执行,错过的计划会保留为记录。</p>
</div>
</div>
<div className="automation-header-actions">
<div className="automation-header-actions">
<Button variant="secondary" size="sm" className="automation-action-button automation-button-secondary" onClick={() => void loadTasks()} disabled={loading || saving}>
<Button variant="secondary" size="sm" className="automation-action-button automation-button-secondary" onClick={() => void loadTasks()} disabled={loading || saving}>
<RefreshIcon />
<RefreshIcon />
<span>刷新</span>
<span>刷新</span>
</Button>
</Button>
<Button size="sm" className="automation-action-button automation-button-primary" onClick={startCreate}>新建任务</Button>
<Button size="sm" className="automation-action-button automation-button-primary" onClick={startCreate}>
<PlusIcon />
<span>新建任务</span>
</Button>
</div>
</div>
</div>
</div>
)}
)}
...
@@ -387,8 +426,8 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
...
@@ -387,8 +426,8 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
className="automation-task-select"
className="automation-task-select"
onClick={() => setSelectedTaskId(task.id)}
onClick={() => setSelectedTaskId(task.id)}
>
>
<strong>{t
ask.title
}</strong>
<strong>{t
runcateText(task.title, 20)
}</strong>
<small>{t
ask.prompt
}</small>
<small>{t
runcateText(task.prompt, 20)
}</small>
</button>
</button>
</article>
</article>
)) : (
)) : (
...
@@ -461,8 +500,8 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
...
@@ -461,8 +500,8 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
</label>
</label>
</div>
</div>
<div className="automation-form-actions">
<div className="automation-form-actions">
<Button variant="secondary" size="sm" onClick={() => setForm(null)} disabled={saving}>取消</Button>
<Button variant="secondary" size="sm"
className="automation-form-button-secondary"
onClick={() => setForm(null)} disabled={saving}>取消</Button>
<Button size="sm" onClick={() => void submitForm()} disabled={saving}>{saving ? "保存中" : "保存"}</Button>
<Button size="sm"
className="automation-form-button-primary"
onClick={() => void submitForm()} disabled={saving}>{saving ? "保存中" : "保存"}</Button>
</div>
</div>
</div>
</div>
) : selectedTask ? (
) : selectedTask ? (
...
@@ -471,12 +510,20 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
...
@@ -471,12 +510,20 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
<div>
<div>
<span className="automation-kicker">{selectedTask.expertName || "通用助手"}</span>
<span className="automation-kicker">{selectedTask.expertName || "通用助手"}</span>
<h2>{selectedTask.title}</h2>
<h2>{selectedTask.title}</h2>
<p className="automation-detail-prompt">{
selectedTask.prompt
}</p>
<p className="automation-detail-prompt">{
truncateText(selectedTask.prompt, 30)
}</p>
</div>
</div>
<div className="automation-detail-actions">
<div className="automation-detail-actions">
<Button variant="secondary" size="sm" className="automation-action-button automation-button-secondary" onClick={() => startEdit(selectedTask)} disabled={saving}>编辑</Button>
<Button variant="secondary" size="sm" className="automation-action-button automation-button-secondary" onClick={() => startEdit(selectedTask)} disabled={saving}>
<Button size="sm" className="automation-action-button automation-button-accent" onClick={() => void runTaskNow(selectedTask.id)} disabled={saving}>
<EditIcon />
{saving ? <span>执行中</span> : <span>立即执行</span>}
<span>编辑</span>
</Button>
<Button size="sm" className="automation-action-button automation-button-accent" onClick={() => void runTaskNow(selectedTask.id)} disabled={saving || isRunningSelectedTask}>
{isRunningSelectedTask ? (
<>
<span className="automation-button-spinner" aria-hidden="true" />
<span>执行中</span>
</>
) : <span>立即执行</span>}
</Button>
</Button>
<button
<button
type="button"
type="button"
...
...
apps/ui/src/styles/automation.css
View file @
143789ee
...
@@ -57,6 +57,28 @@
...
@@ -57,6 +57,28 @@
height
:
15px
;
height
:
15px
;
}
}
.automation-button-spinner
{
width
:
14px
;
height
:
14px
;
flex
:
0
0
14px
;
border
:
2px
solid
rgba
(
255
,
255
,
255
,
0.46
);
border-top-color
:
#ffffff
;
border-radius
:
999px
;
animation
:
automation-spin
0.8s
linear
infinite
;
}
@keyframes
automation-spin
{
to
{
transform
:
rotate
(
360deg
);
}
}
@media
(
prefers-reduced-motion
:
reduce
)
{
.automation-button-spinner
{
animation
:
none
;
}
}
.automation-button-primary
{
.automation-button-primary
{
border
:
1px
solid
rgba
(
37
,
99
,
235
,
0.22
);
border
:
1px
solid
rgba
(
37
,
99
,
235
,
0.22
);
background
:
#2563eb
;
background
:
#2563eb
;
...
@@ -82,6 +104,34 @@
...
@@ -82,6 +104,34 @@
color
:
#1d4ed8
;
color
:
#1d4ed8
;
}
}
.automation-form-actions
.automation-form-button-secondary
{
border
:
1px
solid
rgba
(
203
,
213
,
225
,
0.92
);
background
:
#ffffff
;
color
:
#1f2937
;
box-shadow
:
none
;
}
.automation-form-actions
.automation-form-button-secondary
:hover:not
(
:disabled
),
.automation-form-actions
.automation-form-button-secondary
:focus-visible
{
border-color
:
rgba
(
147
,
197
,
253
,
0.95
);
background
:
#eff6ff
;
color
:
#1d4ed8
;
}
.automation-form-actions
.automation-form-button-primary
{
border
:
1px
solid
#1677FF
;
background
:
#1677FF
;
color
:
#ffffff
;
box-shadow
:
0
8px
18px
rgba
(
22
,
119
,
255
,
0.18
);
}
.automation-form-actions
.automation-form-button-primary
:hover:not
(
:disabled
),
.automation-form-actions
.automation-form-button-primary
:focus-visible
{
border-color
:
#0F63D8
;
background
:
#0F63D8
;
color
:
#ffffff
;
}
.automation-button-accent
{
.automation-button-accent
{
border
:
1px
solid
rgba
(
7
,
193
,
96
,
0.42
);
border
:
1px
solid
rgba
(
7
,
193
,
96
,
0.42
);
background
:
#07C160
;
background
:
#07C160
;
...
@@ -180,6 +230,14 @@
...
@@ -180,6 +230,14 @@
.automation-list-scroll
{
.automation-list-scroll
{
min-height
:
0
;
min-height
:
0
;
height
:
100%
;
overflow
:
hidden
;
}
.automation-list-scroll
.scroll-area-content
{
height
:
100%
;
min-height
:
0
;
overflow-y
:
auto
;
}
}
.automation-task-list
,
.automation-task-list
,
...
@@ -189,7 +247,8 @@
...
@@ -189,7 +247,8 @@
}
}
.automation-task-row
{
.automation-task-row
{
min-height
:
70px
;
height
:
84px
;
overflow
:
hidden
;
padding
:
12px
;
padding
:
12px
;
border
:
1px
solid
rgba
(
226
,
232
,
240
,
0.9
);
border
:
1px
solid
rgba
(
226
,
232
,
240
,
0.9
);
border-radius
:
12px
;
border-radius
:
12px
;
...
@@ -205,9 +264,9 @@
...
@@ -205,9 +264,9 @@
.automation-task-select
{
.automation-task-select
{
display
:
grid
;
display
:
grid
;
gap
:
6
px
;
gap
:
5
px
;
width
:
100%
;
width
:
100%
;
min-height
:
46px
;
height
:
100%
;
min-width
:
0
;
min-width
:
0
;
border
:
0
;
border
:
0
;
background
:
transparent
;
background
:
transparent
;
...
@@ -234,12 +293,11 @@
...
@@ -234,12 +293,11 @@
}
}
.automation-task-select
small
{
.automation-task-select
small
{
display
:
-webkit-box
;
-webkit-box-orient
:
vertical
;
-webkit-line-clamp
:
2
;
color
:
#64748b
;
color
:
#64748b
;
font-size
:
12px
;
font-size
:
12px
;
line-height
:
1.35
;
line-height
:
1.35
;
text-overflow
:
ellipsis
;
white-space
:
nowrap
;
}
}
.automation-detail-pane
{
.automation-detail-pane
{
...
...
apps/ui/test/automationTasksSource.test.ts
View file @
143789ee
...
@@ -7,6 +7,8 @@ const sidebarSource = readFileSync(new URL("../src/features/shell/AppSidebar.tsx
...
@@ -7,6 +7,8 @@ const sidebarSource = readFileSync(new URL("../src/features/shell/AppSidebar.tsx
const
mockApiSource
=
readFileSync
(
new
URL
(
"../src/lib/mock-desktop-api.ts"
,
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
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"
)
const
automationStyles
=
readFileSync
(
new
URL
(
"../src/styles/automation.css"
,
import
.
meta
.
url
),
"utf8"
)
const
automationViewSource
=
readFileSync
(
new
URL
(
"../src/features/automation/AutomationTasksView.tsx"
,
import
.
meta
.
url
),
"utf8"
)
const
appIconsSource
=
readFileSync
(
new
URL
(
"../src/components/icons/AppIcons.tsx"
,
import
.
meta
.
url
),
"utf8"
)
test
(
"automation tasks view is available from the sidebar above settings"
,
()
=>
{
test
(
"automation tasks view is available from the sidebar above settings"
,
()
=>
{
assert
.
match
(
appSource
,
/AutomationTasksView/
)
assert
.
match
(
appSource
,
/AutomationTasksView/
)
...
@@ -24,54 +26,47 @@ test("mock desktop API exposes automation task methods for UI development", () =
...
@@ -24,54 +26,47 @@ test("mock desktop API exposes automation task methods for UI development", () =
})
})
test
(
"automation task destructive and utility actions are explicit"
,
()
=>
{
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
(
automationViewSource
,
/确认删除自动化任务/
)
assert
.
match
(
automationViewSource
,
/>刷新</
)
assert
.
match
(
viewSource
,
/确认删除自动化任务/
)
assert
.
match
(
automationViewSource
,
/>新建任务</
)
assert
.
match
(
viewSource
,
/>刷新</
)
assert
.
match
(
automationViewSource
,
/>编辑</
)
assert
.
match
(
viewSource
,
/>新建任务</
)
assert
.
match
(
automationViewSource
,
/>立即执行</
)
assert
.
match
(
viewSource
,
/>编辑</
)
assert
.
match
(
automationViewSource
,
/>删除</
)
assert
.
match
(
viewSource
,
/>立即执行</
)
assert
.
doesNotMatch
(
automationViewSource
,
/automation-button-toggle/
)
assert
.
match
(
viewSource
,
/>删除</
)
assert
.
doesNotMatch
(
automationViewSource
,
/aria-label="删除自动化任务" onClick=
\{\(\)
=> void deleteTask/
)
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"
,
()
=>
{
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
(
automationViewSource
,
/toggleTaskEnabled/
)
assert
.
doesNotMatch
(
automationViewSource
,
/automationTasks
\.
update
\(
task
\.
id,
\s
*
\{\s
*enabled: !task
\.
enabled
\s
*
\}\)
/
s
)
assert
.
doesNotMatch
(
viewSource
,
/toggleTaskEnabled/
)
assert
.
doesNotMatch
(
automationViewSource
,
/automation-task-row-actions/
)
assert
.
doesNotMatch
(
viewSource
,
/automationTasks
\.
update
\(
task
\.
id,
\s
*
\{\s
*enabled: !task
\.
enabled
\s
*
\}\)
/
s
)
assert
.
doesNotMatch
(
automationViewSource
,
/automation-row-toggle-button/
)
assert
.
doesNotMatch
(
viewSource
,
/automation-task-row-actions/
)
assert
.
match
(
automationViewSource
,
/已完成,已停用/
)
assert
.
doesNotMatch
(
viewSource
,
/automation-row-toggle-button/
)
assert
.
match
(
automationViewSource
,
/已错过,已停用/
)
assert
.
match
(
viewSource
,
/已完成,已停用/
)
assert
.
match
(
automationViewSource
,
/已停用/
)
assert
.
match
(
viewSource
,
/已错过,已停用/
)
assert
.
match
(
viewSource
,
/已停用/
)
})
})
test
(
"automation task list shows only task title and prompt"
,
()
=>
{
test
(
"automation task list truncates title and prompt source text"
,
()
=>
{
const
viewSource
=
readFileSync
(
new
URL
(
"../src/features/automation/AutomationTasksView.tsx"
,
import
.
meta
.
url
),
"utf8"
)
assert
.
match
(
automationViewSource
,
/function truncateText
\(
text: string, maxLength: number
\)
/
)
assert
.
match
(
automationViewSource
,
/return text
\.
length > maxLength
\?
`
\$\{
text
\.
slice
\(
0, maxLength
\)\}\.\.\.
` : text/
)
assert
.
match
(
viewSource
,
/<strong>
\{
task
\.
title
\}
<
\/
strong>/
)
assert
.
match
(
automationViewSource
,
/<strong>
\{
truncateText
\(
task
\.
title,
\s
*20
\)\}
<
\/
strong>/
)
assert
.
match
(
viewSource
,
/<small>
\{
task
\.
prompt
\}
<
\/
small>/
)
assert
.
match
(
automationViewSource
,
/<small>
\{
truncateText
\(
task
\.
prompt,
\s
*20
\)\}
<
\/
small>/
)
assert
.
doesNotMatch
(
viewSource
,
/下次执行:
\{
getTaskNextRunLabel
\(
task
\)\}
/
)
assert
.
match
(
automationViewSource
,
/<p className="automation-detail-prompt">
\{
truncateText
\(
selectedTask
\.
prompt,
\s
*30
\)\}
<
\/
p>/
)
assert
.
doesNotMatch
(
viewSource
,
/<StatusChip tone=
\{
getTaskLifecycleTone
\(
task
\)\}
>
\{
getTaskLifecycleLabel
\(
task
\)\}
<
\/
StatusChip>/
)
assert
.
doesNotMatch
(
automationViewSource
,
/下次执行:
\{
getTaskNextRunLabel
\(
task
\)\}
/
)
assert
.
doesNotMatch
(
automationViewSource
,
/<StatusChip tone=
\{
getTaskLifecycleTone
\(
task
\)\}
>
\{
getTaskLifecycleLabel
\(
task
\)\}
<
\/
StatusChip>/
)
})
})
test
(
"automation task edit can clear the selected expert"
,
()
=>
{
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
(
automationViewSource
,
/const expertId = form
\.
expertId
\|\|
\(
form
\.
id
\?
null : undefined
\)
/
)
assert
.
match
(
automationViewSource
,
/const expertName = form
\.
expertId
\?
selectedExpert
\?\.
label :
\(
form
\.
id
\?
null : undefined
\)
/
)
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"
,
()
=>
{
test
(
"automation run replies use shared markdown rendering"
,
()
=>
{
const
viewSource
=
readFileSync
(
new
URL
(
"../src/features/automation/AutomationTasksView.tsx"
,
import
.
meta
.
url
),
"utf8"
)
assert
.
match
(
automationViewSource
,
/renderChatMessageContent/
)
assert
.
match
(
automationViewSource
,
/markdown-body automation-run-markdown/
)
assert
.
match
(
viewSource
,
/renderChatMessageContent/
)
assert
.
match
(
automationViewSource
,
/className="automation-run-meta"/
)
assert
.
match
(
viewSource
,
/markdown-body automation-run-markdown/
)
assert
.
match
(
automationViewSource
,
/run
\.
replyText/
)
assert
.
match
(
viewSource
,
/className="automation-run-meta"/
)
assert
.
doesNotMatch
(
automationViewSource
,
/
\{
run
\.
replyText
\}
<
\/
p>/
)
assert
.
match
(
viewSource
,
/run
\.
replyText/
)
assert
.
doesNotMatch
(
viewSource
,
/
\{
run
\.
replyText
\}
<
\/
p>/
)
assert
.
match
(
automationStyles
,
/
\.
automation-run-list
\s
*
\{[^
}
]
*overflow:
\s
*auto/i
s
)
assert
.
match
(
automationStyles
,
/
\.
automation-run-list
\s
*
\{[^
}
]
*overflow:
\s
*auto/i
s
)
assert
.
doesNotMatch
(
automationStyles
,
/
\.
automation-run-item
\s
*>
\s
*div
\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-detail-pane
\s
*
\{[^
}
]
*grid-template-rows:
\s
*auto auto minmax
\(
0,
\s
*1fr
\)
/i
s
)
...
@@ -83,3 +78,78 @@ test("automation run replies use shared markdown rendering", () => {
...
@@ -83,3 +78,78 @@ test("automation run replies use shared markdown rendering", () => {
test
(
"run-now action uses WeChat green accent"
,
()
=>
{
test
(
"run-now action uses WeChat green accent"
,
()
=>
{
assert
.
match
(
automationStyles
,
/
\.
automation-button-accent
\s
*
\{[^
}
]
*#07C160/i
s
)
assert
.
match
(
automationStyles
,
/
\.
automation-button-accent
\s
*
\{[^
}
]
*#07C160/i
s
)
})
})
test
(
"automation task form actions use scoped DingTalk-style button colors"
,
()
=>
{
assert
.
match
(
automationViewSource
,
/<Button
[^]
*className="automation-form-button-secondary"
[^]
*>取消<
\/
Button>/
)
assert
.
match
(
automationViewSource
,
/<Button
[^]
*className="automation-form-button-primary"
[^]
*>
\{
saving
\?
"保存中" : "保存"
\}
<
\/
Button>/
)
assert
.
match
(
automationStyles
,
/
\.
automation-form-button-secondary
\s
*
\{[^
}
]
*background:
\s
*#ffffff/i
s
)
assert
.
match
(
automationStyles
,
/
\.
automation-form-button-primary
\s
*
\{[^
}
]
*background:
\s
*#1677FF/i
s
)
assert
.
match
(
automationStyles
,
/
\.
automation-form-button-primary:hover:not
\(
:disabled
\)[^
{
]
*
\{[^
}
]
*background:
\s
*#0F63D8/i
s
)
})
test
(
"automation task action buttons use svg icons"
,
()
=>
{
assert
.
match
(
appIconsSource
,
/export function PlusIcon
\(\)
/
)
assert
.
match
(
appIconsSource
,
/export function EditIcon
\(\)
/
)
assert
.
match
(
automationViewSource
,
/import
\{
EditIcon, PlusIcon, RefreshIcon, TrashIcon
\}
from/
)
assert
.
match
(
automationViewSource
,
/<PlusIcon
\/
>
\s
*<span>新建任务<
\/
span>/
)
assert
.
match
(
automationViewSource
,
/<EditIcon
\/
>
\s
*<span>编辑<
\/
span>/
)
})
test
(
"automation task header omits the runtime scheduling helper copy"
,
()
=>
{
assert
.
doesNotMatch
(
automationViewSource
,
/应用运行时执行,错过的计划会保留为记录。/
)
})
test
(
"automation task list scrolls inside the left pane"
,
()
=>
{
assert
.
match
(
automationStyles
,
/
\.
automation-list-pane
\s
*
\{[^
}
]
*grid-template-rows:
\s
*auto minmax
\(
0,
\s
*1fr
\)
/i
s
)
assert
.
match
(
automationStyles
,
/
\.
automation-list-scroll
\s
*
\{[^
}
]
*min-height:
\s
*0
[^
}
]
*overflow:
\s
*hidden/i
s
)
assert
.
match
(
automationStyles
,
/
\.
automation-list-scroll
\.
scroll-area-content
\s
*
\{[^
}
]
*height:
\s
*100%
[^
}
]
*min-height:
\s
*0
[^
}
]
*overflow-y:
\s
*auto/i
s
)
})
test
(
"automation task rows use a fixed compact height"
,
()
=>
{
assert
.
match
(
automationStyles
,
/
\.
automation-task-row
\s
*
\{[^
}
]
*height:
\s
*84px/i
s
)
assert
.
match
(
automationStyles
,
/
\.
automation-task-row
\s
*
\{[^
}
]
*overflow:
\s
*hidden/i
s
)
assert
.
match
(
automationStyles
,
/
\.
automation-task-select
\s
*
\{[^
}
]
*gap:
\s
*5px/i
s
)
assert
.
match
(
automationStyles
,
/
\.
automation-task-select strong
\s
*
\{[^
}
]
*text-overflow:
\s
*ellipsis
[^
}
]
*white-space:
\s
*nowrap/i
s
)
assert
.
match
(
automationStyles
,
/
\.
automation-task-select small
\s
*
\{[^
}
]
*text-overflow:
\s
*ellipsis
[^
}
]
*white-space:
\s
*nowrap/i
s
)
})
test
(
"run-now button derives selected task running state from optimistic state or active run records"
,
()
=>
{
assert
.
match
(
automationViewSource
,
/const activeRunStatuses = new Set<AutomationTaskRun
\[
"status"
\]
>
\(\[
"queued", "running"
\]\)
/
)
assert
.
match
(
automationViewSource
,
/const
\[
optimisticRunningTaskIds,
\s
*setOptimisticRunningTaskIds
\]
= useState<Set<string>>
\(\(\)
=> new Set
\(\)\)
/
)
assert
.
doesNotMatch
(
automationViewSource
,
/const
\[
runningTaskId,
\s
*setRunningTaskId
\]
/
)
assert
.
doesNotMatch
(
automationViewSource
,
/setRunningTaskId/
)
assert
.
match
(
automationViewSource
,
/setOptimisticRunningTaskIds
\(\(
current
\)
=> new Set
\(
current
\)\.
add
\(
taskId
\)\)
/
)
assert
.
match
(
automationViewSource
,
/next
\.
delete
\(
taskId
\)
/
)
assert
.
match
(
automationViewSource
,
/const hasActiveSelectedRun = runs
\.
some
\(\(
run
\)
=> run
\.
taskId === selectedTask
\?\.
id && activeRunStatuses
\.
has
\(
run
\.
status
\)\)
/
)
assert
.
match
(
automationViewSource
,
/const hasOptimisticSelectedRun = optimisticRunningTaskIds
\.
has
\(
selectedTask
\?\.
id
\?\?
""
\)
/
)
assert
.
match
(
automationViewSource
,
/const isRunningSelectedTask = hasActiveSelectedRun
\|\|
hasOptimisticSelectedRun/
)
assert
.
match
(
automationViewSource
,
/<span className="automation-button-spinner" aria-hidden="true"
\/
>
\s
*<span>执行中<
\/
span>/
)
assert
.
match
(
automationViewSource
,
/disabled=
\{
saving
\|\|
isRunningSelectedTask
\}
/
)
})
test
(
"automation task runs poll while a selected task has optimistic state or active run records"
,
()
=>
{
assert
.
match
(
automationViewSource
,
/const loadSelectedTaskRuns = useCallback/
)
assert
.
match
(
automationViewSource
,
/const selectedTaskIdRef = useRef
\(
""
\)
/
)
assert
.
match
(
automationViewSource
,
/const taskId = selectedTask
\?\.
id/
)
assert
.
match
(
automationViewSource
,
/desktopApi
\.
automationTasks
\.
listRuns
\(
taskId
\)
/
)
assert
.
match
(
automationViewSource
,
/if
\(
selectedTaskIdRef
\.
current === taskId
\)
\{\s
*setRuns
\(
nextRuns
\)\s
*
\}
/
s
)
assert
.
match
(
automationViewSource
,
/void loadSelectedTaskRuns
\(\)
/
)
assert
.
match
(
automationViewSource
,
/const hasActiveSelectedRun = runs
\.
some
\(\(
run
\)
=> run
\.
taskId === selectedTask
\?\.
id && activeRunStatuses
\.
has
\(
run
\.
status
\)\)
/
)
assert
.
match
(
automationViewSource
,
/const shouldPollSelectedRuns = hasActiveSelectedRun
\|\|
hasOptimisticSelectedRun/
)
assert
.
match
(
automationViewSource
,
/if
\(
!shouldPollSelectedRuns
\)
\{
/
)
assert
.
match
(
automationViewSource
,
/window
\.
setInterval
\(\(\)
=>
\{[^
}
]
*void loadSelectedTaskRuns
\(\)[^
}
]
*void loadTasks
\(\)[^
}
]
*
\}
,
\s
*2000
\)
/
s
)
assert
.
match
(
automationViewSource
,
/window
\.
clearInterval
\(
pollTimer
\)
/
)
})
test
(
"run-now only prepends runs while the same task remains selected"
,
()
=>
{
assert
.
match
(
automationViewSource
,
/const runTaskNow = async
\(
taskId: string
\)
=>
\{
/
)
assert
.
match
(
automationViewSource
,
/const run = await desktopApi
\.
automationTasks
\.
runNow
\(
taskId
\)
/
)
assert
.
match
(
automationViewSource
,
/if
\(
selectedTaskIdRef
\.
current === taskId
\)
\{\s
*setRuns
\(\(
current
\)
=>
\[
run,
\.\.\.
current
\.
filter
\(\(
item
\)
=> item
\.
id !== run
\.
id
\)\]\)\s
*
\}
/
s
)
assert
.
match
(
automationViewSource
,
/await loadTasks
\(\)
/
)
})
test
(
"run-now spinner has accessible reduced-motion styling"
,
()
=>
{
assert
.
match
(
automationStyles
,
/
\.
automation-button-spinner
\s
*
\{[^
}
]
*border-radius:
\s
*999px
[^
}
]
*animation:
\s
*automation-spin 0
\.
8s linear infinite/i
s
)
assert
.
match
(
automationStyles
,
/@keyframes automation-spin
\s
*
\{[^
}
]
*transform:
\s
*rotate
\(
360deg
\)
/i
s
)
assert
.
match
(
automationStyles
,
/@media
\(
prefers-reduced-motion:
\s
*reduce
\)\s
*
\{[^
}
]
*
\.
automation-button-spinner
\s
*
\{[^
}
]
*animation:
\s
*none/i
s
)
})
packages/gateway-client/src/index.ts
View file @
143789ee
...
@@ -784,12 +784,11 @@ export class GatewayClient {
...
@@ -784,12 +784,11 @@ export class GatewayClient {
}
}
private
async
resolveCompletedChatReply
(
runId
:
string
,
payload
:
Record
<
string
,
unknown
>
):
Promise
<
ChatMessage
>
{
private
async
resolveCompletedChatReply
(
runId
:
string
,
payload
:
Record
<
string
,
unknown
>
):
Promise
<
ChatMessage
>
{
const
reply
=
this
.
buildChatMessage
(
runId
,
payload
);
const
pending
=
this
.
pendingChatRuns
.
get
(
runId
);
const
reply
=
this
.
buildChatMessage
(
runId
,
payload
,
""
);
if
(
reply
.
content
.
trim
())
{
if
(
reply
.
content
.
trim
())
{
return
reply
;
return
reply
;
}
}
const
pending
=
this
.
pendingChatRuns
.
get
(
runId
);
if
(
!
pending
)
{
if
(
!
pending
)
{
return
reply
;
return
reply
;
}
}
...
@@ -803,6 +802,10 @@ export class GatewayClient {
...
@@ -803,6 +802,10 @@ export class GatewayClient {
}
catch
{
}
catch
{
}
}
if
(
pending
?.
accumulatedText
.
trim
())
{
return
this
.
buildAccumulatedChatMessage
(
pending
.
sessionKey
,
runId
,
pending
.
accumulatedText
);
}
return
reply
;
return
reply
;
}
}
...
@@ -876,10 +879,12 @@ export class GatewayClient {
...
@@ -876,10 +879,12 @@ export class GatewayClient {
pending
.
reject
(
error
);
pending
.
reject
(
error
);
}
}
private
buildChatMessage
(
runId
:
string
,
payload
:
Record
<
string
,
unknown
>
):
ChatMessage
{
private
buildChatMessage
(
runId
:
string
,
payload
:
Record
<
string
,
unknown
>
,
fallbackContent
?:
string
):
ChatMessage
{
const
pending
=
this
.
pendingChatRuns
.
get
(
runId
);
const
pending
=
this
.
pendingChatRuns
.
get
(
runId
);
const
message
=
this
.
findRecordDeep
(
payload
,
[
"message"
]);
const
message
=
this
.
findRecordDeep
(
payload
,
[
"message"
]);
const
content
=
this
.
extractTextCandidate
(
message
)
??
pending
?.
accumulatedText
??
""
;
const
completedContent
=
this
.
extractTextCandidate
(
message
);
const
fallbackText
=
fallbackContent
??
pending
?.
accumulatedText
??
""
;
const
content
=
completedContent
?.
trim
()
?
completedContent
:
fallbackText
;
const
timestamp
=
this
.
findNumberDeep
(
message
??
payload
,
[
"timestamp"
,
"createdAt"
,
"created_at"
]);
const
timestamp
=
this
.
findNumberDeep
(
message
??
payload
,
[
"timestamp"
,
"createdAt"
,
"created_at"
]);
return
{
return
{
id
:
`
${
pending
?.
sessionKey
??
"session"
}:
$
{
runId
}:
final
`,
id
:
`
${
pending
?.
sessionKey
??
"session"
}:
$
{
runId
}:
final
`,
...
@@ -890,6 +895,15 @@ export class GatewayClient {
...
@@ -890,6 +895,15 @@ export class GatewayClient {
};
};
}
}
private buildAccumulatedChatMessage(sessionKey: string, runId: string, content: string): ChatMessage {
return {
id: `
$
{
sessionKey
}:
$
{
runId
}
:accumulated`
,
role
:
"assistant"
,
content
,
createdAt
:
new
Date
().
toISOString
()
};
}
private
normalizeChatRole
(
role
:
unknown
):
MessageRole
{
private
normalizeChatRole
(
role
:
unknown
):
MessageRole
{
if
(
role
===
"system"
||
role
===
"user"
||
role
===
"assistant"
||
role
===
"tool"
||
role
===
"toolResult"
)
{
if
(
role
===
"system"
||
role
===
"user"
||
role
===
"assistant"
||
role
===
"tool"
||
role
===
"toolResult"
)
{
return
role
;
return
role
;
...
...
packages/gateway-client/test/chatCancelSource.test.ts
View file @
143789ee
...
@@ -14,3 +14,20 @@ test("gateway client only sends cancel RPC when gateway advertises chat cancel",
...
@@ -14,3 +14,20 @@ test("gateway client only sends cancel RPC when gateway advertises chat cancel",
assert
.
match
(
gatewaySource
,
/availableMethods.*chat
\.
cancel/
s
)
assert
.
match
(
gatewaySource
,
/availableMethods.*chat
\.
cancel/
s
)
assert
.
match
(
gatewaySource
,
/this
\.
request
\(
"chat
\.
cancel"/
)
assert
.
match
(
gatewaySource
,
/this
\.
request
\(
"chat
\.
cancel"/
)
})
})
test
(
"gateway client builds completed messages from payload before stream fallback"
,
()
=>
{
assert
.
match
(
gatewaySource
,
/const completedContent = this
\.
extractTextCandidate
\(
message
\)
/
)
assert
.
match
(
gatewaySource
,
/const fallbackText = fallbackContent
\?\?
pending
\?\.
accumulatedText
\?\?
""/
)
assert
.
match
(
gatewaySource
,
/const content = completedContent
\?\.
trim
\(\)\s
*
\?\s
*completedContent
\s
*:
\s
*fallbackText/
s
)
assert
.
match
(
gatewaySource
,
/return reply;/
)
})
test
(
"gateway client resolves final replies by payload then history then stream"
,
()
=>
{
assert
.
match
(
gatewaySource
,
/const reply = this
\.
buildChatMessage
\(
runId, payload, ""
\)
/
)
assert
.
match
(
gatewaySource
,
/if
\(
reply
\.
content
\.
trim
\(\)\)
\{\s
*return reply;
?\s
*
\}
/
s
)
assert
.
match
(
gatewaySource
,
/const assistant =
\[\.\.\.
history
\]\.
reverse
\(\)\.
find
\(\(
message
\)
=> message
\.
role === "assistant" && message
\.
content
\.
trim
\(\)\)
/
)
assert
.
match
(
gatewaySource
,
/return assistant/
)
assert
.
match
(
gatewaySource
,
/if
\(
pending
\?\.
accumulatedText
\.
trim
\(\)\)
\{\s
*return this
\.
buildAccumulatedChatMessage
\(
pending
\.
sessionKey, runId, pending
\.
accumulatedText
\)
;
?\s
*
\}
/
s
)
assert
.
doesNotMatch
(
gatewaySource
,
/chooseLongestChatMessage/
)
assert
.
doesNotMatch
(
gatewaySource
,
/selectLongestText/
)
})
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