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() { ...@@ -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">
......
import { useEffect, useMemo, useState } from "react" import { useCallback, 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?.id) { if (!taskId) {
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>{task.title}</strong> <strong>{truncateText(task.title, 20)}</strong>
<small>{task.prompt}</small> <small>{truncateText(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"
......
...@@ -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: 6px; gap: 5px;
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 {
......
This diff is collapsed.
...@@ -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;
......
...@@ -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/)
})
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