feat: 公开定时任务页面、Word 导出、定时任务实时刷新与技能详情重构

- 新增 /public/scheduled-tasks/:taskId 公开只读访问页与对应的
  /api/public/scheduled-tasks 后端路由(无需登录)
- 新增对话整体 / 单条消息 / Markdown 工件 "另存为 Word (.docx)" 能力
- 定时任务列表与运行列表在任务执行中自动轮询刷新,并新增"正在执行"标记
- 定时任务运行消息接口在事件存储为空时回退到 LangGraph checkpoint,
  让正在执行的任务也能立即看到提示词与流式输出
- 技能详情对话框由折叠面板改为 Tab 切换(Detail / SKILL.md),
  并支持 Rendered / Raw 切换、一键复制
- config.yaml 增加 database (MySQL) 与 checkpointer (SQLite) 配置块

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
吉鑫升andClaude Opus 4.7 committed 2026-05-20 17:04:58 +08:00
1 parent 7e75c5b9b9
commit d339db4799
18 files changed
+1617 -125

No files matched your search

+6
View File
@@ -13,6 +13,7 @@ import { HashRouter, useLocation } from "react-router-dom";
import LandingPage from "./pages/LandingPage";
import LoginPage from "./pages/LoginPage";
import PageRoutes from "./pages/PageRoutes";
import PublicScheduledTaskPage from "./pages/PublicScheduledTaskPage";
import PublicSharePage from "./pages/PublicSharePage";
import { NotFoundPage } from "./pages/NotFoundPage";
@@ -50,6 +51,11 @@ export function App() {
<Route path="/landing" element={<LandingPage />} />
{/* Public read-only conversation viewer — no authentication. */}
<Route path="/share/:shareCode" element={<PublicSharePage />} />
{/* Public read-only scheduled-task runs viewer — no authentication. */}
<Route
path="/public/scheduled-tasks/:taskId"
element={<PublicScheduledTaskPage />}
/>
<Route path="/page/*" element={<PageRoutes />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
@@ -6,6 +6,7 @@ import {
CopyIcon,
DownloadIcon,
EyeIcon,
FileType2Icon,
LoaderIcon,
PackageIcon,
SaveIcon,
@@ -44,6 +45,7 @@ import {
} from "@/core/artifacts/hooks";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { useI18n } from "@/core/i18n/hooks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { installSkill } from "@/core/skills/api";
import { streamdownPlugins } from "@/core/streamdown";
import { checkCodeFile, getFileName } from "@/core/utils/files";
@@ -377,6 +379,26 @@ export function ArtifactFileDetail({
updateArtifact,
]);
const [isExportingWord, setIsExportingWord] = useState(false);
const handleDownloadAsWord = useCallback(async () => {
const source = (displayContent ?? "").trim();
if (!source) {
toast.error("Markdown 内容为空,无法另存为 Word");
return;
}
setIsExportingWord(true);
try {
const baseName = getFileName(filepath).replace(/\.(md|markdown)$/i, "");
await downloadMarkdownAsDocx(source, baseName || "document", baseName);
toast.success("已另存为 Word");
} catch (error) {
console.error("Failed to export markdown as docx:", error);
toast.error(error instanceof Error ? error.message : "另存为 Word 失败");
} finally {
setIsExportingWord(false);
}
}, [displayContent, filepath]);
return (
<Artifact className={cn(className)}>
<ArtifactHeader className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 px-4 py-3">
@@ -518,6 +540,15 @@ export function ArtifactFileDetail({
tooltip={t.clipboard.copyToClipboard}
/>
)}
{!isWriteFile && language === "markdown" && (
<ArtifactAction
icon={isExportingWord ? LoaderIcon : FileType2Icon}
label="另存为 Word"
tooltip="另存为 Word (.docx)"
disabled={isExportingWord || !displayContent}
onClick={handleDownloadAsWord}
/>
)}
{!isWriteFile && (
<ArtifactAction
icon={DownloadIcon}
@@ -1,6 +1,6 @@
"use client";
import { Download, FileJson, FileText } from "lucide-react";
import { Download, FileJson, FileText, FileType2 } from "lucide-react";
import { useCallback } from "react";
import { toast } from "sonner";
@@ -15,6 +15,7 @@ import { useI18n } from "@/core/i18n/hooks";
import {
exportThreadAsJSON,
exportThreadAsMarkdown,
exportThreadAsWord,
} from "@/core/threads/export";
import type { AgentThread } from "@/core/threads/types";
@@ -28,7 +29,7 @@ export function ExportTrigger({ threadId }: { threadId: string }) {
const messages = thread.messages;
const handleExport = useCallback(
(format: "markdown" | "json") => {
async (format: "markdown" | "json" | "word") => {
if (messages.length === 0) {
toast.error(t.conversation.noMessages);
return;
@@ -39,12 +40,18 @@ export function ExportTrigger({ threadId }: { threadId: string }) {
values: thread.values,
} as AgentThread;
if (format === "markdown") {
exportThreadAsMarkdown(agentThread, messages);
} else {
exportThreadAsJSON(agentThread, messages);
try {
if (format === "markdown") {
exportThreadAsMarkdown(agentThread, messages);
} else if (format === "json") {
exportThreadAsJSON(agentThread, messages);
} else {
await exportThreadAsWord(agentThread, messages);
}
toast.success(t.common.exportSuccess);
} catch (error) {
toast.error(error instanceof Error ? error.message : "导出失败");
}
toast.success(t.common.exportSuccess);
},
[messages, thread.values, threadId, t],
);
@@ -67,11 +74,15 @@ export function ExportTrigger({ threadId }: { threadId: string }) {
</DropdownMenuTrigger>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => handleExport("markdown")}>
<DropdownMenuItem onSelect={() => void handleExport("markdown")}>
<FileText className="text-muted-foreground" />
<span>{t.common.exportAsMarkdown}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => handleExport("json")}>
<DropdownMenuItem onSelect={() => void handleExport("word")}>
<FileType2 className="text-muted-foreground" />
<span>{t.common.exportAsWord}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void handleExport("json")}>
<FileJson className="text-muted-foreground" />
<span>{t.common.exportAsJSON}</span>
</DropdownMenuItem>
@@ -0,0 +1,55 @@
import { FileText, Loader2 } from "lucide-react";
import { useState, type ComponentProps } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { Tooltip } from "../tooltip";
export function DownloadWordButton({
markdown,
fileName,
title,
...props
}: ComponentProps<typeof Button> & {
markdown: string;
fileName?: string;
title?: string;
}) {
const [busy, setBusy] = useState(false);
const handleDownload = async () => {
const source = markdown?.trim();
if (!source) {
toast.error("无可下载的 Markdown 内容");
return;
}
setBusy(true);
try {
await downloadMarkdownAsDocx(source, fileName || "message", title);
toast.success("Word 文件已生成");
} catch (error) {
toast.error(error instanceof Error ? error.message : "生成 Word 失败");
} finally {
setBusy(false);
}
};
return (
<Tooltip content="下载为 Word">
<Button
size="icon-sm"
type="button"
variant="ghost"
disabled={busy}
onClick={handleDownload}
{...props}
>
{busy ? (
<Loader2 size={12} className="animate-spin" />
) : (
<FileText size={12} />
)}
</Button>
</Tooltip>
);
}
@@ -42,6 +42,7 @@ import { cn } from "@/lib/utils";
import { CopyButton } from "../copy-button";
import { ThreadContext } from "./context";
import { DownloadWordButton } from "./download-word-button";
import { MarkdownContent } from "./markdown-content";
import { MessageTokenUsage } from "./message-token-usage";
@@ -82,6 +83,10 @@ export function MessageListItem({
tokenUsageEnabled?: boolean;
}) {
const isHuman = message.type === "human";
const messageMarkdown =
extractContentFromMessage(message) ??
extractReasoningContentFromMessage(message) ??
"";
return (
<AIElementMessage
className={cn("group/conversation-message relative w-full", className)}
@@ -102,13 +107,13 @@ export function MessageListItem({
)}
>
<div className="flex gap-1">
<CopyButton
clipboardData={
extractContentFromMessage(message) ??
extractReasoningContentFromMessage(message) ??
""
}
/>
<CopyButton clipboardData={messageMarkdown} />
{!isHuman && messageMarkdown && (
<DownloadWordButton
markdown={messageMarkdown}
fileName="message"
/>
)}
</div>
</MessageToolbar>
)}
@@ -1,7 +1,8 @@
"use client";
import {
ChevronDownIcon,
CopyIcon,
EyeIcon,
FileCode2Icon,
FileTextIcon,
GlobeIcon,
@@ -14,11 +15,6 @@ import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
@@ -26,6 +22,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { getStoredAuth } from "@/core/auth";
@@ -37,17 +34,21 @@ import {
} from "@/core/skills/hooks";
import type { Skill } from "@/core/skills/type";
import { reasoningPlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
/**
* Whether the current user may edit a skill's detail text and view its raw
* SKILL.md source.
*
* Admins qualify for any skill; the publisher (owner of a custom skill) for
* their own. Built-in skills have no human owner, so only admins qualify.
* Mirrors the backend authz in `PUT /api/skills/{name}/detail` and
* `GET /api/skills/{name}/content`.
*/
const markdownClassName =
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
"[&_h1]:mt-0 [&_h1]:mb-3 [&_h1]:text-xl [&_h1]:font-semibold " +
"[&_h2]:mt-6 [&_h2]:mb-2 [&_h2]:border-b [&_h2]:pb-2 [&_h2]:text-lg [&_h2]:font-semibold " +
"[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold " +
"[&_p]:my-3 [&_ul]:my-3 [&_ol]:my-3 [&_li]:my-1.5 " +
"[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 " +
"[&_blockquote]:my-4 [&_blockquote]:border-l-4 [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground " +
"[&_code]:rounded [&_code]:border [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs " +
"[&_pre]:my-4 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-muted [&_pre]:p-4 " +
"[&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0 " +
"[&_table]:my-4 [&_table]:w-full [&_table]:min-w-max [&_table]:border-collapse " +
"[&_th]:border [&_th]:bg-muted [&_th]:px-3 [&_th]:py-2 [&_td]:border [&_td]:px-3 [&_td]:py-2";
function canManageSkill(skill: Skill | null | undefined): boolean {
if (!skill) return false;
const auth = getStoredAuth();
@@ -60,12 +61,10 @@ function canManageSkill(skill: Skill | null | undefined): boolean {
);
}
/**
* Reusable skill detail dialog. Given a skill name it fetches the skill
* metadata (including the admin/owner-authored `detail`) and lets authorized
* users edit the detail inline. The raw SKILL.md source is shown only to the
* publisher or an admin — everyone else sees just the description and detail.
*/
function stripYamlFrontmatter(content: string): string {
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trimStart();
}
export function SkillDetailDialog({
skillName,
open,
@@ -77,33 +76,38 @@ export function SkillDetailDialog({
}) {
const { t } = useI18n();
const tx = t.settings.skills;
// Only fetch while the dialog is open to avoid background requests.
const activeName = open ? skillName : null;
const { data: skill, isLoading: isSkillLoading } = useSkill(activeName);
const setDetail = useSetSkillDetail();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const [showSource, setShowSource] = useState(false);
const [activeTab, setActiveTab] = useState<"detail" | "source" | null>(
null,
);
const [showRawSource, setShowRawSource] = useState(false);
const canManage = useMemo(() => canManageSkill(skill), [skill]);
// The SKILL.md source endpoint is publisher/admin-only — only request it
// when the viewer is actually allowed to see it.
const {
data: contentData,
isLoading: isContentLoading,
error: contentError,
} = useSkillContent(open && canManage ? skillName : null);
// Reset transient UI whenever the dialog closes or switches to another skill.
useEffect(() => {
if (!open) {
setEditing(false);
setShowSource(false);
}
setEditing(false);
setActiveTab(null);
setShowRawSource(false);
}, [open, skillName]);
const skillBody = useMemo(() => {
return stripYamlFrontmatter(contentData?.content ?? "");
}, [contentData]);
const defaultTab: "detail" | "source" =
canManage && skill && !skill.detail ? "source" : "detail";
const selectedTab = activeTab ?? defaultTab;
function startEditing() {
setDraft(skill?.detail ?? "");
setEditing(true);
@@ -120,11 +124,18 @@ export function SkillDetailDialog({
}
}
async function copySource() {
await navigator.clipboard.writeText(contentData?.content ?? "");
toast.success("Copied SKILL.md content");
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl">
{/* Header */}
<DialogHeader className="space-y-0 border-b px-6 py-4 text-left">
<DialogContent
className="flex w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-3xl"
style={{ height: "min(760px, calc(100dvh - 2rem))" }}
>
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
<div className="flex items-start gap-3">
<div className="bg-primary/10 text-primary flex size-9 shrink-0 items-center justify-center rounded-lg">
<SparklesIcon className="size-5" />
@@ -142,49 +153,110 @@ export function SkillDetailDialog({
{skill?.published && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
className="border-sky-500/30 text-xs text-sky-700 dark:text-sky-300"
>
<GlobeIcon className="size-3" />
{tx.badgePublished}
</Badge>
)}
</div>
<DialogDescription className="mt-1 text-sm leading-relaxed">
{skill?.description || "—"}
<DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed">
{skill?.description || "-"}
</DialogDescription>
</div>
</div>
</DialogHeader>
{/* Body */}
<div className="min-h-0 flex-1 space-y-5 overflow-auto px-6 py-5">
{/* Detail — human-authored long-form introduction */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-foreground/90 flex items-center gap-1.5 text-sm font-semibold">
<FileTextIcon className="text-muted-foreground size-4" />
{isSkillLoading || !skill ? (
<div className="flex min-h-0 flex-1 items-center justify-center px-6 py-10">
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
</div>
) : (
<Tabs
value={selectedTab}
onValueChange={(value) =>
setActiveTab(value as Exclude<typeof activeTab, null>)
}
className="flex min-h-0 flex-1 flex-col gap-0"
>
<div className="flex shrink-0 items-center justify-between gap-2 border-b px-6 py-2">
<TabsList className="h-8">
<TabsTrigger value="detail" className="text-xs">
<FileTextIcon className="mr-1 size-3.5" />
{tx.detailSectionTitle}
</h3>
{canManage && !editing && (
</TabsTrigger>
{canManage && (
<TabsTrigger value="source" className="text-xs">
<FileCode2Icon className="mr-1 size-3.5" />
{tx.contentSectionTitle}
</TabsTrigger>
)}
</TabsList>
<div className="flex shrink-0 items-center gap-1">
{selectedTab === "detail" && canManage && !editing && (
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground h-7 px-2"
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
onClick={startEditing}
>
<PencilIcon className="size-3.5" />
{tx.detailEditAction}
</Button>
)}
{selectedTab === "source" &&
canManage &&
!isContentLoading &&
!contentError && (
<>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
onClick={() => setShowRawSource((value) => !value)}
>
{showRawSource ? (
<>
<EyeIcon className="size-3.5" />
Rendered
</>
) : (
<>
<FileCode2Icon className="size-3.5" />
Raw
</>
)}
</Button>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
onClick={() => void copySource()}
>
<CopyIcon className="size-3.5" />
Copy
</Button>
</>
)}
</div>
</div>
<TabsContent
value="detail"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
style={{ overflowY: "auto" }}
>
{editing ? (
<div className="space-y-2">
<Textarea
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
onChange={(event) => setDraft(event.currentTarget.value)}
placeholder={tx.detailPlaceholder}
className="max-h-[320px] min-h-44 resize-y overflow-y-auto text-sm leading-relaxed"
className="max-h-[420px] min-h-44 resize-y overflow-y-auto text-sm leading-relaxed"
autoFocus
/>
<div className="flex items-center justify-end gap-2">
@@ -208,14 +280,10 @@ export function SkillDetailDialog({
</Button>
</div>
</div>
) : isSkillLoading ? (
<div className="text-muted-foreground flex items-center gap-2 rounded-lg border border-dashed px-4 py-8 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : skill?.detail ? (
<div className="bg-muted/30 rounded-lg border p-4 text-sm leading-relaxed">
<div className="bg-muted/30 min-w-0 rounded-lg border p-4">
<MarkdownContent
className={markdownClassName}
content={skill.detail}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
@@ -226,53 +294,51 @@ export function SkillDetailDialog({
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center">
<FileTextIcon className="mx-auto mb-2 size-6 opacity-40" />
<p className="text-sm">{tx.detailEmpty}</p>
{!canManage && (
{canManage ? (
<p className="mt-1 text-xs">
Open the SKILL.md tab to view the skill source.
</p>
) : (
<p className="mt-1 text-xs">{tx.detailAdminOnlyHint}</p>
)}
</div>
)}
</section>
</TabsContent>
{/* SKILL.md source — publisher / admin only, collapsed by default */}
{canManage && (
<section>
<Collapsible open={showSource} onOpenChange={setShowSource}>
<CollapsibleTrigger className="bg-muted/30 hover:bg-muted/60 flex w-full items-center justify-between rounded-lg border px-4 py-2.5 text-sm font-semibold transition-colors">
<span className="flex items-center gap-1.5">
<FileCode2Icon className="text-muted-foreground size-4" />
{tx.contentSectionTitle}
</span>
<ChevronDownIcon
className={cn(
"text-muted-foreground size-4 transition-transform",
showSource && "rotate-180",
)}
/>
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<div className="bg-muted/50 max-h-[340px] overflow-auto rounded-lg border">
{isContentLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : contentError ? (
<div className="text-destructive p-4 text-sm">
{contentError instanceof Error
? contentError.message
: String(contentError)}
</div>
) : (
<pre className="text-foreground/80 p-4 font-mono text-xs leading-relaxed break-words whitespace-pre-wrap">
{contentData?.content}
</pre>
)}
</div>
</CollapsibleContent>
</Collapsible>
</section>
<TabsContent
value="source"
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
style={{ overflowY: "auto" }}
>
{isContentLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : contentError ? (
<div className="text-destructive py-6 text-sm">
{contentError instanceof Error
? contentError.message
: String(contentError)}
</div>
) : showRawSource ? (
<pre className="text-foreground/80 max-w-full overflow-x-auto rounded-lg border bg-muted p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
{contentData?.content}
</pre>
) : (
<MarkdownContent
className={markdownClassName}
content={skillBody || "(SKILL.md is empty)"}
isLoading={false}
remarkPlugins={reasoningPlugins.remarkPlugins}
rehypePlugins={reasoningPlugins.rehypePlugins}
/>
)}
</TabsContent>
)}
</div>
</Tabs>
)}
</DialogContent>
</Dialog>
);
@@ -48,6 +48,7 @@ export const enUS: Translations = {
export: "Export",
exportAsMarkdown: "Export as Markdown",
exportAsJSON: "Export as JSON",
exportAsWord: "Export as Word",
exportSuccess: "Conversation exported",
},
@@ -49,6 +49,7 @@ export interface Translations {
export: string;
exportAsMarkdown: string;
exportAsJSON: string;
exportAsWord: string;
exportSuccess: string;
};
@@ -48,6 +48,7 @@ export const zhCN: Translations = {
export: "导出",
exportAsMarkdown: "导出为 Markdown",
exportAsJSON: "导出为 JSON",
exportAsWord: "导出为 Word",
exportSuccess: "对话已导出",
},
@@ -41,6 +41,11 @@ export function useSchedulerThread() {
});
}
/** A task is currently executing — used to drive live polling of the task list. */
function taskIsLive(status?: string | null): boolean {
return status === "running" || status === "pending";
}
export function useScheduledTasks(
params: { search?: string; tagIds?: string[] } = {},
) {
@@ -51,6 +56,12 @@ export function useScheduledTasks(
queryFn: () => listScheduledTasks({ search, tagIds }),
refetchOnWindowFocus: false,
placeholderData: keepPreviousData,
// Poll the list while any task is currently executing so its last_status
// and last_run_at refresh without a manual reload.
refetchInterval: (query) =>
(query.state.data ?? []).some((task) => taskIsLive(task.last_status))
? 5000
: false,
});
}
@@ -60,6 +71,13 @@ export function useScheduledTaskRuns(taskId?: string | null) {
queryFn: () => listScheduledTaskRuns(taskId!),
enabled: !!taskId,
refetchOnWindowFocus: false,
// Poll the run list while any run is in flight so the user can watch
// status transitions (pending → running → succeeded/failed) without
// refreshing the page.
refetchInterval: (query) =>
(query.state.data ?? []).some((run) => runIsLive(run.status))
? 4000
: false,
});
}
@@ -113,6 +131,11 @@ export function useAllScheduledTasks(
enabled: params.enabled ?? true,
refetchOnWindowFocus: false,
placeholderData: keepPreviousData,
// Mirror the user list polling so the admin view also refreshes live.
refetchInterval: (query) =>
(query.state.data ?? []).some((task) => taskIsLive(task.last_status))
? 5000
: false,
});
}
@@ -123,6 +146,11 @@ export function useAdminScheduledTaskRuns(taskId?: string | null) {
queryFn: () => adminListScheduledTaskRuns(taskId!),
enabled: !!taskId,
refetchOnWindowFocus: false,
// Poll while any run is still executing so admins see live progress.
refetchInterval: (query) =>
(query.state.data ?? []).some((run) => runIsLive(run.status))
? 4000
: false,
});
}
+16
View File
@@ -1,5 +1,7 @@
import type { Message } from "@langchain/langgraph-sdk";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import {
extractContentFromMessage,
extractReasoningContentFromMessage,
@@ -138,3 +140,17 @@ export function exportThreadAsJSON(thread: AgentThread, messages: Message[]) {
const filename = `${sanitizeFilename(titleOfThread(thread))}.json`;
downloadAsFile(json, filename, "application/json;charset=utf-8");
}
/**
* Export the conversation as a Word document (.docx). The whole thread is
* serialised to Markdown first (same formatter the `.md` export uses), and
* then converted to a docx Blob in the browser via `markdownToDocxBlob`.
*/
export async function exportThreadAsWord(
thread: AgentThread,
messages: Message[],
): Promise<void> {
const markdown = formatThreadAsMarkdown(thread, messages);
const title = titleOfThread(thread);
await downloadMarkdownAsDocx(markdown, sanitizeFilename(title), title);
}
@@ -0,0 +1,527 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { Download, FileText, Loader2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { toast } from "sonner";
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 { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { getBackendBaseURL } from "@/core/config";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { reasoningPlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
type PublicTask = {
task_id: string;
name: string;
schedule_text: string;
cron_expr?: string;
timezone: string;
enabled: boolean;
next_run_at?: string | null;
last_run_at?: string | null;
last_status?: string | null;
published?: boolean;
};
type PublicRunFile = {
index: number;
name: string;
mime_type?: string | null;
kind?: string | null;
size?: number | null;
};
type PublicRunResult = {
title: string;
content: string;
format: "markdown" | "text" | "json" | "mixed";
files: PublicRunFile[];
created_at: string;
};
type PublicRun = {
id: string;
task_id: string;
scheduled_for: string;
started_at?: string | null;
finished_at?: string | null;
status: "pending" | "running" | "succeeded" | "failed" | string;
error?: string | null;
result?: PublicRunResult | null;
retry_count?: number | null;
created_at?: string | null;
updated_at?: string | null;
};
type PublicRunsResponse = {
task: PublicTask;
total: number;
runs: PublicRun[];
};
type PublicFileContent = {
index: number;
name: string;
mime_type?: string | null;
kind?: string | null;
size?: number | null;
previewable: boolean;
content?: string | null;
};
function publicBaseUrl() {
return `${getBackendBaseURL()}/api/public/scheduled-tasks`;
}
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
if (!response.ok) {
const body = await response.json().catch(() => ({ detail: "Request failed" }));
throw new Error(typeof body.detail === "string" ? body.detail : "Request failed");
}
return response.json() as Promise<T>;
}
async function fetchPublicTaskRuns(taskId: string): Promise<PublicRunsResponse> {
return fetchJson<PublicRunsResponse>(`${publicBaseUrl()}/${encodeURIComponent(taskId)}/runs?limit=200`);
}
async function fetchPublicRunFile(runId: string, index: number): Promise<PublicFileContent> {
return fetchJson<PublicFileContent>(
`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/files/${index}`,
);
}
function publicRunDownloadUrl(runId: string): string {
return `${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/download`;
}
function publicRunFileDownloadUrl(runId: string, index: number): string {
return `${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/files/${index}/download`;
}
function statusLabel(status?: string | null) {
switch (status) {
case "succeeded":
return "成功";
case "failed":
return "失败";
case "running":
return "执行中";
case "pending":
return "等待中";
default:
return status || "未知";
}
}
function statusVariant(status?: string | null): "default" | "secondary" | "destructive" | "outline" {
if (status === "succeeded") return "default";
if (status === "failed") return "destructive";
if (status === "running") return "secondary";
return "outline";
}
function formatDate(value?: string | null): string {
return value ? new Date(value).toLocaleString() : "暂无";
}
function isMarkdownFile(file: { name: string; kind?: string | null; mime_type?: string | null }): boolean {
const name = file.name.toLowerCase();
return (
file.kind === "markdown" ||
file.mime_type === "text/markdown" ||
name.endsWith(".md") ||
name.endsWith(".markdown")
);
}
/**
* Public, unauthenticated scheduled-task viewer.
*
* URL: `#/public/scheduled-tasks/<task_id>`
*
* 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.
*/
export default function PublicScheduledTaskPage() {
const { taskId } = useParams<{ taskId: string }>();
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const { data, isLoading, isError, error } = useQuery({
queryKey: ["public-scheduled-task", taskId],
queryFn: () => fetchPublicTaskRuns(taskId!),
enabled: !!taskId,
retry: false,
refetchOnWindowFocus: false,
refetchInterval: 8000,
});
const runs = useMemo(() => data?.runs ?? [], [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);
}
if (selectedRunId && !runs.some((r) => r.id === selectedRunId) && runs.length > 0) {
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 (
<div className="text-muted-foreground flex h-screen items-center justify-center text-sm">
缺少任务 ID。请使用 <code className="mx-1">/public/scheduled-tasks/&lt;task_id&gt;</code>。
</div>
);
}
if (isLoading) {
return (
<div className="text-muted-foreground flex h-screen items-center justify-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
正在加载任务记录...
</div>
);
}
if (isError) {
return (
<div className="text-destructive flex h-screen items-center justify-center text-sm">
加载失败:{error instanceof Error ? error.message : "未知错误"}
</div>
);
}
const task = data?.task;
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)]">
<Card className="flex min-h-0 flex-col">
<CardHeader className="gap-1">
<div className="flex items-center justify-between gap-2">
<CardTitle className="truncate text-base">
{task?.name || "任务"}
</CardTitle>
<Badge variant={task?.enabled ? "default" : "secondary"} className="shrink-0">
{task?.enabled ? "启用" : "暂停"}
</Badge>
</div>
<p className="text-muted-foreground truncate text-xs">
{task?.schedule_text} · {task?.timezone}
</p>
<p className="text-muted-foreground text-xs">
最近状态:{statusLabel(task?.last_status)} · 上次:{formatDate(task?.last_run_at)}
</p>
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<ScrollArea className="size-full">
<div className="flex flex-col gap-2 p-3 pt-0">
{runs.length === 0 && (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
这个任务还没有执行记录。
</div>
)}
{runs.map((run) => {
const active = run.id === selectedRunId;
const runRunning = run.status === "running" || run.status === "pending";
return (
<button
key={run.id}
className={cn(
"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">
<div className="truncate text-sm font-medium">
{run.result?.title || formatDate(run.scheduled_for)}
</div>
<div className="text-muted-foreground mt-1 text-xs">
计划:{formatDate(run.scheduled_for)}
</div>
</div>
<Badge
variant={statusVariant(run.status)}
className={cn(runRunning && "border-primary/30 bg-primary/10 text-primary")}
>
{runRunning && <Loader2 className="size-3 animate-spin" />}
{statusLabel(run.status)}
</Badge>
</div>
</button>
);
})}
</div>
</ScrollArea>
</CardContent>
</Card>
<Card className="flex min-h-0 flex-col">
<CardHeader className="flex flex-wrap items-center justify-between gap-2 space-y-0">
<div className="min-w-0">
<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">
请在左侧选择一条执行记录。
</div>
)}
</CardContent>
</Card>
</div>
);
}
/**
* 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.
*/
function MarkdownFilesPane({
runId,
files,
}: {
runId: string;
files: PublicRunFile[];
}) {
const [contents, setContents] = useState<Record<number, PublicFileContent>>({});
const [loading, setLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
useEffect(() => {
let cancelled = false;
async function loadAll() {
if (files.length === 0) {
setContents({});
return;
}
setLoading(true);
try {
const all = await Promise.all(
files.map((f) => fetchPublicRunFile(runId, f.index)),
);
if (!cancelled) {
const map: Record<number, PublicFileContent> = {};
for (const item of all) map[item.index] = item;
setContents(map);
}
} catch (err) {
if (!cancelled) {
toast.error(err instanceof Error ? err.message : "加载文件内容失败");
}
} finally {
if (!cancelled) setLoading(false);
}
}
void loadAll();
return () => {
cancelled = true;
};
}, [runId, files]);
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]);
if (files.length === 0) {
return null;
}
const activeFile =
activeIndex != null ? files.find((f) => f.index === activeIndex) ?? null : null;
const activeContent = activeFile ? contents[activeFile.index] : null;
return (
<div className="flex flex-col gap-3">
{files.length > 1 && (
<div className="flex flex-wrap gap-2 border-b pb-2">
{files.map((file) => {
const active = file.index === activeIndex;
return (
<button
key={file.index}
onClick={() => setActiveIndex(file.index)}
className={cn(
"rounded-md border px-3 py-1 text-xs transition-colors",
active
? "border-primary bg-primary/10 text-primary"
: "bg-card hover:bg-accent/60",
)}
>
<FileText className="mr-1 inline size-3" />
{file.name}
</button>
);
})}
</div>
)}
{activeFile && (
<div className="flex items-center justify-between gap-2">
<div className="text-muted-foreground text-xs">
{activeFile.name}
{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">
无法预览此 Markdown 文件,请下载查看。
</div>
)
) : !loading ? (
<div className="text-muted-foreground text-sm">未加载到内容。</div>
) : null}
</div>
);
}
+34 -5
View File
@@ -10,6 +10,7 @@ import {
Globe,
Heart,
HeartOff,
Loader2,
Lock,
Pause,
Play,
@@ -304,6 +305,9 @@ export default function ScheduledTasksPage() {
const isOwner = task.role !== "subscriber";
const isSubscriber = task.role === "subscriber";
const notifyOn = !!task._subscription?.notify_element;
const isRunningNow =
task.last_status === "running" ||
task.last_status === "pending";
return (
<button
key={task.task_id}
@@ -323,6 +327,15 @@ export default function ScheduledTasksPage() {
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="truncate font-medium">{task.name}</div>
{isRunningNow && (
<Badge
variant="secondary"
className="border-primary/30 bg-primary/10 text-primary shrink-0 text-xs"
>
<Loader2 className="size-3 animate-spin" />
正在执行
</Badge>
)}
{isSubscriber && (
<Badge variant="outline" className="shrink-0 text-xs">
<Heart className="size-3" />收藏
@@ -345,7 +358,12 @@ export default function ScheduledTasksPage() {
<div className="text-muted-foreground mt-3 grid gap-1 text-xs">
<div>下次执行:{formatDate(task.next_run_at)}</div>
<div>上次执行:{formatDate(task.last_run_at)}</div>
<div>最近状态:{statusLabel(task.last_status)}</div>
<div className="flex items-center gap-1">
<span>最近状态:{statusLabel(task.last_status)}</span>
{isRunningNow && (
<Loader2 className="text-primary size-3 animate-spin" />
)}
</div>
{(task.creator_email || task.creator_user_id) && (
<div className="truncate">
创建者:
@@ -583,10 +601,16 @@ export default function ScheduledTasksPage() {
请先选择一个定时任务。
</div>
)}
{filteredRuns.map((run) => (
{filteredRuns.map((run) => {
const runRunning =
run.status === "running" || run.status === "pending";
return (
<div
key={run.id}
className="rounded-md border p-4 transition-colors hover:bg-accent/40"
className={cn(
"rounded-md border p-4 transition-colors hover:bg-accent/40",
runRunning && "border-primary/40 bg-primary/5",
)}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
@@ -598,7 +622,11 @@ export default function ScheduledTasksPage() {
{formatDate(run.started_at)}
</div>
</div>
<Badge variant={statusVariant(run.status)}>
<Badge
variant={statusVariant(run.status)}
className={cn(runRunning && "border-primary/30 bg-primary/10 text-primary")}
>
{runRunning && <Loader2 className="size-3 animate-spin" />}
{statusLabel(run.status)}
</Badge>
</div>
@@ -641,7 +669,8 @@ export default function ScheduledTasksPage() {
)}
</div>
</div>
))}
);
})}
</div>
</ScrollArea>
</CardContent>
@@ -35,6 +35,7 @@ from app.gateway.routers import (
memory,
models,
notifications,
public_scheduled_tasks,
recommended_questions,
runs,
scheduled_tasks,
@@ -685,6 +686,11 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Scheduled task API is mounted at /api/scheduled-tasks
app.include_router(scheduled_tasks.router)
# Public, unauthenticated read-only scheduled task API
# (/api/public/scheduled-tasks/*). Lets external systems query tasks and
# surface results without a DeerFlow login.
app.include_router(public_scheduled_tasks.router)
# System settings API is mounted at /api/system-settings
app.include_router(system_settings.router)
@@ -0,0 +1,304 @@
"""Public (unauthenticated) read-only API for scheduled tasks.
Mounted under the ``/api/public/`` prefix so the auth middleware lets these
routes through without a session cookie. Intended for embedding scheduled
task results into external dashboards / documentation / chat systems that
shouldn't need a DeerFlow login.
Surface area:
- ``GET /api/public/scheduled-tasks/`` — list every task (no prompt body)
- ``GET /api/public/scheduled-tasks/{task_id}`` — task metadata
- ``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}/files/{i}``— inline file content / preview
- ``GET /api/public/scheduled-tasks/runs/{run_id}/download`` — zip / md download of result
These are read-only. No write/mutation routes belong here.
"""
from __future__ import annotations
import logging
import mimetypes
import re
import zipfile
from io import BytesIO
from pathlib import Path
from typing import Any
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse, Response, StreamingResponse
from deerflow.runtime.scheduler import get_scheduled_task_service
logger = logging.getLogger(__name__)
# Whitelisted under ``auth_middleware._PUBLIC_PATH_PREFIXES`` (``/api/public/``).
router = APIRouter(prefix="/api/public/scheduled-tasks", tags=["public-scheduled-tasks"])
# ---------------------------------------------------------------------------
# Sanitisation helpers
# ---------------------------------------------------------------------------
# Fields stripped from every task before it leaves the public boundary.
# Keeps owner ids, raw cron, and the (often verbose) author prompt out of
# anonymous responses.
_TASK_PRIVATE_FIELDS: frozenset[str] = frozenset(
{
"user_id",
"owner_user_id",
"creator_user_id",
"creator_email",
"prompt",
"execution_context",
"_subscription",
"role",
"scheduler_thread_id",
}
)
# Fields stripped from every run before it leaves the public boundary.
# We keep the result body (the user explicitly wants the MD content surfaced)
# but redact any path that points at a server-side filesystem location.
_RUN_PRIVATE_FIELDS: frozenset[str] = frozenset(
{
"user_id",
"owner_user_id",
"scheduler_thread_id",
"agent_run_id",
"can_edit",
}
)
def _redact_task(task: dict[str, Any]) -> dict[str, Any]:
return {k: v for k, v in task.items() if k not in _TASK_PRIVATE_FIELDS}
def _redact_run(run: dict[str, Any]) -> dict[str, Any]:
cleaned = {k: v for k, v in run.items() if k not in _RUN_PRIVATE_FIELDS}
result = cleaned.get("result")
if isinstance(result, dict):
files = result.get("files")
if isinstance(files, list):
cleaned_files = []
for idx, item in enumerate(files):
if not isinstance(item, dict):
continue
# Strip absolute filesystem path; clients use the file index
# to fetch content via the public file endpoint.
public_file = {k: v for k, v in item.items() if k != "path"}
public_file["index"] = idx
cleaned_files.append(public_file)
result = {**result, "files": cleaned_files}
cleaned["result"] = result
return cleaned
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _safe_filename(value: str, default: str = "scheduled-task-result") -> str:
name = re.sub(r'[\\/:*?"<>|\r\n]+', "-", value).strip(" .")
return name or default
def _result_markdown(result: dict[str, Any]) -> str:
title = str(result.get("title") or "Scheduled Task Result")
content = str(result.get("content") or "")
return f"# {title}\n\n{content}".strip() + "\n"
def _utf8_sig_bytes(content: str) -> bytes:
return content.encode("utf-8-sig")
def _content_disposition(filename: str) -> str:
quoted = quote(filename)
return f"attachment; filename*=UTF-8''{quoted}"
def _run_file_path(run: dict[str, Any], file_index: int) -> tuple[dict[str, Any], Path]:
result = run.get("result") or {}
files = result.get("files") if isinstance(result, dict) and isinstance(result.get("files"), list) else []
if file_index < 0 or file_index >= len(files):
raise HTTPException(status_code=404, detail="Result file not found")
item = files[file_index]
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
raise HTTPException(status_code=404, detail="Result file not found")
path = Path(item["path"])
if not path.exists() or not path.is_file():
raise HTTPException(status_code=404, detail="Result file is no longer available")
return item, path
def _is_text_previewable(path: Path, mime_type: str | None) -> bool:
if mime_type and (mime_type.startswith("text/") or mime_type in {"application/json", "application/xml"}):
return True
return path.suffix.lower() in {".md", ".markdown", ".txt", ".json", ".csv", ".xml", ".yaml", ".yml", ".log"}
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.get("")
async def public_list_tasks(
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on task name"),
limit: int = Query(default=200, ge=1, le=500),
offset: int = Query(default=0, ge=0),
) -> dict[str, Any]:
"""Return every scheduled task across all users.
Sensitive fields (user_id, raw prompt, scheduler thread id, …) are stripped
before serialisation. The response shape is intentionally stable so external
integrations can pin to it without surprises.
"""
service = get_scheduled_task_service()
tasks = await service.admin_list_tasks()
if search:
needle = search.strip().lower()
tasks = [t for t in tasks if needle in str(t.get("name", "")).lower()]
total = len(tasks)
sliced = tasks[offset : offset + limit]
return {
"total": total,
"limit": limit,
"offset": offset,
"tasks": [_redact_task(task) for task in sliced],
}
@router.get("/{task_id}")
async def public_get_task(task_id: str) -> dict[str, Any]:
"""Return metadata for a single task (without prompt body)."""
service = get_scheduled_task_service()
task = await service.store.get_task_any(task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return _redact_task(task)
@router.get("/{task_id}/runs")
async def public_list_task_runs(
task_id: str,
limit: int = Query(default=50, ge=1, le=200),
) -> dict[str, Any]:
"""Return runs for a task, latest first."""
service = get_scheduled_task_service()
task = await service.store.get_task_any(task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
runs = await service.admin_list_runs(task_id, limit=limit)
return {
"task": _redact_task(task),
"total": len(runs),
"runs": [_redact_run(run) for run in runs],
}
@router.get("/runs/{run_id}")
async def public_get_run(run_id: str) -> dict[str, Any]:
"""Return a single run with its full result body (Markdown included)."""
service = get_scheduled_task_service()
run = await service.admin_get_run(run_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
return _redact_run(run)
@router.get("/runs/{run_id}/files/{file_index}")
async def public_get_run_file(run_id: str, file_index: int) -> dict[str, Any]:
"""Return a single result-file entry with its inline text content where
previewable. Binary files come back with ``previewable: false`` and the
consumer should fetch the download endpoint instead."""
service = get_scheduled_task_service()
run = await service.admin_get_run(run_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
item, path = _run_file_path(run, file_index)
mime_type = item.get("mime_type") or mimetypes.guess_type(path.name)[0]
previewable = _is_text_previewable(path, mime_type)
content: str | None = None
if previewable:
try:
content = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
content = path.read_text(encoding="utf-8", errors="replace")
return {
"index": file_index,
"name": item.get("name") or path.name,
"mime_type": mime_type,
"kind": item.get("kind"),
"size": path.stat().st_size,
"previewable": previewable,
"content": content,
}
@router.get("/runs/{run_id}/files/{file_index}/download")
async def public_download_run_file(run_id: str, file_index: int):
"""Stream a single result file as a download."""
service = get_scheduled_task_service()
run = await service.admin_get_run(run_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
item, path = _run_file_path(run, file_index)
filename = _safe_filename(str(item.get("name") or path.name), path.name)
media = item.get("mime_type") or mimetypes.guess_type(path.name)[0] or "application/octet-stream"
return FileResponse(path, filename=filename, media_type=media)
@router.get("/runs/{run_id}/download")
async def public_download_run(run_id: str):
"""Download the full result: a zipped bundle of result.md + every file,
or a single ``.md`` when no auxiliary files were produced."""
service = get_scheduled_task_service()
run = await service.admin_get_run(run_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
result = run.get("result") or {}
if not isinstance(result, dict):
result = {}
files = result.get("files") if isinstance(result.get("files"), list) else []
accessible_files: list[tuple[str, Path]] = []
for item in files:
if not isinstance(item, dict):
continue
path_value = item.get("path")
if not isinstance(path_value, str) or not path_value:
continue
path = Path(path_value)
if path.exists() and path.is_file():
accessible_files.append((str(item.get("name") or path.name), path))
base_name = _safe_filename(str(result.get("title") or "scheduled-task-result"))
if not accessible_files:
return Response(
content=_utf8_sig_bytes(_result_markdown(result)),
media_type="text/markdown; charset=utf-8",
headers={"Content-Disposition": _content_disposition(f"{base_name}.md")},
)
archive = BytesIO()
with zipfile.ZipFile(archive, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr("result.md", _utf8_sig_bytes(_result_markdown(result)))
for name, path in accessible_files:
arcname = _safe_filename(name, path.name)
zf.write(path, arcname=arcname)
archive.seek(0)
return StreamingResponse(
archive,
media_type="application/zip",
headers={"Content-Disposition": _content_disposition(f"{base_name}.zip")},
)
@@ -14,7 +14,16 @@ from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user, get_local_provider, get_optional_user_from_request, get_run_event_store, get_run_store, get_tag_store
from app.gateway.deps import (
get_checkpointer,
get_current_user,
get_local_provider,
get_optional_user_from_request,
get_run_event_store,
get_run_store,
get_tag_store,
)
from deerflow.runtime import serialize_channel_values
from deerflow.runtime.scheduler import DuplicateTaskNameError, get_scheduled_task_service
router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
@@ -235,31 +244,129 @@ async def get_scheduled_task_run(run_id: str, request: Request) -> dict:
return run
def _event_type_for_checkpoint_message(message: dict) -> str:
"""Map a serialized LangChain message to the same ``event_type`` strings
the run-event store uses, so the frontend's existing role labels keep
working for checkpoint-derived synthetic rows."""
raw_type = message.get("type") or message.get("role") or ""
if raw_type in {"human", "user"}:
return "llm.human.input"
if raw_type in {"ai", "assistant"}:
return "llm.ai.response"
if raw_type == "tool":
return "llm.tool.result"
if raw_type == "system":
return "llm.system"
return f"llm.{raw_type or 'message'}"
async def _checkpoint_messages_for_run(
request: Request, agent_run: dict, *, limit: int
) -> list[dict]:
"""Return a synthetic list of message rows pulled from the LangGraph
checkpoint for an in-flight run.
The event store is updated by the streaming pipeline and may stay empty
until the first message tuple is flushed — meanwhile the checkpoint
already carries the current state (input + accumulating AI deltas). We
project that into the same row shape the frontend uses for the event
store, so the run-detail page can show progress immediately."""
try:
checkpointer = get_checkpointer(request)
except Exception:
return []
thread_id = agent_run.get("thread_id")
if not thread_id:
return []
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
try:
tup = await checkpointer.aget_tuple(config)
except Exception:
return []
if tup is None:
return []
checkpoint = getattr(tup, "checkpoint", {}) or {}
channel_values = checkpoint.get("channel_values") or {}
try:
values = serialize_channel_values(channel_values)
except Exception:
return []
messages = values.get("messages") or []
rows: list[dict] = []
for index, message in enumerate(messages[-limit:]):
if not isinstance(message, dict):
continue
rows.append(
{
"seq": index + 1,
"event_type": _event_type_for_checkpoint_message(message),
"category": "message",
"content": message,
"metadata": {"source": "checkpoint"},
"created_at": None,
}
)
return rows
@router.get("/runs/{run_id}/messages")
async def get_scheduled_task_run_messages(run_id: str, request: Request, limit: int = 500) -> dict:
"""Agent messages produced by a scheduled run's underlying agent run.
Lets the run-detail page show what the agent is doing — useful when a run
is stuck in ``running``. Authorization reuses :func:`_load_run` (owner /
subscriber / admin); the event-store query then passes ``user_id=None``
because access has already been checked.
Resolution order (most authoritative first):
1. Event-store rows for the underlying agent run — these are the per-tuple
streaming events the worker writes as the agent runs.
2. LangGraph checkpoint snapshot — the canonical conversation state. Used
as a fallback when the event store is still empty (early in a run) or
when only partial events have been flushed; we project the snapshot
into the same row shape the frontend already renders so the user can
see the prompt + current AI output while the task is mid-flight.
3. Synthetic human-input row built from ``task.prompt`` — shown even
before the agent run is registered, so a `pending` task at minimum
displays what was queued.
"""
service = get_scheduled_task_service()
user_id = await _user_id(request)
run = await _load_run(service, request, run_id, user_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
capped = max(1, min(limit, 1000))
# Always available: task prompt as the seeded human input. Lets the
# ``execution process`` tab show *something* even before the agent run is
# registered, which is the user-visible bug we're fixing.
fallback_rows: list[dict] = []
task_id = run.get("task_id")
if task_id:
task = await service.store.get_task_any(task_id)
prompt = (task or {}).get("prompt")
if prompt:
fallback_rows.append(
{
"seq": 0,
"event_type": "llm.human.input",
"category": "message",
# Frontend treats human-input content as a plain string.
"content": str(prompt),
"metadata": {"source": "task_prompt"},
"created_at": run.get("created_at") or run.get("scheduled_for"),
}
)
agent_run_id = run.get("agent_run_id")
if not agent_run_id:
return {"messages": [], "has_more": False}
return {"messages": fallback_rows, "has_more": False}
# Each scheduled run executes on its own throwaway runtime thread — NOT the
# run row's ``scheduler_thread_id`` — so resolve the real thread from the
# run store before querying its message events.
run_store = get_run_store(request)
agent_run = await run_store.get(agent_run_id, user_id=None)
if agent_run is None:
return {"messages": [], "has_more": False}
capped = max(1, min(limit, 1000))
return {"messages": fallback_rows, "has_more": False}
event_store = get_run_event_store(request)
rows = await event_store.list_messages_by_run(
agent_run["thread_id"],
@@ -267,6 +374,16 @@ async def get_scheduled_task_run_messages(run_id: str, request: Request, limit:
limit=capped + 1,
user_id=None,
)
# Event store has no rows yet (run just started, or events still buffering).
# Fall back to the live checkpoint so the user immediately sees the prompt
# + whatever AI output has been streamed so far.
if not rows:
checkpoint_rows = await _checkpoint_messages_for_run(request, agent_run, limit=capped)
if checkpoint_rows:
return {"messages": checkpoint_rows[:capped], "has_more": False}
return {"messages": fallback_rows, "has_more": False}
has_more = len(rows) > capped
return {"messages": rows[:capped] if has_more else rows, "has_more": has_more}
@@ -109,6 +109,16 @@ skills:
agents_api:
enabled: true
database:
backend: mysql
mysql_url: $MYSQL_DATABASE_URL
pool_size: 10
echo_sql: false
checkpointer:
type: sqlite
connection_string: D:\cursor-work\offline-backend-20260512\backend\.deer-flow\data\deerflow.db
# 记忆子系统:跨会话持久化用户画像与 agent 私有记忆。
# 三层覆盖优先级(高->低):
# users/{user_id}/memory_config.json > .deer-flow/memory_user_overrides.json > 此处
@@ -0,0 +1,278 @@
# 公开定时任务 API
本文档描述用于对接外部系统的**公开只读**定时任务接口,以及配套的公开网页入口。所有 `/api/public/*` 接口都**无需登录**,可以直接调用。
> ⚠️ 任何接入这套接口的系统需要确认:暴露 task / run 列表与正文是符合贵方业务安全要求的。如果不希望全员可见,请走带鉴权的 `/api/scheduled-tasks/*`。
---
## 1. 基础信息
| 项 | 值 |
|---|---|
| 网关基址(开发) | `http://localhost:8001` |
| 网关基址(生产) | 部署到 Nginx 后通常是 `https://<你的域名>` |
| 公开 API 前缀 | `/api/public/scheduled-tasks` |
| 鉴权 | **无需**鉴权(白名单在 `auth_middleware._PUBLIC_PATH_PREFIXES`) |
| 编码 | 请求 / 响应均为 UTF-8,响应类型 `application/json`(下载除外) |
| 错误响应 | 标准 FastAPI 错误体 `{"detail": "..."}`;HTTP 状态码 `404 / 400 / 500` 等 |
公开网页页面:
```
#/public/scheduled-tasks/<task_id>
```
例如 `https://<你的域名>/#/public/scheduled-tasks/8f1d8b9c-...`。
左侧列出该任务所有执行记录,默认打开第一条;右侧默认渲染 Markdown 正文,点击 Tab 可以查看“结果文件”或“元数据”,点击左侧任意记录右侧内容会跟随切换。
---
## 2. 接口列表
### 2.1 列出全部定时任务
```
GET /api/public/scheduled-tasks
```
**Query 参数**
| 名称 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `search` | string | 否 | — | 任务名模糊匹配(不区分大小写) |
| `limit` | int | 否 | 200 | 单页数量,1–500 |
| `offset` | int | 否 | 0 | 偏移量,配合 `limit` 分页 |
**响应**
```json
{
"total": 12,
"limit": 200,
"offset": 0,
"tasks": [
{
"task_id": "8f1d8b9c-...",
"name": "每日舆情简报",
"schedule_text": "每天 09:00",
"cron_expr": "0 9 * * *",
"timezone": "Asia/Shanghai",
"enabled": true,
"next_run_at": "2026-05-21T01:00:00+00:00",
"last_run_at": "2026-05-20T01:00:12+00:00",
"last_status": "succeeded",
"published": true
}
]
}
```
> 已脱敏:`user_id`、`creator_email`、`prompt`、`scheduler_thread_id`、`execution_context` 等私有字段不会出现在公开响应里。
### 2.2 获取单个任务元数据
```
GET /api/public/scheduled-tasks/{task_id}
```
**响应**:与上面 `tasks[]` 的元素结构一致;任务不存在返回 `404`。
### 2.3 列出某任务的所有执行记录
```
GET /api/public/scheduled-tasks/{task_id}/runs
```
**Query 参数**
| 名称 | 类型 | 必填 | 默认 | 说明 |
|---|---|---|---|---|
| `limit` | int | 否 | 50 | 最近执行数量,1–200 |
**响应**
```json
{
"task": { "task_id": "...", "name": "...", "schedule_text": "...", "timezone": "...", "enabled": true },
"total": 7,
"runs": [
{
"id": "run-uuid-...",
"task_id": "8f1d8b9c-...",
"scheduled_for": "2026-05-20T01:00:00+00:00",
"started_at": "2026-05-20T01:00:01+00:00",
"finished_at": "2026-05-20T01:00:12+00:00",
"status": "succeeded",
"error": null,
"retry_count": 0,
"result": {
"title": "2026-05-20 舆情简报",
"content": "# 简报\n\n...",
"format": "markdown",
"files": [
{ "index": 0, "name": "report.md", "mime_type": "text/markdown", "kind": "markdown", "size": 4321 }
],
"created_at": "2026-05-20T01:00:12+00:00"
}
}
]
}
```
> 已脱敏:返回的 `files[]` 不包含服务器绝对路径;客户端通过下标 `index` 调用文件接口。
### 2.4 获取单条执行记录
```
GET /api/public/scheduled-tasks/runs/{run_id}
```
返回单条 run 数据(结构同 2.3 中的 `runs[]` 元素)。
### 2.5 获取单个结果文件的内容(可预览文本直接内联)
```
GET /api/public/scheduled-tasks/runs/{run_id}/files/{file_index}
```
`file_index` 来自 `run.result.files[].index`。
**响应**
```json
{
"index": 0,
"name": "report.md",
"mime_type": "text/markdown",
"kind": "markdown",
"size": 4321,
"previewable": true,
"content": "# 简报\n\n..."
}
```
二进制文件 `previewable=false`、`content=null`,请走 2.6 下载。
### 2.6 下载单个结果文件
```
GET /api/public/scheduled-tasks/runs/{run_id}/files/{file_index}/download
```
返回原始文件二进制流,附带 `Content-Disposition: attachment` 头部。
### 2.7 打包下载整个执行结果
```
GET /api/public/scheduled-tasks/runs/{run_id}/download
```
- 如果该 run **没有附加文件**:直接返回 `result.md`(UTF-8 BOM)。
- 如果有 ≥ 1 个附加文件:返回 ZIP,里面包含:
- `result.md` — 标题 + 正文 Markdown
- 每个生成文件原样
---
## 3. 调用示例
### curl
```bash
# 列出全部任务
curl -s "https://<domain>/api/public/scheduled-tasks?limit=50"
# 查看任务的执行记录
curl -s "https://<domain>/api/public/scheduled-tasks/<task_id>/runs"
# 下载某次执行
curl -L -o report.zip \
"https://<domain>/api/public/scheduled-tasks/runs/<run_id>/download"
```
### Python
```python
import requests
base = "https://<domain>/api/public/scheduled-tasks"
tasks = requests.get(f"{base}", params={"limit": 100, "search": "舆情"}).json()
for task in tasks["tasks"]:
runs = requests.get(f"{base}/{task['task_id']}/runs", params={"limit": 5}).json()
for run in runs["runs"]:
if run.get("result"):
print(task["name"], run["id"], run["result"]["title"])
print(run["result"]["content"][:200])
```
### 直接嵌入 iframe
```html
<iframe src="https://<domain>/#/public/scheduled-tasks/<task_id>"
style="width:100%;height:800px;border:0"></iframe>
```
---
## 4. 状态码
| 状态码 | 含义 |
|---|---|
| 200 | 成功 |
| 400 | 参数错误(如 `limit` 超出范围) |
| 404 | 任务 / 执行 / 文件 不存在 |
| 5xx | 网关 / 后台错误 |
---
## 5. 字段映射快查
### `task` 字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `task_id` | string | 任务 ID |
| `name` | string | 任务名 |
| `schedule_text` | string | 人类可读的执行计划 |
| `cron_expr` | string | 规范化的 cron 表达式 |
| `timezone` | string | 时区 |
| `enabled` | bool | 是否启用 |
| `next_run_at` | string &#124; null | 下次执行时间(ISO 8601) |
| `last_run_at` | string &#124; null | 上次执行时间 |
| `last_status` | string &#124; null | 上次执行状态(`succeeded` / `failed` / `running` / `pending` / `…`) |
| `published` | bool | 是否被发布到广场 |
### `run` 字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `id` | string | 执行 ID |
| `task_id` | string | 所属任务 ID |
| `scheduled_for` | string | 原定执行时间 |
| `started_at` | string &#124; null | 实际开始时间 |
| `finished_at` | string &#124; null | 完成 / 失败时间 |
| `status` | string | `pending` / `running` / `succeeded` / `failed` |
| `error` | string &#124; null | 失败描述 |
| `retry_count` | int &#124; null | 重试次数 |
| `result` | object &#124; null | 执行结果(见下) |
### `result` 字段
| 字段 | 类型 | 说明 |
|---|---|---|
| `title` | string | 结果标题 |
| `content` | string | 主体 Markdown / 文本 |
| `format` | string | `markdown` / `text` / `json` / `mixed` |
| `files[]` | array | 附加文件元数据,含 `index / name / mime_type / kind / size` |
| `created_at` | string | 结果生成时间 |
---
## 6. 实现位置
- 路由实现:[`app/gateway/routers/public_scheduled_tasks.py`](../app/gateway/routers/public_scheduled_tasks.py)
- 公开 URL 白名单:[`app/gateway/auth_middleware.py`](../app/gateway/auth_middleware.py) 中的 `_PUBLIC_PATH_PREFIXES`
- 前端公开页面:[`frontend-web/src/pages/PublicScheduledTaskPage.tsx`](../../../frontend-web/src/pages/PublicScheduledTaskPage.tsx)
- 路由注册:[`frontend-web/src/App.tsx`](../../../frontend-web/src/App.tsx) `/#/public/scheduled-tasks/:taskId`