From 53bbb9b15bad58ac215bee834c117645ce86c691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Thu, 21 May 2026 15:53:43 +0800 Subject: [PATCH 01/28] =?UTF-8?q?feat:=20=E5=85=AC=E5=BC=80=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E4=BB=BB=E5=8A=A1=E9=A1=B5=E6=94=AF=E6=8C=81=E6=AD=A3?= =?UTF-8?q?=E6=96=87/MD=20=E5=88=87=E6=8D=A2=E4=B8=8E=E5=86=85=E8=81=94?= =?UTF-8?q?=E7=BC=96=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 公开查看器右侧改为 Tabs:MD 附件优先、然后是「正文」;标签使用文件名(去后缀) - 新增内联编辑:正文与 MD 文件均可编辑保存,每个 Tab 支持 Word 下载 - 后端新增公开 PUT 端点(/runs/{run_id}/files/{i}、/runs/{run_id}/body),服务层提取 _apply_run_file_edit 并新增 admin_update_run_file / admin_update_run_body - 列表过滤:仅展示 succeeded 且有正文或可用 MD 的执行记录;后端在响应中标记每个文件 available,前端隐藏磁盘已缺失的 MD Tab - 左侧任务名与右侧执行记录标题均改为可换行 + Tooltip 显示完整内容 - .gitignore 增加 frontend-web/dist Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 2 +- .../src/pages/PublicScheduledTaskPage.tsx | 766 +++++++++++------- .../gateway/routers/public_scheduled_tasks.py | 70 +- .../deerflow/runtime/scheduler/service.py | 41 +- offline-backend-20260512/config.yaml | 2 +- 5 files changed, 573 insertions(+), 308 deletions(-) diff --git a/.gitignore b/.gitignore index 6771619f..b398187d 100644 --- a/.gitignore +++ b/.gitignore @@ -24,7 +24,7 @@ frontend-web/src.zip frontend-web/src9.zip frontend-web/src-v1.zip frontend-web/src-*.rar - +frontend-web/dist # Frontend build artifacts frontend-web/dist-scheduled-check/ diff --git a/frontend-web/src/pages/PublicScheduledTaskPage.tsx b/frontend-web/src/pages/PublicScheduledTaskPage.tsx index 63798f12..c2f78303 100644 --- a/frontend-web/src/pages/PublicScheduledTaskPage.tsx +++ b/frontend-web/src/pages/PublicScheduledTaskPage.tsx @@ -1,8 +1,15 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; -import { Download, FileText, Loader2 } from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Check, + Download, + FileText, + Loader2, + Pencil, + X, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useParams } from "react-router-dom"; import { toast } from "sonner"; @@ -10,6 +17,14 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; import { getBackendBaseURL } from "@/core/config"; import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; @@ -35,6 +50,9 @@ type PublicRunFile = { mime_type?: string | null; kind?: string | null; size?: number | null; + /** Server-reported "is the archive file still on disk?" flag. Older responses + * may omit this — treat missing as available. */ + available?: boolean; }; type PublicRunResult = { @@ -79,8 +97,8 @@ function publicBaseUrl() { return `${getBackendBaseURL()}/api/public/scheduled-tasks`; } -async function fetchJson(url: string): Promise { - const response = await fetch(url); +async function fetchJson(url: string, init?: RequestInit): Promise { + const response = await fetch(url, init); if (!response.ok) { const body = await response.json().catch(() => ({ detail: "Request failed" })); throw new Error(typeof body.detail === "string" ? body.detail : "Request failed"); @@ -98,8 +116,30 @@ async function fetchPublicRunFile(runId: string, index: number): Promise { + await fetchJson( + `${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/files/${index}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }, + ); +} + +async function updatePublicRunBody(runId: string, content: string): Promise { + await fetchJson( + `${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/body`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + }, + ); } function publicRunFileDownloadUrl(runId: string, index: number): string { @@ -142,14 +182,32 @@ function isMarkdownFile(file: { name: string; kind?: string | null; mime_type?: ); } +function isFileAvailable(file: PublicRunFile): boolean { + // Older responses omit `available`; treat that as still available so we + // don't accidentally hide content from a backend that hasn't shipped the + // flag yet. + return file.available !== false; +} + +function stripExtension(name: string): string { + if (!name) return name; + const dot = name.lastIndexOf("."); + if (dot <= 0) return name; + return name.slice(0, dot); +} + +const BODY_TAB_VALUE = "__body__"; + /** * Public, unauthenticated scheduled-task viewer. * * URL: `#/public/scheduled-tasks/` * - * Left pane lists every run of the task (latest first). Selecting a run shows - * its Markdown body on the right by default; the user can switch tabs to view - * generated files or raw metadata. + * Left pane lists every run of the task (latest first). Right pane uses tabs + * to switch between the inline body (`正文`) and each Markdown attachment. + * Markdown attachments take priority — when they exist, the first one is the + * default tab. Each tab supports inline editing (saved via the public PUT + * endpoints) and a per-tab Word download. */ export default function PublicScheduledTaskPage() { const { taskId } = useParams<{ taskId: string }>(); @@ -164,57 +222,36 @@ export default function PublicScheduledTaskPage() { refetchInterval: 8000, }); - const runs = useMemo(() => data?.runs ?? [], [data]); + // Public viewer only surfaces runs that have actual content to show: + // pending/running/failed are hidden, and succeeded runs with neither a body + // nor a Markdown attachment fall out too (nothing to render). + const runs = useMemo(() => { + const all = data?.runs ?? []; + return all.filter((run) => { + if (run.status !== "succeeded") return false; + const result = run.result; + if (!result) return false; + const hasBody = !!result.content && result.content.trim().length > 0; + const hasMarkdownFile = (result.files ?? []).some( + (file) => isMarkdownFile(file) && isFileAvailable(file), + ); + return hasBody || hasMarkdownFile; + }); + }, [data]); const selectedRun = useMemo( () => runs.find((r) => r.id === selectedRunId) ?? null, [runs, selectedRunId], ); - // Files attached to the result that are Markdown. When at least one is - // present we render ONLY those — body / meta panels are deliberately hidden - // so the public viewer focuses on the deliverable doc. - const markdownFiles = useMemo( - () => (selectedRun?.result?.files ?? []).filter(isMarkdownFile), - [selectedRun], - ); - const hasMarkdownFiles = markdownFiles.length > 0; - useEffect(() => { if (!selectedRunId && runs.length > 0) { - setSelectedRunId(runs[0].id); + setSelectedRunId(runs[0]!.id); } if (selectedRunId && !runs.some((r) => r.id === selectedRunId) && runs.length > 0) { - setSelectedRunId(runs[0].id); + setSelectedRunId(runs[0]!.id); } }, [runs, selectedRunId]); - const handleDownloadResult = async () => { - if (!selectedRun) return; - const a = document.createElement("a"); - a.href = publicRunDownloadUrl(selectedRun.id); - a.rel = "noopener"; - document.body.appendChild(a); - a.click(); - a.remove(); - }; - - const handleDownloadBodyAsWord = async () => { - if (!selectedRun?.result?.content) { - toast.error("当前记录正文为空"); - return; - } - try { - await downloadMarkdownAsDocx( - selectedRun.result.content, - selectedRun.result.title || "scheduled-task-result", - selectedRun.result.title || undefined, - ); - toast.success("Word 文件已生成"); - } catch (err) { - toast.error(err instanceof Error ? err.message : "生成 Word 失败"); - } - }; - if (!taskId) { return (
@@ -243,285 +280,408 @@ export default function PublicScheduledTaskPage() { const task = data?.task; return ( -
- - -
- - {task?.name || "任务"} - - - {task?.enabled ? "启用" : "暂停"} - -
-

- {task?.schedule_text} · {task?.timezone} -

-

- 最近状态:{statusLabel(task?.last_status)} · 上次:{formatDate(task?.last_run_at)} -

-
- - -
- {runs.length === 0 && ( -
- 这个任务还没有执行记录。 -
- )} - {runs.map((run) => { - const active = run.id === selectedRunId; - const runRunning = run.status === "running" || run.status === "pending"; - return ( -
- - ); - })} -
- - - + + ); + })} +
+ + + - - -
- - {selectedRun?.result?.title || "请选择一条执行记录"} - - {selectedRun && ( -

- 开始:{formatDate(selectedRun.started_at)} · 完成: - {formatDate(selectedRun.finished_at)} -

- )} -
- {selectedRun?.result && ( -
- - {!hasMarkdownFiles && selectedRun.result.content && ( - - )} -
- )} -
- - {selectedRun?.result || selectedRun?.error ? ( - hasMarkdownFiles ? ( - // Result has MD files — show ONLY those. Body, meta, and other - // attachments are intentionally hidden so the public viewer is - // focused on the deliverable document. - -
- -
-
- ) : ( - // No MD files — fall back to the body Markdown. - -
- {selectedRun.error && ( -
- {selectedRun.error} -
- )} - {selectedRun.result?.content ? ( - selectedRun.result.format === "markdown" || - selectedRun.result.format === "mixed" ? ( - - ) : ( -
-                        {selectedRun.result.content}
-                      
- ) - ) : ( -
- 无正文内容。 -
- )} -
-
- ) + + {selectedRun ? ( + ) : ( -
+ 请在左侧选择一条执行记录。 -
+
)} - -
- + + + ); } +type ContentTab = { + /** Stable tab identifier — `__body__` for inline content, `file-` for files. */ + value: string; + /** Short label rendered in the TabsTrigger. */ + label: string; + /** Distinguishes body vs MD attachment so the editor knows which PUT to call. */ + kind: "body" | "file"; + /** File index (when `kind === "file"`). */ + fileIndex?: number; + /** Filename for downloads (defaults to the run title for body). */ + fileName?: string; +}; + /** - * Markdown-only result viewer used by the public page. - * - * Whenever a run has at least one .md file attached, this is the **only** - * thing the right pane shows — body text, metadata, and non-Markdown - * attachments are all hidden by the parent. We render each .md file inline - * as rich Markdown so anonymous viewers can read it without downloading. - * If multiple .md files exist a small switcher at the top lets the user - * pick which one is rendered; the others are not exposed. + * Right-pane controller for a single run: builds the tab list (body + MDs), + * fetches each tab's source text, and hosts the view/edit toggle. */ -function MarkdownFilesPane({ - runId, - files, -}: { - runId: string; - files: PublicRunFile[]; -}) { - const [contents, setContents] = useState>({}); - const [loading, setLoading] = useState(false); - const [activeIndex, setActiveIndex] = useState(null); +function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) { + const queryClient = useQueryClient(); + // Skip files whose archive is missing on disk — otherwise the public viewer + // would render a tab that 404s with "Result file is no longer available". + const markdownFiles = useMemo( + () => + (run.result?.files ?? []).filter( + (file) => isMarkdownFile(file) && isFileAvailable(file), + ), + [run.result?.files], + ); + + // Tabs: every MD file first (priority per spec), then the inline body. + const tabs: ContentTab[] = useMemo(() => { + const list: ContentTab[] = markdownFiles.map((file, idx) => ({ + value: `file-${file.index}`, + // Show the actual filename without the `.md` suffix — the user explicitly + // asked for "no '文件' label, just the name without extension". + label: stripExtension(file.name) || `文件 ${idx + 1}`, + kind: "file", + fileIndex: file.index, + fileName: file.name, + })); + list.push({ + value: BODY_TAB_VALUE, + label: "正文", + kind: "body", + fileName: run.result?.title || "scheduled-task-result", + }); + return list; + }, [markdownFiles, run.result?.title]); + + const [activeTab, setActiveTab] = useState(tabs[0]?.value ?? BODY_TAB_VALUE); + + // When the run's MD-file set shifts (rare) — fall back to the first tab. useEffect(() => { - let cancelled = false; - async function loadAll() { - if (files.length === 0) { - setContents({}); - return; - } - setLoading(true); + if (!tabs.some((t) => t.value === activeTab)) { + setActiveTab(tabs[0]?.value ?? BODY_TAB_VALUE); + } + }, [tabs, activeTab]); + + // Fetch each MD file's text once. Keyed by file index inside the run. + const [fileTexts, setFileTexts] = useState>({}); + const [fileLoading, setFileLoading] = useState>({}); + + const loadFile = useCallback( + async (fileIndex: number) => { + setFileLoading((s) => ({ ...s, [fileIndex]: true })); try { - const all = await Promise.all( - files.map((f) => fetchPublicRunFile(runId, f.index)), - ); - if (!cancelled) { - const map: Record = {}; - for (const item of all) map[item.index] = item; - setContents(map); - } - } catch (err) { - if (!cancelled) { - toast.error(err instanceof Error ? err.message : "加载文件内容失败"); - } + const file = await fetchPublicRunFile(run.id, fileIndex); + const text = file.previewable && file.content != null ? file.content : ""; + setFileTexts((s) => ({ ...s, [fileIndex]: text })); + } catch { + // Race: the archive may have disappeared between list and fetch. Fall + // back to empty content; the parent filter will drop this tab on the + // next refetch tick. + setFileTexts((s) => ({ ...s, [fileIndex]: "" })); } finally { - if (!cancelled) setLoading(false); + setFileLoading((s) => ({ ...s, [fileIndex]: false })); + } + }, + [run.id], + ); + + // Pull every MD file on first render — keeps tab-switching instant. + useEffect(() => { + for (const file of markdownFiles) { + if (fileTexts[file.index] == null && !fileLoading[file.index]) { + void loadFile(file.index); } } - void loadAll(); - return () => { - cancelled = true; - }; - }, [runId, files]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [markdownFiles, run.id]); + // Editor state — separate from displayed source so users can cancel. + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState(""); + + const activeTabMeta = useMemo( + () => tabs.find((t) => t.value === activeTab) ?? tabs[0], + [tabs, activeTab], + ); + const activeSource = useMemo(() => { + if (!activeTabMeta) return ""; + if (activeTabMeta.kind === "body") return run.result?.content ?? ""; + return fileTexts[activeTabMeta.fileIndex!] ?? ""; + }, [activeTabMeta, run.result?.content, fileTexts]); + + // Reset the editor state any time the tab changes underneath us. useEffect(() => { - if (activeIndex == null && files.length > 0) { - setActiveIndex(files[0]!.index); - } - if (activeIndex != null && !files.some((f) => f.index === activeIndex)) { - setActiveIndex(files[0]?.index ?? null); - } - }, [activeIndex, files]); + setIsEditing(false); + setDraft(activeSource); + }, [activeTabMeta?.value, activeSource]); - if (files.length === 0) { - return null; - } + const saveMutation = useMutation({ + mutationFn: async () => { + if (!activeTabMeta) return; + if (activeTabMeta.kind === "body") { + await updatePublicRunBody(run.id, draft); + } else { + await updatePublicRunFile(run.id, activeTabMeta.fileIndex!, draft); + } + }, + onSuccess: async () => { + if (activeTabMeta?.kind === "file") { + setFileTexts((s) => ({ ...s, [activeTabMeta.fileIndex!]: draft })); + } + toast.success("已保存"); + setIsEditing(false); + // Force a refetch so the left-list run row picks up the change too. + await queryClient.invalidateQueries({ queryKey: ["public-scheduled-task", taskId] }); + }, + onError: (err) => { + toast.error(err instanceof Error ? err.message : "保存失败"); + }, + }); - const activeFile = - activeIndex != null ? files.find((f) => f.index === activeIndex) ?? null : null; - const activeContent = activeFile ? contents[activeFile.index] : null; + const handleDownloadWord = useCallback(async () => { + if (!activeTabMeta) return; + const markdown = isEditing ? draft : activeSource; + if (!markdown.trim()) { + toast.error("当前内容为空"); + return; + } + const fileName = activeTabMeta.fileName || run.result?.title || "scheduled-task-result"; + const title = activeTabMeta.kind === "body" ? run.result?.title : undefined; + try { + await downloadMarkdownAsDocx(markdown, fileName, title); + toast.success("Word 文件已生成"); + } catch (err) { + toast.error(err instanceof Error ? err.message : "生成 Word 失败"); + } + }, [activeTabMeta, draft, isEditing, activeSource, run.result?.title]); + + const isFileTab = activeTabMeta?.kind === "file"; + const fileIndex = activeTabMeta?.fileIndex; + const fileIsLoading = isFileTab && fileIndex != null ? !!fileLoading[fileIndex] : false; + const showEmptyHint = !activeSource && !fileIsLoading; return ( -
- {files.length > 1 && ( -
- {files.map((file) => { - const active = file.index === activeIndex; - return ( - + {isEditing ? ( + <> + - ); - })} + + 取消 + + + + ) : ( + + )}
- )} - {activeFile && ( -
-
- {activeFile.name} - {typeof activeFile.size === "number" ? ` · ${activeFile.size} bytes` : ""} -
- - - 下载 - -
- )} - {loading && !activeContent && ( -
正在加载内容...
- )} - {activeContent ? ( - activeContent.previewable && activeContent.content ? ( - + + + + {tabs.length === 0 ? ( +
无可用内容。
) : ( -
- 无法预览此 Markdown 文件,请下载查看。 -
- ) - ) : !loading ? ( -
未加载到内容。
- ) : null} -
+ + + {tabs.map((tab) => ( + + {tab.label} + + ))} + + +
+ {run.error && ( +
+ {run.error} +
+ )} + {fileIsLoading ? ( +
+ + 正在加载文件内容... +
+ ) : isEditing ? ( +