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:
1 parent
7e75c5b9b9
commit
d339db4799
18 files changed
+1617
-125
No files matched your search
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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/<task_id></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>
|
||||
);
|
||||
}
|
||||
@@ -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 | null | 下次执行时间(ISO 8601) |
|
||||
| `last_run_at` | string | null | 上次执行时间 |
|
||||
| `last_status` | string | null | 上次执行状态(`succeeded` / `failed` / `running` / `pending` / `…`) |
|
||||
| `published` | bool | 是否被发布到广场 |
|
||||
|
||||
### `run` 字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `id` | string | 执行 ID |
|
||||
| `task_id` | string | 所属任务 ID |
|
||||
| `scheduled_for` | string | 原定执行时间 |
|
||||
| `started_at` | string | null | 实际开始时间 |
|
||||
| `finished_at` | string | null | 完成 / 失败时间 |
|
||||
| `status` | string | `pending` / `running` / `succeeded` / `failed` |
|
||||
| `error` | string | null | 失败描述 |
|
||||
| `retry_count` | int | null | 重试次数 |
|
||||
| `result` | object | 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`
|
||||
Reference in new issue
Block a user