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>