feat: 公开定时任务页支持正文/MD 切换与内联编辑

- 公开查看器右侧改为 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) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.7 committed 2026-05-21 15:53:43 +08:00
1 parent f1d58983c2
commit 53bbb9b15b
5 files changed
+573 -308

No files matched your search

+1 -1
View File
@@ -24,7 +24,7 @@ frontend-web/src.zip
frontend-web/src9.zip frontend-web/src9.zip
frontend-web/src-v1.zip frontend-web/src-v1.zip
frontend-web/src-*.rar frontend-web/src-*.rar
frontend-web/dist
# Frontend build artifacts # Frontend build artifacts
frontend-web/dist-scheduled-check/ frontend-web/dist-scheduled-check/
+463 -303
View File
@@ -1,8 +1,15 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Download, FileText, Loader2 } from "lucide-react"; import {
import { useEffect, useMemo, useState } from "react"; Check,
Download,
FileText,
Loader2,
Pencil,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -10,6 +17,14 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area"; 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 { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
@@ -35,6 +50,9 @@ type PublicRunFile = {
mime_type?: string | null; mime_type?: string | null;
kind?: string | null; kind?: string | null;
size?: number | 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 = { type PublicRunResult = {
@@ -79,8 +97,8 @@ function publicBaseUrl() {
return `${getBackendBaseURL()}/api/public/scheduled-tasks`; return `${getBackendBaseURL()}/api/public/scheduled-tasks`;
} }
async function fetchJson<T>(url: string): Promise<T> { async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url); const response = await fetch(url, init);
if (!response.ok) { if (!response.ok) {
const body = await response.json().catch(() => ({ detail: "Request failed" })); const body = await response.json().catch(() => ({ detail: "Request failed" }));
throw new Error(typeof body.detail === "string" ? body.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<PublicF
); );
} }
function publicRunDownloadUrl(runId: string): string { async function updatePublicRunFile(
return `${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/download`; runId: string,
index: number,
content: string,
): Promise<void> {
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<void> {
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 { 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. * Public, unauthenticated scheduled-task viewer.
* *
* URL: `#/public/scheduled-tasks/<task_id>` * URL: `#/public/scheduled-tasks/<task_id>`
* *
* Left pane lists every run of the task (latest first). Selecting a run shows * Left pane lists every run of the task (latest first). Right pane uses tabs
* its Markdown body on the right by default; the user can switch tabs to view * to switch between the inline body (`正文`) and each Markdown attachment.
* generated files or raw metadata. * 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() { export default function PublicScheduledTaskPage() {
const { taskId } = useParams<{ taskId: string }>(); const { taskId } = useParams<{ taskId: string }>();
@@ -164,57 +222,36 @@ export default function PublicScheduledTaskPage() {
refetchInterval: 8000, 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( const selectedRun = useMemo(
() => runs.find((r) => r.id === selectedRunId) ?? null, () => runs.find((r) => r.id === selectedRunId) ?? null,
[runs, selectedRunId], [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(() => { useEffect(() => {
if (!selectedRunId && runs.length > 0) { if (!selectedRunId && runs.length > 0) {
setSelectedRunId(runs[0].id); setSelectedRunId(runs[0]!.id);
} }
if (selectedRunId && !runs.some((r) => r.id === selectedRunId) && runs.length > 0) { if (selectedRunId && !runs.some((r) => r.id === selectedRunId) && runs.length > 0) {
setSelectedRunId(runs[0].id); setSelectedRunId(runs[0]!.id);
} }
}, [runs, selectedRunId]); }, [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) { if (!taskId) {
return ( return (
<div className="text-muted-foreground flex h-screen items-center justify-center text-sm"> <div className="text-muted-foreground flex h-screen items-center justify-center text-sm">
@@ -243,285 +280,408 @@ export default function PublicScheduledTaskPage() {
const task = data?.task; const task = data?.task;
return ( return (
<div className="bg-muted/20 grid h-screen min-h-0 grid-cols-1 gap-4 p-4 xl:grid-cols-[380px_minmax(0,1fr)]"> <TooltipProvider delayDuration={150}>
<Card className="flex min-h-0 flex-col"> <div className="bg-muted/20 grid h-screen min-h-0 grid-cols-1 gap-4 p-4 xl:grid-cols-[380px_minmax(0,1fr)]">
<CardHeader className="gap-1"> <Card className="flex min-h-0 flex-col">
<div className="flex items-center justify-between gap-2"> <CardHeader className="gap-1">
<CardTitle className="truncate text-base"> <div className="flex items-start justify-between gap-2">
{task?.name || "任务"} <Tooltip>
</CardTitle> <TooltipTrigger asChild>
<Badge variant={task?.enabled ? "default" : "secondary"} className="shrink-0"> <CardTitle className="line-clamp-2 text-base leading-snug break-all">
{task?.enabled ? "启用" : "暂停"} {task?.name || "任务"}
</Badge> </CardTitle>
</div> </TooltipTrigger>
<p className="text-muted-foreground truncate text-xs"> {task?.name && (
{task?.schedule_text} · {task?.timezone} <TooltipContent side="bottom" className="max-w-[320px] break-all">
</p> {task.name}
<p className="text-muted-foreground text-xs"> </TooltipContent>
最近状态:{statusLabel(task?.last_status)} · 上次:{formatDate(task?.last_run_at)} )}
</p> </Tooltip>
</CardHeader> <Badge variant={task?.enabled ? "default" : "secondary"} className="shrink-0">
<CardContent className="min-h-0 flex-1 p-0"> {task?.enabled ? "启用" : "暂停"}
<ScrollArea className="size-full"> </Badge>
<div className="flex flex-col gap-2 p-3 pt-0"> </div>
{runs.length === 0 && ( <p className="text-muted-foreground truncate text-xs">
<div className="text-muted-foreground rounded-md border p-3 text-sm"> {task?.schedule_text} · {task?.timezone}
这个任务还没有执行记录。 </p>
</div> <p className="text-muted-foreground text-xs">
)} 最近状态:{statusLabel(task?.last_status)} · 上次:{formatDate(task?.last_run_at)}
{runs.map((run) => { </p>
const active = run.id === selectedRunId; </CardHeader>
const runRunning = run.status === "running" || run.status === "pending"; <CardContent className="min-h-0 flex-1 p-0">
return ( <ScrollArea className="size-full">
<button <div className="flex flex-col gap-2 p-3 pt-0">
key={run.id} {runs.length === 0 && (
className={cn( <div className="text-muted-foreground rounded-md border p-3 text-sm">
"bg-card hover:bg-accent/60 rounded-md border p-3 text-left transition-colors", 暂无可查看的执行记录。
active && "border-primary bg-accent", </div>
)} )}
onClick={() => setSelectedRunId(run.id)} {runs.map((run) => {
> const active = run.id === selectedRunId;
<div className="flex items-start justify-between gap-2"> const runRunning = run.status === "running" || run.status === "pending";
<div className="min-w-0"> const runTitle = run.result?.title || formatDate(run.scheduled_for);
<div className="truncate text-sm font-medium"> return (
{run.result?.title || formatDate(run.scheduled_for)} <button
</div> key={run.id}
<div className="text-muted-foreground mt-1 text-xs"> className={cn(
计划:{formatDate(run.scheduled_for)} "bg-card hover:bg-accent/60 rounded-md border p-3 text-left transition-colors",
active && "border-primary bg-accent",
)}
onClick={() => setSelectedRunId(run.id)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<Tooltip>
<TooltipTrigger asChild>
<div
className="text-sm leading-snug font-medium break-all"
title={runTitle}
>
{runTitle}
</div>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[360px] break-all">
{runTitle}
</TooltipContent>
</Tooltip>
<div className="text-muted-foreground mt-1 text-xs">
计划:{formatDate(run.scheduled_for)}
</div>
</div> </div>
<Badge
variant={statusVariant(run.status)}
className={cn(
"shrink-0",
runRunning && "border-primary/30 bg-primary/10 text-primary",
)}
>
{runRunning && <Loader2 className="size-3 animate-spin" />}
{statusLabel(run.status)}
</Badge>
</div> </div>
<Badge </button>
variant={statusVariant(run.status)} );
className={cn(runRunning && "border-primary/30 bg-primary/10 text-primary")} })}
> </div>
{runRunning && <Loader2 className="size-3 animate-spin" />} </ScrollArea>
{statusLabel(run.status)} </CardContent>
</Badge> </Card>
</div>
</button>
);
})}
</div>
</ScrollArea>
</CardContent>
</Card>
<Card className="flex min-h-0 flex-col"> <Card className="flex min-h-0 flex-col">
<CardHeader className="flex flex-wrap items-center justify-between gap-2 space-y-0"> {selectedRun ? (
<div className="min-w-0"> <RunDetailPane key={selectedRun.id} run={selectedRun} taskId={taskId} />
<CardTitle className="truncate text-base">
{selectedRun?.result?.title || "请选择一条执行记录"}
</CardTitle>
{selectedRun && (
<p className="text-muted-foreground mt-1 text-xs">
开始:{formatDate(selectedRun.started_at)} · 完成:
{formatDate(selectedRun.finished_at)}
</p>
)}
</div>
{selectedRun?.result && (
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={handleDownloadResult}>
<Download className="size-4" />
下载结果
</Button>
{!hasMarkdownFiles && selectedRun.result.content && (
<Button
size="sm"
variant="outline"
onClick={handleDownloadBodyAsWord}
>
<FileText className="size-4" />
下载 Word
</Button>
)}
</div>
)}
</CardHeader>
<CardContent className="min-h-0 flex-1 overflow-hidden p-0">
{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.
<ScrollArea className="size-full">
<div className="p-3">
<MarkdownFilesPane runId={selectedRun.id} files={markdownFiles} />
</div>
</ScrollArea>
) : (
// No MD files — fall back to the body Markdown.
<ScrollArea className="size-full">
<div className="p-4">
{selectedRun.error && (
<div className="text-destructive border-destructive/30 bg-destructive/5 mb-3 rounded-md border p-2 text-xs">
{selectedRun.error}
</div>
)}
{selectedRun.result?.content ? (
selectedRun.result.format === "markdown" ||
selectedRun.result.format === "mixed" ? (
<MarkdownContent
className="max-w-none"
content={selectedRun.result.content}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
) : (
<pre className="whitespace-pre-wrap text-sm">
{selectedRun.result.content}
</pre>
)
) : (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
无正文内容。
</div>
)}
</div>
</ScrollArea>
)
) : ( ) : (
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-sm"> <CardContent className="text-muted-foreground flex h-full items-center justify-center p-6 text-sm">
请在左侧选择一条执行记录。 请在左侧选择一条执行记录。
</div> </CardContent>
)} )}
</CardContent> </Card>
</Card> </div>
</div> </TooltipProvider>
); );
} }
type ContentTab = {
/** Stable tab identifier — `__body__` for inline content, `file-<index>` 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. * 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.
* 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.
*/ */
function MarkdownFilesPane({ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
runId, const queryClient = useQueryClient();
files,
}: {
runId: string;
files: PublicRunFile[];
}) {
const [contents, setContents] = useState<Record<number, PublicFileContent>>({});
const [loading, setLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
// 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<string>(tabs[0]?.value ?? BODY_TAB_VALUE);
// When the run's MD-file set shifts (rare) — fall back to the first tab.
useEffect(() => { useEffect(() => {
let cancelled = false; if (!tabs.some((t) => t.value === activeTab)) {
async function loadAll() { setActiveTab(tabs[0]?.value ?? BODY_TAB_VALUE);
if (files.length === 0) { }
setContents({}); }, [tabs, activeTab]);
return;
} // Fetch each MD file's text once. Keyed by file index inside the run.
setLoading(true); const [fileTexts, setFileTexts] = useState<Record<number, string>>({});
const [fileLoading, setFileLoading] = useState<Record<number, boolean>>({});
const loadFile = useCallback(
async (fileIndex: number) => {
setFileLoading((s) => ({ ...s, [fileIndex]: true }));
try { try {
const all = await Promise.all( const file = await fetchPublicRunFile(run.id, fileIndex);
files.map((f) => fetchPublicRunFile(runId, f.index)), const text = file.previewable && file.content != null ? file.content : "";
); setFileTexts((s) => ({ ...s, [fileIndex]: text }));
if (!cancelled) { } catch {
const map: Record<number, PublicFileContent> = {}; // Race: the archive may have disappeared between list and fetch. Fall
for (const item of all) map[item.index] = item; // back to empty content; the parent filter will drop this tab on the
setContents(map); // next refetch tick.
} setFileTexts((s) => ({ ...s, [fileIndex]: "" }));
} catch (err) {
if (!cancelled) {
toast.error(err instanceof Error ? err.message : "加载文件内容失败");
}
} finally { } 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(); // eslint-disable-next-line react-hooks/exhaustive-deps
return () => { }, [markdownFiles, run.id]);
cancelled = true;
};
}, [runId, files]);
// Editor state — separate from displayed source so users can cancel.
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState<string>("");
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(() => { useEffect(() => {
if (activeIndex == null && files.length > 0) { setIsEditing(false);
setActiveIndex(files[0]!.index); setDraft(activeSource);
} }, [activeTabMeta?.value, activeSource]);
if (activeIndex != null && !files.some((f) => f.index === activeIndex)) {
setActiveIndex(files[0]?.index ?? null);
}
}, [activeIndex, files]);
if (files.length === 0) { const saveMutation = useMutation({
return null; 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 = const handleDownloadWord = useCallback(async () => {
activeIndex != null ? files.find((f) => f.index === activeIndex) ?? null : null; if (!activeTabMeta) return;
const activeContent = activeFile ? contents[activeFile.index] : null; 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 ( return (
<div className="flex flex-col gap-3"> <>
{files.length > 1 && ( <CardHeader className="flex flex-wrap items-start justify-between gap-2 space-y-0">
<div className="flex flex-wrap gap-2 border-b pb-2"> <div className="min-w-0 flex-1">
{files.map((file) => { <Tooltip>
const active = file.index === activeIndex; <TooltipTrigger asChild>
return ( <CardTitle className="line-clamp-2 text-base leading-snug break-all">
<button {run.result?.title || "执行记录"}
key={file.index} </CardTitle>
onClick={() => setActiveIndex(file.index)} </TooltipTrigger>
className={cn( {run.result?.title && (
"rounded-md border px-3 py-1 text-xs transition-colors", <TooltipContent side="bottom" className="max-w-[420px] break-all">
active {run.result.title}
? "border-primary bg-primary/10 text-primary" </TooltipContent>
: "bg-card hover:bg-accent/60", )}
)} </Tooltip>
<p className="text-muted-foreground mt-1 text-xs">
开始:{formatDate(run.started_at)} · 完成:{formatDate(run.finished_at)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{isFileTab && fileIndex != null && (
<a
className="bg-background hover:bg-accent inline-flex h-9 items-center gap-1 rounded-md border px-3 text-xs"
href={publicRunFileDownloadUrl(run.id, fileIndex)}
target="_blank"
rel="noopener noreferrer"
>
<Download className="size-4" />
下载原文件
</a>
)}
<Button size="sm" variant="outline" onClick={handleDownloadWord}>
<FileText className="size-4" />
下载 Word
</Button>
{isEditing ? (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
setIsEditing(false);
setDraft(activeSource);
}}
disabled={saveMutation.isPending}
> >
<FileText className="mr-1 inline size-3" /> <X className="size-4" />
{file.name} 取消
</button> </Button>
); <Button
})} size="sm"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Check className="size-4" />
)}
保存
</Button>
</>
) : (
<Button
size="sm"
variant="outline"
onClick={() => {
setDraft(activeSource);
setIsEditing(true);
}}
disabled={fileIsLoading}
>
<Pencil className="size-4" />
编辑
</Button>
)}
</div> </div>
)} </CardHeader>
{activeFile && (
<div className="flex items-center justify-between gap-2"> <CardContent className="min-h-0 flex-1 overflow-hidden p-0">
<div className="text-muted-foreground text-xs"> {tabs.length === 0 ? (
{activeFile.name} <div className="text-muted-foreground p-6 text-sm">无可用内容。</div>
{typeof activeFile.size === "number" ? ` · ${activeFile.size} bytes` : ""}
</div>
<a
className="bg-background hover:bg-accent inline-flex h-7 items-center gap-1 rounded-md border px-2 text-xs"
href={publicRunFileDownloadUrl(runId, activeFile.index)}
target="_blank"
rel="noopener noreferrer"
>
<Download className="size-3" />
下载
</a>
</div>
)}
{loading && !activeContent && (
<div className="text-muted-foreground text-sm">正在加载内容...</div>
)}
{activeContent ? (
activeContent.previewable && activeContent.content ? (
<MarkdownContent
className="max-w-none"
content={activeContent.content}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
) : ( ) : (
<div className="text-muted-foreground rounded-md border p-3 text-sm"> <Tabs
无法预览此 Markdown 文件,请下载查看。 value={activeTab}
</div> onValueChange={setActiveTab}
) className="flex h-full min-h-0 flex-col gap-0 px-4 pb-3"
) : !loading ? ( >
<div className="text-muted-foreground text-sm">未加载到内容。</div> <TabsList
) : null} variant="line"
</div> className="bg-card sticky top-0 z-10 h-auto flex-wrap justify-start gap-1 border-b py-1"
>
{tabs.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="data-[state=active]:bg-accent/60 max-w-[220px]"
title={tab.label}
>
<span className="truncate">{tab.label}</span>
</TabsTrigger>
))}
</TabsList>
<div className="flex min-h-0 flex-1 flex-col pt-3">
{run.error && (
<div className="text-destructive border-destructive/30 bg-destructive/5 mb-3 rounded-md border p-2 text-xs">
{run.error}
</div>
)}
{fileIsLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
正在加载文件内容...
</div>
) : isEditing ? (
<Textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
className="min-h-0 flex-1 resize-none font-mono text-sm"
spellCheck={false}
/>
) : (
<ScrollArea className="min-h-0 flex-1">
{showEmptyHint ? (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
无内容。点击「编辑」开始添加。
</div>
) : (
<MarkdownContent
className="max-w-none pr-2"
content={activeSource}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
)}
</ScrollArea>
)}
</div>
</Tabs>
)}
</CardContent>
</>
); );
} }
@@ -1,4 +1,4 @@
"""Public (unauthenticated) read-only API for scheduled tasks. """Public (unauthenticated) API for scheduled tasks.
Mounted under the ``/api/public/`` prefix so the auth middleware lets these Mounted under the ``/api/public/`` prefix so the auth middleware lets these
routes through without a session cookie. Intended for embedding scheduled routes through without a session cookie. Intended for embedding scheduled
@@ -12,9 +12,14 @@ Surface area:
- ``GET /api/public/scheduled-tasks/{task_id}/runs`` — runs for a task (latest first) - ``GET /api/public/scheduled-tasks/{task_id}/runs`` — runs for a task (latest first)
- ``GET /api/public/scheduled-tasks/runs/{run_id}`` — single run (result included) - ``GET /api/public/scheduled-tasks/runs/{run_id}`` — single run (result included)
- ``GET /api/public/scheduled-tasks/runs/{run_id}/files/{i}``— inline file content / preview - ``GET /api/public/scheduled-tasks/runs/{run_id}/files/{i}``— inline file content / preview
- ``PUT /api/public/scheduled-tasks/runs/{run_id}/files/{i}``— overwrite an editable result file
- ``PUT /api/public/scheduled-tasks/runs/{run_id}/body`` — overwrite the inline Markdown body
- ``GET /api/public/scheduled-tasks/runs/{run_id}/download`` — zip / md download of result - ``GET /api/public/scheduled-tasks/runs/{run_id}/download`` — zip / md download of result
These are read-only. No write/mutation routes belong here. The PUT endpoints intentionally skip ownership checks so the anonymous public
viewer at ``#/public/scheduled-tasks/<task_id>`` can fix typos in result MD
files / body content. Tighten this if the deployment requires read-only
sharing.
""" """
from __future__ import annotations from __future__ import annotations
@@ -30,6 +35,7 @@ from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse, Response, StreamingResponse from fastapi.responses import FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field
from deerflow.runtime.scheduler import get_scheduled_task_service from deerflow.runtime.scheduler import get_scheduled_task_service
@@ -39,6 +45,17 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/public/scheduled-tasks", tags=["public-scheduled-tasks"]) router = APIRouter(prefix="/api/public/scheduled-tasks", tags=["public-scheduled-tasks"])
_MAX_PUBLIC_EDIT_BYTES = 5 * 1024 * 1024 # 5 MB — mirrors the authenticated edit cap
class PublicRunFileUpdateRequest(BaseModel):
content: str = Field(..., description="New text content to write into the run file")
class PublicRunBodyUpdateRequest(BaseModel):
content: str = Field(..., description="New Markdown body for the run's result")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Sanitisation helpers # Sanitisation helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -93,6 +110,15 @@ def _redact_run(run: dict[str, Any]) -> dict[str, Any]:
# to fetch content via the public file endpoint. # to fetch content via the public file endpoint.
public_file = {k: v for k, v in item.items() if k != "path"} public_file = {k: v for k, v in item.items() if k != "path"}
public_file["index"] = idx public_file["index"] = idx
# Mark whether the underlying archive is still on disk. The
# public viewer filters out tabs whose source is gone so users
# never see the raw "Result file is no longer available" 404.
path_value = item.get("path")
public_file["available"] = (
isinstance(path_value, str)
and bool(path_value)
and Path(path_value).is_file()
)
cleaned_files.append(public_file) cleaned_files.append(public_file)
result = {**result, "files": cleaned_files} result = {**result, "files": cleaned_files}
cleaned["result"] = result cleaned["result"] = result
@@ -302,3 +328,43 @@ async def public_download_run(run_id: str):
media_type="application/zip", media_type="application/zip",
headers={"Content-Disposition": _content_disposition(f"{base_name}.zip")}, headers={"Content-Disposition": _content_disposition(f"{base_name}.zip")},
) )
@router.put("/runs/{run_id}/files/{file_index}")
async def public_update_run_file(
run_id: str,
file_index: int,
body: PublicRunFileUpdateRequest,
) -> dict[str, Any]:
"""Overwrite an MD/text result file on disk and refresh its size in the run row.
Anonymous — no ownership check. See module docstring for the rationale.
"""
if len(body.content.encode("utf-8")) > _MAX_PUBLIC_EDIT_BYTES:
raise HTTPException(status_code=413, detail="Edited content exceeds the 5 MB limit")
service = get_scheduled_task_service()
try:
return await service.admin_update_run_file(run_id, file_index, body.content)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except PermissionError as exc:
# Surfaces archive-path escape attempts. Treat as 400 since the caller is
# already trusted to be reaching a real run row.
raise HTTPException(status_code=400, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.put("/runs/{run_id}/body")
async def public_update_run_body(run_id: str, body: PublicRunBodyUpdateRequest) -> dict[str, Any]:
"""Overwrite the inline Markdown body (``result.content``) of a run.
Anonymous — no ownership check. Used by the public viewer's "正文" tab.
"""
if len(body.content.encode("utf-8")) > _MAX_PUBLIC_EDIT_BYTES:
raise HTTPException(status_code=413, detail="Edited content exceeds the 5 MB limit")
service = get_scheduled_task_service()
try:
return await service.admin_update_run_body(run_id, body.content)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
@@ -305,12 +305,51 @@ class ScheduledTaskService:
""" """
run = await self.store.get_run(run_id, user_id) run = await self.store.get_run(run_id, user_id)
if run is None: if run is None:
# Either the run doesn't exist, or the caller is a subscriber, not owner.
candidate = await self.store.get_run_any(run_id) candidate = await self.store.get_run_any(run_id)
if candidate is None: if candidate is None:
raise FileNotFoundError(f"Run {run_id} not found") raise FileNotFoundError(f"Run {run_id} not found")
raise PermissionError("Only the run owner may edit result files") raise PermissionError("Only the run owner may edit result files")
return await self._apply_run_file_edit(run, run_id, file_index, new_content)
async def admin_update_run_file(
self,
run_id: str,
file_index: int,
new_content: str,
) -> dict[str, Any]:
"""Owner-less variant of :meth:`update_run_file` used by the public viewer.
The public scheduled-task page is unauthenticated, so it cannot supply a
``user_id``. Mutations from that page route here instead. The disk-write
and result-row update logic is identical to the owner path.
"""
run = await self.store.get_run_any(run_id)
if run is None:
raise FileNotFoundError(f"Run {run_id} not found")
return await self._apply_run_file_edit(run, run_id, file_index, new_content)
async def admin_update_run_body(self, run_id: str, new_content: str) -> dict[str, Any]:
"""Overwrite the inline Markdown body (``result.content``) of a run.
Used by the public scheduled-task viewer so anonymous editors can fix
typos in the "正文" tab. Unlike :meth:`admin_update_run_file`, no disk
artifact backs the body — it lives entirely in the run row.
"""
run = await self.store.get_run_any(run_id)
if run is None:
raise FileNotFoundError(f"Run {run_id} not found")
result = run.get("result") if isinstance(run.get("result"), dict) else {}
new_result = {**result, "content": new_content}
await self.store.update_run(run_id, {"result": new_result})
return {"content": new_content}
async def _apply_run_file_edit(
self,
run: dict[str, Any],
run_id: str,
file_index: int,
new_content: str,
) -> dict[str, Any]:
result = run.get("result") or {} result = run.get("result") or {}
files = result.get("files") if isinstance(result, dict) else None files = result.get("files") if isinstance(result, dict) else None
if not isinstance(files, list) or file_index < 0 or file_index >= len(files): if not isinstance(files, list) or file_index < 0 or file_index >= len(files):
+1 -1
View File
@@ -148,7 +148,7 @@ memory:
hindsight: # provider=hindsight 时生效,需另行部署 Hindsight 服务 hindsight: # provider=hindsight 时生效,需另行部署 Hindsight 服务
mode: local_external # 'cloud' | 'local_embedded' | 'local_external' mode: local_external # 'cloud' | 'local_embedded' | 'local_external'
api_url: http://localhost:8765 api_url: http://47.88.25.99:18888
api_key: "" # 部署 Hindsight 后填,或改成 $HINDSIGHT_API_KEY 并设同名环境变量 api_key: "" # 部署 Hindsight 后填,或改成 $HINDSIGHT_API_KEY 并设同名环境变量
# 注意:写成 $VAR 但环境变量未设置会导致配置加载失败 # 注意:写成 $VAR 但环境变量未设置会导致配置加载失败
bank_id_template: "deerflow-user-{user_id}" bank_id_template: "deerflow-user-{user_id}"