Commit 143789ee authored by edy's avatar edy

fix(ui): guard automation run updates

parent 5f36e0b1
Pipeline #18475 failed
......@@ -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() {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
......
import { useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import type {
AutomationTask,
AutomationTaskRun,
......@@ -10,7 +10,7 @@ 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 { EditIcon, PlusIcon, 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"
......@@ -35,6 +35,7 @@ interface AutomationTaskFormState {
}
const weekdayLabels = ["日", "一", "二", "三", "四", "五", "六"]
const activeRunStatuses = new Set<AutomationTaskRun["status"]>(["queued", "running"])
function toDateTimeLocalValue(date: Date) {
const year = date.getFullYear()
......@@ -168,6 +169,10 @@ function getTaskNextRunLabel(task: AutomationTask) {
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) {
const [tasks, setTasks] = useState<AutomationTask[]>([])
const [runs, setRuns] = useState<AutomationTaskRun[]>([])
......@@ -177,6 +182,8 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [errorText, setErrorText] = useState("")
const [optimisticRunningTaskIds, setOptimisticRunningTaskIds] = useState<Set<string>>(() => new Set())
const selectedTaskIdRef = useRef("")
const expertOptions = useMemo(() => [
{ id: "", label: "通用助手" },
......@@ -200,7 +207,11 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
return true
}), [filterMode, tasks])
const loadTasks = async () => {
useEffect(() => {
selectedTaskIdRef.current = selectedTask?.id ?? ""
}, [selectedTask?.id])
const loadTasks = useCallback(async () => {
setLoading(true)
setErrorText("")
try {
......@@ -212,34 +223,34 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadTasks()
}, [])
useEffect(() => {
let active = true
if (!selectedTask?.id) {
const loadSelectedTaskRuns = useCallback(async () => {
const taskId = selectedTask?.id
if (!taskId) {
setRuns([])
return
return []
}
void desktopApi.automationTasks.listRuns(selectedTask.id)
.then((nextRuns) => {
if (active) {
try {
const nextRuns = await desktopApi.automationTasks.listRuns(taskId)
if (selectedTaskIdRef.current === taskId) {
setRuns(nextRuns)
}
})
.catch((error) => {
if (active) {
return nextRuns
} catch (error) {
setErrorText(error instanceof Error ? error.message : "运行记录加载失败")
}
})
return () => {
active = false
return []
}
}, [selectedTask?.id])
useEffect(() => {
void loadTasks()
}, [loadTasks])
useEffect(() => {
void loadSelectedTaskRuns()
}, [loadSelectedTaskRuns])
const startCreate = () => {
setForm(createDefaultForm(projects))
}
......@@ -316,15 +327,23 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
}
const runTaskNow = async (taskId: string) => {
setOptimisticRunningTaskIds((current) => new Set(current).add(taskId))
setSaving(true)
setErrorText("")
try {
const run = await desktopApi.automationTasks.runNow(taskId)
if (selectedTaskIdRef.current === taskId) {
setRuns((current) => [run, ...current.filter((item) => item.id !== run.id)])
}
await loadTasks()
} catch (error) {
setErrorText(error instanceof Error ? error.message : "立即执行失败")
} finally {
setOptimisticRunningTaskIds((current) => {
const next = new Set(current)
next.delete(taskId)
return next
})
setSaving(false)
}
}
......@@ -333,6 +352,24 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
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 (
<div className="automation-page-stack">
<Panel
......@@ -342,14 +379,16 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
<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>
<Button size="sm" className="automation-action-button automation-button-primary" onClick={startCreate}>
<PlusIcon />
<span>新建任务</span>
</Button>
</div>
</div>
)}
......@@ -387,8 +426,8 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
className="automation-task-select"
onClick={() => setSelectedTaskId(task.id)}
>
<strong>{task.title}</strong>
<small>{task.prompt}</small>
<strong>{truncateText(task.title, 20)}</strong>
<small>{truncateText(task.prompt, 20)}</small>
</button>
</article>
)) : (
......@@ -461,8 +500,8 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
</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>
<Button variant="secondary" size="sm" className="automation-form-button-secondary" onClick={() => setForm(null)} disabled={saving}>取消</Button>
<Button size="sm" className="automation-form-button-primary" onClick={() => void submitForm()} disabled={saving}>{saving ? "保存中" : "保存"}</Button>
</div>
</div>
) : selectedTask ? (
......@@ -471,12 +510,20 @@ export function AutomationTasksView({ projects }: AutomationTasksViewProps) {
<div>
<span className="automation-kicker">{selectedTask.expertName || "通用助手"}</span>
<h2>{selectedTask.title}</h2>
<p className="automation-detail-prompt">{selectedTask.prompt}</p>
<p className="automation-detail-prompt">{truncateText(selectedTask.prompt, 30)}</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 variant="secondary" size="sm" className="automation-action-button automation-button-secondary" onClick={() => startEdit(selectedTask)} disabled={saving}>
<EditIcon />
<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
type="button"
......
......@@ -57,6 +57,28 @@
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 {
border: 1px solid rgba(37, 99, 235, 0.22);
background: #2563eb;
......@@ -82,6 +104,34 @@
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 {
border: 1px solid rgba(7, 193, 96, 0.42);
background: #07C160;
......@@ -180,6 +230,14 @@
.automation-list-scroll {
min-height: 0;
height: 100%;
overflow: hidden;
}
.automation-list-scroll .scroll-area-content {
height: 100%;
min-height: 0;
overflow-y: auto;
}
.automation-task-list,
......@@ -189,7 +247,8 @@
}
.automation-task-row {
min-height: 70px;
height: 84px;
overflow: hidden;
padding: 12px;
border: 1px solid rgba(226, 232, 240, 0.9);
border-radius: 12px;
......@@ -205,9 +264,9 @@
.automation-task-select {
display: grid;
gap: 6px;
gap: 5px;
width: 100%;
min-height: 46px;
height: 100%;
min-width: 0;
border: 0;
background: transparent;
......@@ -234,12 +293,11 @@
}
.automation-task-select small {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
color: #64748b;
font-size: 12px;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.automation-detail-pane {
......
This diff is collapsed.
......@@ -784,12 +784,11 @@ export class GatewayClient {
}
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()) {
return reply;
}
const pending = this.pendingChatRuns.get(runId);
if (!pending) {
return reply;
}
......@@ -803,6 +802,10 @@ export class GatewayClient {
} catch {
}
if (pending?.accumulatedText.trim()) {
return this.buildAccumulatedChatMessage(pending.sessionKey, runId, pending.accumulatedText);
}
return reply;
}
......@@ -876,10 +879,12 @@ export class GatewayClient {
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 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"]);
return {
id: `${pending?.sessionKey ?? "session"}:${runId}:final`,
......@@ -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 {
if (role === "system" || role === "user" || role === "assistant" || role === "tool" || role === "toolResult") {
return role;
......
......@@ -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, /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/)
})
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment