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:
1 parent
f1d58983c2
commit
53bbb9b15b
5 files changed
+573
-308
No files matched your search
+1
-1
@@ -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/
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
+40
-1
@@ -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):
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
Reference in new issue
Block a user