diff --git a/frontend-web/src/core/scheduled-tasks/api.ts b/frontend-web/src/core/scheduled-tasks/api.ts index fcc873ec..01bff0c7 100644 --- a/frontend-web/src/core/scheduled-tasks/api.ts +++ b/frontend-web/src/core/scheduled-tasks/api.ts @@ -8,6 +8,7 @@ import type { ScheduledTaskResultFileContent, ScheduledTaskRun, ScheduledTaskRunFileUpdateResponse, + ScheduledTaskRunMessagesResponse, ScheduledTaskSubscription, SchedulerThread, } from "./types"; @@ -79,6 +80,13 @@ export async function deleteScheduledTaskRun(runId: string) { ); } +/** Agent messages for a run's underlying agent run — used to inspect a stuck/running run. */ +export async function getScheduledTaskRunMessages(runId: string) { + return parseResponse( + await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}/messages`), + ); +} + export function scheduledTaskRunDownloadUrl(runId: string) { return `${baseUrl()}/runs/${encodeURIComponent(runId)}/download`; } diff --git a/frontend-web/src/core/scheduled-tasks/hooks.ts b/frontend-web/src/core/scheduled-tasks/hooks.ts index 75cc5211..e2705640 100644 --- a/frontend-web/src/core/scheduled-tasks/hooks.ts +++ b/frontend-web/src/core/scheduled-tasks/hooks.ts @@ -16,6 +16,7 @@ import { getSchedulerThread, getScheduledTaskDeliveryProfile, getScheduledTaskRun, + getScheduledTaskRunMessages, listAllScheduledTasks, listPublishedScheduledTasks, listScheduledTaskRuns, @@ -62,12 +63,34 @@ export function useScheduledTaskRuns(taskId?: string | null) { }); } +/** A run still in flight — used to drive live polling of the run/messages. */ +function runIsLive(status?: string | null): boolean { + return status === "running" || status === "pending"; +} + export function useScheduledTaskRun(runId?: string | null) { return useQuery({ queryKey: ["scheduled-tasks", "runs", runId], queryFn: () => getScheduledTaskRun(runId!), enabled: !!runId, refetchOnWindowFocus: false, + // Keep refreshing while the run is still executing so the detail page + // reflects status / result changes without a manual reload. + refetchInterval: (query) => (runIsLive(query.state.data?.status) ? 5000 : false), + }); +} + +/** Agent messages for a run; polls every 4s while the run is still executing. */ +export function useScheduledTaskRunMessages( + runId?: string | null, + options: { live?: boolean } = {}, +) { + return useQuery({ + queryKey: ["scheduled-tasks", "runs", runId, "messages"], + queryFn: () => getScheduledTaskRunMessages(runId!), + enabled: !!runId, + refetchOnWindowFocus: false, + refetchInterval: options.live ? 4000 : false, }); } @@ -124,6 +147,12 @@ export function useAdminScheduledTaskActions() { mutationFn: adminDeleteScheduledTask, onSuccess: invalidate, }), + // The DELETE /runs/{id} route is admin-aware, so the same call deletes + // any user's run when the caller is an admin. + removeRun: useMutation({ + mutationFn: deleteScheduledTaskRun, + onSuccess: invalidate, + }), trigger: useMutation({ mutationFn: adminTriggerScheduledTask, onSuccess: invalidate, diff --git a/frontend-web/src/core/scheduled-tasks/types.ts b/frontend-web/src/core/scheduled-tasks/types.ts index 0ca16fa0..fe2f30ba 100644 --- a/frontend-web/src/core/scheduled-tasks/types.ts +++ b/frontend-web/src/core/scheduled-tasks/types.ts @@ -107,3 +107,19 @@ export type ScheduledTaskRunFileUpdateResponse = { mime_type?: string | null; kind?: string | null; }; + +/** One agent message produced by a scheduled run's underlying agent run. */ +export type ScheduledTaskRunMessage = { + seq: number; + event_type: string; + category: string; + // string for human input; object (LangChain message dump) for ai/tool. + content: unknown; + metadata?: Record | null; + created_at?: string | null; +}; + +export type ScheduledTaskRunMessagesResponse = { + messages: ScheduledTaskRunMessage[]; + has_more: boolean; +}; diff --git a/frontend-web/src/pages/AdminScheduledTasksPage.tsx b/frontend-web/src/pages/AdminScheduledTasksPage.tsx index 018a1d69..2ca2c4e8 100644 --- a/frontend-web/src/pages/AdminScheduledTasksPage.tsx +++ b/frontend-web/src/pages/AdminScheduledTasksPage.tsx @@ -1,7 +1,8 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { + Download, Edit3, Eye, Pause, @@ -10,6 +11,7 @@ import { Search, Trash2, } from "lucide-react"; +import { useSearchParams } from "react-router-dom"; import { useRouteNavigate as useNavigate } from "@/core/route-base"; import { toast } from "sonner"; @@ -41,6 +43,7 @@ import { statusVariant, } from "@/components/workspace/scheduled-tasks/schedule-builder"; import { + downloadScheduledTaskRun, type ScheduledTask, useAdminScheduledTaskActions, useAdminScheduledTaskRuns, @@ -68,12 +71,19 @@ const emptyForm = { export default function AdminScheduledTasksPage() { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + // ?task= carries the task to re-select when returning from a run detail. + const initialTaskId = searchParams.get("task"); const [searchInput, setSearchInput] = useState(""); const [search, setSearch] = useState(""); const { data: tasks = [], isLoading } = useAllScheduledTasks({ search }); const actions = useAdminScheduledTaskActions(); - const [selectedTaskId, setSelectedTaskId] = useState(null); + const [selectedTaskId, setSelectedTaskId] = useState( + initialTaskId, + ); + // One-shot scroll target after a back-navigation. + const pendingScrollRef = useRef(initialTaskId); const [editing, setEditing] = useState(null); const [form, setForm] = useState(emptyForm); const [scheduleConfig, setScheduleConfig] = useState( @@ -174,6 +184,24 @@ export default function AdminScheduledTasksPage() { } }; + const downloadRun = async (runId: string) => { + try { + await downloadScheduledTaskRun(runId); + } catch (error) { + toast.error(error instanceof Error ? error.message : "下载失败"); + } + }; + + const deleteRun = async (runId: string) => { + if (!window.confirm("确认删除这条执行记录?该操作不可撤销。")) return; + try { + await actions.removeRun.mutateAsync(runId); + toast.success("执行记录已删除"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "删除失败"); + } + }; + return ( @@ -221,6 +249,12 @@ export default function AdminScheduledTasksPage() { {sortedTasks.map((task) => ( + + ))} diff --git a/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx b/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx index 9d47ed62..b58c9866 100644 --- a/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx +++ b/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx @@ -1,8 +1,8 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { ChevronDown, Download, Edit3, FileText, ListChecks, Loader2, Save, Trash2, X } from "lucide-react"; -import { useParams } from "react-router-dom"; +import { Activity, ChevronDown, ChevronLeft, Download, Edit3, FileText, Loader2, Save, Trash2, X } from "lucide-react"; +import { useParams, useSearchParams } from "react-router-dom"; import { useRouteNavigate as useNavigate } from "@/core/route-base"; import { toast } from "sonner"; @@ -24,9 +24,11 @@ import { getScheduledTaskRunFileContent, type ScheduledTaskResultFileContent, type ScheduledTaskRun, + type ScheduledTaskRunMessage, updateScheduledTaskRunFile, useScheduledTaskActions, useScheduledTaskRun, + useScheduledTaskRunMessages, } from "@/core/scheduled-tasks"; import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; import { cn } from "@/lib/utils"; @@ -97,12 +99,117 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) { type EditState = { draft: string; saving: boolean }; +/** Flatten a LangChain-style message content (string | parts | nested) to text. */ +function messageText(content: unknown): string { + if (content == null) return ""; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((part) => { + if (typeof part === "string") return part; + if (part && typeof part === "object" && "text" in part) { + return String((part as { text?: unknown }).text ?? ""); + } + return ""; + }) + .filter(Boolean) + .join("\n"); + } + if (typeof content === "object" && "content" in content) { + return messageText((content as { content?: unknown }).content); + } + return JSON.stringify(content, null, 2); +} + +const EXECUTION_ROLE: Record = { + "llm.human.input": { label: "触发输入", tone: "bg-muted text-foreground" }, + "llm.ai.response": { label: "智能体", tone: "bg-primary/10 text-primary" }, + "llm.tool.result": { label: "工具结果", tone: "bg-amber-500/10 text-amber-600 dark:text-amber-400" }, +}; + +/** One agent message rendered inside the execution-process tab. */ +function ExecutionMessage({ message }: { message: ScheduledTaskRunMessage }) { + const role = EXECUTION_ROLE[message.event_type] ?? { + label: message.event_type, + tone: "bg-muted text-foreground", + }; + const raw = message.content; + const obj = raw && typeof raw === "object" ? (raw as Record) : null; + const text = messageText(raw); + const toolName = typeof obj?.name === "string" ? obj.name : null; + const toolCalls = Array.isArray(obj?.tool_calls) + ? (obj!.tool_calls as Array>) + : []; + const isAi = message.event_type === "llm.ai.response"; + + return ( +
+
+ + {role.label} + {toolName ? ` · ${toolName}` : ""} + + {message.created_at && ( + + {new Date(message.created_at).toLocaleString()} + + )} +
+
+ {text ? ( + isAi ? ( + + ) : ( +
{text}
+ ) + ) : ( + !toolCalls.length &&
(无文本内容)
+ )} + {toolCalls.length > 0 && ( +
+ {toolCalls.map((call, index) => ( +
+ 调用工具:{String(call.name ?? "未知")} + {call.args != null && ( +
+                    {typeof call.args === "string" ? call.args : JSON.stringify(call.args, null, 2)}
+                  
+ )} +
+ ))} +
+ )} +
+
+ ); +} + export default function ScheduledTaskRunDetailPage() { const navigate = useNavigate(); const { run_id } = useParams(); + const [searchParams] = useSearchParams(); const actions = useScheduledTaskActions(); const { data: run, isLoading } = useScheduledTaskRun(run_id); const result = run?.result ?? null; + // A run still executing — drives live polling of its agent messages. + const runLive = run?.status === "running" || run?.status === "pending"; + const hasExecution = !!run?.agent_run_id; + const { data: messagesData, isLoading: messagesLoading } = useScheduledTaskRunMessages( + hasExecution ? run_id : null, + { live: runLive }, + ); + const runMessages = messagesData?.messages ?? []; + + // Back navigation returns to the originating list (admin vs. mine) and + // re-selects the task the user came from (carried in ?task=). + const handleBack = useCallback(() => { + const base = + searchParams.get("from") === "admin" + ? "/page/workspace/admin/scheduled-tasks" + : "/page/workspace/scheduled/tasks"; + const taskId = run?.task_id ?? searchParams.get("task"); + navigate(taskId ? `${base}?task=${encodeURIComponent(taskId)}` : base); + }, [navigate, run?.task_id, searchParams]); const [fileContents, setFileContents] = useState([]); const [filesLoading, setFilesLoading] = useState(false); const [showMeta, setShowMeta] = useState(false); @@ -257,7 +364,7 @@ export default function ScheduledTaskRunDetailPage() { try { await actions.removeRun.mutateAsync(run.id); toast.success("执行记录已删除"); - navigate("/page/workspace/scheduled/tasks"); + handleBack(); } catch (error) { toast.error(error instanceof Error ? error.message : "删除失败"); } @@ -281,9 +388,9 @@ export default function ScheduledTaskRunDetailPage() { 执行信息 -