Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
6e88c5a1c5
20 files changed
+1090
-429
No files matched your search
@@ -46,6 +46,7 @@ export async function listScheduledTasks(params: ListScheduledTasksParams = {})
|
||||
|
||||
export async function createScheduledTask(data: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
prompt: string;
|
||||
schedule: string;
|
||||
timezone?: string | null;
|
||||
@@ -203,6 +204,7 @@ export async function updateScheduledTask(
|
||||
taskId: string,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
description: string | null;
|
||||
prompt: string;
|
||||
schedule: string;
|
||||
timezone: string;
|
||||
@@ -329,6 +331,7 @@ export async function adminUpdateScheduledTask(
|
||||
taskId: string,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
description: string | null;
|
||||
prompt: string;
|
||||
schedule: string;
|
||||
timezone: string;
|
||||
|
||||
@@ -14,6 +14,7 @@ export type ScheduledTaskSubscription = {
|
||||
export type ScheduledTask = {
|
||||
task_id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
prompt: string;
|
||||
schedule_text: string;
|
||||
cron_expr: string;
|
||||
@@ -27,6 +28,8 @@ export type ScheduledTask = {
|
||||
scheduler_thread_id: string;
|
||||
published?: boolean;
|
||||
published_at?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
role?: ScheduledTaskRole;
|
||||
_subscription?: ScheduledTaskSubscription | null;
|
||||
creator_user_id?: string | null;
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import { BotIcon, PlusSquare } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AgentWelcome } from "@/components/workspace/agent-welcome";
|
||||
import { ArtifactTrigger } from "@/components/workspace/artifacts";
|
||||
import { ChatBox, useThreadChat } from "@/components/workspace/chats";
|
||||
import { ExportTrigger } from "@/components/workspace/export-trigger";
|
||||
@@ -55,11 +56,6 @@ export default function AgentChatPage() {
|
||||
}
|
||||
}, [isNewThread, threadId]);
|
||||
|
||||
// Auto-submit a message that the user typed on ChatPage right before
|
||||
// selecting this agent. ChatPage stashed it in sessionStorage so we don't
|
||||
// depend on router state (the next/navigation shim doesn't carry it).
|
||||
const autoSubmitDoneRef = useRef(false);
|
||||
|
||||
const { showNotification } = useNotification();
|
||||
const [thread, sendMessage] = useThreadStream({
|
||||
threadId: isNewThread ? undefined : threadId,
|
||||
@@ -104,29 +100,6 @@ export default function AgentChatPage() {
|
||||
[sendMessage, threadId, agent_id],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoSubmitDoneRef.current) return;
|
||||
if (!isNewThread || hasStartedConversation) return;
|
||||
let raw: string | null = null;
|
||||
try {
|
||||
raw = sessionStorage.getItem("pendingAgentMessage");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!raw) return;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { agentId?: string; text?: string };
|
||||
if (parsed.agentId !== agent_id || !parsed.text) {
|
||||
return;
|
||||
}
|
||||
autoSubmitDoneRef.current = true;
|
||||
sessionStorage.removeItem("pendingAgentMessage");
|
||||
handleSubmit({ text: parsed.text, files: [] });
|
||||
} catch {
|
||||
sessionStorage.removeItem("pendingAgentMessage");
|
||||
}
|
||||
}, [agent_id, hasStartedConversation, handleSubmit, isNewThread]);
|
||||
|
||||
const handleClarificationSubmit = useCallback(
|
||||
(answer: string) => {
|
||||
setHasStartedConversation(true);
|
||||
@@ -143,12 +116,7 @@ export default function AgentChatPage() {
|
||||
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
|
||||
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
|
||||
: undefined;
|
||||
// AgentChatPage skips the centered welcome/landing state entirely — when the
|
||||
// user reaches this page they've already committed to an agent (either by
|
||||
// selecting one on the homepage or by visiting the agent's URL), so we drop
|
||||
// them straight into the conversation layout: messages area on top, input at
|
||||
// the bottom. The agent badge in the header still identifies the agent.
|
||||
const isLandingState = false;
|
||||
const isLandingState = isNewThread && !hasStartedConversation;
|
||||
|
||||
return (
|
||||
<ThreadContext.Provider value={{ thread }}>
|
||||
@@ -228,25 +196,32 @@ export default function AgentChatPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InputBox
|
||||
className={cn("bg-background/5 w-full -translate-y-4")}
|
||||
isNewThread={isNewThread}
|
||||
threadId={threadId}
|
||||
autoFocus={isNewThread}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
<div className="w-full -translate-y-4">
|
||||
<InputBox
|
||||
className="w-full"
|
||||
isNewThread={isLandingState}
|
||||
threadId={threadId}
|
||||
autoFocus={isLandingState}
|
||||
status={
|
||||
thread.error
|
||||
? "error"
|
||||
: thread.isLoading
|
||||
? "streaming"
|
||||
: "ready"
|
||||
}
|
||||
context={settings.context}
|
||||
extraHeader={
|
||||
isLandingState && (
|
||||
<AgentWelcome agent={agent} agentName={agent?.name ?? agent_id} />
|
||||
)
|
||||
}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onFollowupsVisibilityChange={setShowFollowups}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
</div>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
|
||||
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
|
||||
{t.common.notAvailableInDemoMode}
|
||||
|
||||
@@ -1,15 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Download, FileText, Loader2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Check,
|
||||
Download,
|
||||
FileText,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
@@ -19,6 +43,7 @@ import { cn } from "@/lib/utils";
|
||||
type PublicTask = {
|
||||
task_id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
schedule_text: string;
|
||||
cron_expr?: string;
|
||||
timezone: string;
|
||||
@@ -35,6 +60,9 @@ type PublicRunFile = {
|
||||
mime_type?: string | null;
|
||||
kind?: string | null;
|
||||
size?: number | null;
|
||||
/** Server-reported "is the archive file still on disk?" flag. Older responses
|
||||
* may omit this — treat missing as available. */
|
||||
available?: boolean;
|
||||
};
|
||||
|
||||
type PublicRunResult = {
|
||||
@@ -79,8 +107,8 @@ function publicBaseUrl() {
|
||||
return `${getBackendBaseURL()}/api/public/scheduled-tasks`;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => ({ detail: "Request failed" }));
|
||||
throw new Error(typeof body.detail === "string" ? body.detail : "Request failed");
|
||||
@@ -98,8 +126,41 @@ async function fetchPublicRunFile(runId: string, index: number): Promise<PublicF
|
||||
);
|
||||
}
|
||||
|
||||
function publicRunDownloadUrl(runId: string): string {
|
||||
return `${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/download`;
|
||||
async function updatePublicRunFile(
|
||||
runId: string,
|
||||
index: number,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
await fetchJson(
|
||||
`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/files/${index}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function updatePublicRunBody(runId: string, content: string): Promise<void> {
|
||||
await fetchJson(
|
||||
`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/body`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function deletePublicRun(runId: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!response.ok && response.status !== 204) {
|
||||
const body = await response.json().catch(() => ({ detail: "Request failed" }));
|
||||
throw new Error(typeof body.detail === "string" ? body.detail : "Request failed");
|
||||
}
|
||||
}
|
||||
|
||||
function publicRunFileDownloadUrl(runId: string, index: number): string {
|
||||
@@ -142,18 +203,52 @@ function isMarkdownFile(file: { name: string; kind?: string | null; mime_type?:
|
||||
);
|
||||
}
|
||||
|
||||
function isFileAvailable(file: PublicRunFile): boolean {
|
||||
// Older responses omit `available`; treat that as still available so we
|
||||
// don't accidentally hide content from a backend that hasn't shipped the
|
||||
// flag yet.
|
||||
return file.available !== false;
|
||||
}
|
||||
|
||||
function stripExtension(name: string): string {
|
||||
if (!name) return name;
|
||||
const dot = name.lastIndexOf(".");
|
||||
if (dot <= 0) return name;
|
||||
return name.slice(0, dot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a trailing ` - YYYY-MM-DD HH:MM[:SS]` (or `_YYYY-MM-DD ...`) suffix
|
||||
* that the agent appends to run titles. The scheduled-for column is already
|
||||
* displayed separately below the title, so the duplicated date looks like noise.
|
||||
*/
|
||||
function stripTimestampSuffix(title: string): string {
|
||||
if (!title) return title;
|
||||
const cleaned = title.replace(
|
||||
/[\s_]*[-_]?\s*\d{4}-\d{1,2}-\d{1,2}[\sT]+\d{1,2}:\d{2}(?::\d{2})?\s*$/,
|
||||
"",
|
||||
);
|
||||
return cleaned.trim() || title;
|
||||
}
|
||||
|
||||
const BODY_TAB_VALUE = "__body__";
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Left pane lists every run of the task (latest first). Right pane uses tabs
|
||||
* to switch between the inline body (`正文`) and each Markdown attachment.
|
||||
* Markdown attachments take priority — when they exist, the first one is the
|
||||
* default tab. Each tab supports inline editing (saved via the public PUT
|
||||
* endpoints) and a per-tab Word download.
|
||||
*/
|
||||
export default function PublicScheduledTaskPage() {
|
||||
const { taskId } = useParams<{ taskId: string }>();
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
||||
const [pendingDeleteRun, setPendingDeleteRun] = useState<{ id: string; title: string } | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ["public-scheduled-task", taskId],
|
||||
@@ -164,57 +259,51 @@ export default function PublicScheduledTaskPage() {
|
||||
refetchInterval: 8000,
|
||||
});
|
||||
|
||||
const runs = useMemo(() => data?.runs ?? [], [data]);
|
||||
const deleteRunMutation = useMutation({
|
||||
mutationFn: (runId: string) => deletePublicRun(runId),
|
||||
onSuccess: async (_data, runId) => {
|
||||
toast.success("已删除");
|
||||
if (selectedRunId === runId) {
|
||||
setSelectedRunId(null);
|
||||
}
|
||||
setPendingDeleteRun(null);
|
||||
await queryClient.invalidateQueries({ queryKey: ["public-scheduled-task", taskId] });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "删除失败");
|
||||
},
|
||||
});
|
||||
|
||||
// Public viewer only surfaces runs that have actual content to show:
|
||||
// pending/running/failed are hidden, and succeeded runs with neither a body
|
||||
// nor a Markdown attachment fall out too (nothing to render).
|
||||
const runs = useMemo(() => {
|
||||
const all = data?.runs ?? [];
|
||||
return all.filter((run) => {
|
||||
if (run.status !== "succeeded") return false;
|
||||
const result = run.result;
|
||||
if (!result) return false;
|
||||
const hasBody = !!result.content && result.content.trim().length > 0;
|
||||
const hasMarkdownFile = (result.files ?? []).some(
|
||||
(file) => isMarkdownFile(file) && isFileAvailable(file),
|
||||
);
|
||||
return hasBody || hasMarkdownFile;
|
||||
});
|
||||
}, [data]);
|
||||
const selectedRun = useMemo(
|
||||
() => 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);
|
||||
setSelectedRunId(runs[0]!.id);
|
||||
}
|
||||
if (selectedRunId && !runs.some((r) => r.id === selectedRunId) && runs.length > 0) {
|
||||
setSelectedRunId(runs[0].id);
|
||||
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">
|
||||
@@ -243,285 +332,486 @@ export default function PublicScheduledTaskPage() {
|
||||
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>
|
||||
);
|
||||
})}
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<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-start justify-between gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CardTitle className="line-clamp-2 text-base leading-snug break-all">
|
||||
{task?.name || "任务"}
|
||||
</CardTitle>
|
||||
</TooltipTrigger>
|
||||
{task?.name && (
|
||||
<TooltipContent side="bottom" className="max-w-[320px] break-all">
|
||||
{task.name}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
<Badge variant={task?.enabled ? "default" : "secondary"} className="shrink-0">
|
||||
{task?.enabled ? "启用" : "暂停"}
|
||||
</Badge>
|
||||
</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)}
|
||||
{task?.description && task.description.trim() && (
|
||||
<p className="text-muted-foreground line-clamp-3 text-xs leading-relaxed break-all whitespace-pre-wrap">
|
||||
{task.description.trim()}
|
||||
</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}
|
||||
</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";
|
||||
const runTitle = stripTimestampSuffix(
|
||||
run.result?.title || formatDate(run.scheduled_for),
|
||||
);
|
||||
const isDeletingThis =
|
||||
deleteRunMutation.isPending && deleteRunMutation.variables === run.id;
|
||||
return (
|
||||
<div
|
||||
key={run.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"bg-card hover:bg-accent/60 group rounded-md border p-3 text-left transition-colors cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
active && "border-primary bg-accent",
|
||||
)}
|
||||
onClick={() => setSelectedRunId(run.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setSelectedRunId(run.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="text-sm leading-snug font-medium break-all"
|
||||
title={runTitle}
|
||||
>
|
||||
{runTitle}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-[360px] break-all">
|
||||
{runTitle}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
执行时间:{formatDate(run.scheduled_for)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Badge
|
||||
variant={statusVariant(run.status)}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
runRunning && "border-primary/30 bg-primary/10 text-primary",
|
||||
)}
|
||||
>
|
||||
{runRunning && <Loader2 className="size-3 animate-spin" />}
|
||||
{statusLabel(run.status)}
|
||||
</Badge>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-destructive size-7"
|
||||
disabled={isDeletingThis}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setPendingDeleteRun({ id: run.id, title: runTitle });
|
||||
}}
|
||||
title="删除记录"
|
||||
>
|
||||
{isDeletingThis ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="flex min-h-0 flex-col">
|
||||
{selectedRun ? (
|
||||
<RunDetailPane key={selectedRun.id} run={selectedRun} taskId={taskId} />
|
||||
) : (
|
||||
<div className="text-muted-foreground flex h-full items-center justify-center p-6 text-sm">
|
||||
<CardContent className="text-muted-foreground flex h-full items-center justify-center p-6 text-sm">
|
||||
请在左侧选择一条执行记录。
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={pendingDeleteRun !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !deleteRunMutation.isPending) {
|
||||
setPendingDeleteRun(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[420px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除执行记录</DialogTitle>
|
||||
<DialogDescription>
|
||||
确认删除这条执行记录吗?此操作不可恢复。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{pendingDeleteRun && (
|
||||
<div className="bg-muted/40 text-foreground rounded-md border px-3 py-2 text-sm break-all">
|
||||
{pendingDeleteRun.title}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPendingDeleteRun(null)}
|
||||
disabled={deleteRunMutation.isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (pendingDeleteRun) {
|
||||
deleteRunMutation.mutate(pendingDeleteRun.id);
|
||||
}
|
||||
}}
|
||||
disabled={deleteRunMutation.isPending}
|
||||
>
|
||||
{deleteRunMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="size-4" />
|
||||
)}
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type ContentTab = {
|
||||
/** Stable tab identifier — `__body__` for inline content, `file-<index>` for files. */
|
||||
value: string;
|
||||
/** Short label rendered in the TabsTrigger. */
|
||||
label: string;
|
||||
/** Distinguishes body vs MD attachment so the editor knows which PUT to call. */
|
||||
kind: "body" | "file";
|
||||
/** File index (when `kind === "file"`). */
|
||||
fileIndex?: number;
|
||||
/** Filename for downloads (defaults to the run title for body). */
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Markdown-only result viewer used by the public page.
|
||||
*
|
||||
* 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.
|
||||
* Right-pane controller for a single run: builds the tab list (body + MDs),
|
||||
* fetches each tab's source text, and hosts the view/edit toggle.
|
||||
*/
|
||||
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);
|
||||
function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Skip files whose archive is missing on disk — otherwise the public viewer
|
||||
// would render a tab that 404s with "Result file is no longer available".
|
||||
const markdownFiles = useMemo(
|
||||
() =>
|
||||
(run.result?.files ?? []).filter(
|
||||
(file) => isMarkdownFile(file) && isFileAvailable(file),
|
||||
),
|
||||
[run.result?.files],
|
||||
);
|
||||
|
||||
// Tabs: every MD file first (priority per spec), then the inline body.
|
||||
const tabs: ContentTab[] = useMemo(() => {
|
||||
const list: ContentTab[] = markdownFiles.map((file, idx) => ({
|
||||
value: `file-${file.index}`,
|
||||
// Show the actual filename without the `.md` suffix — the user explicitly
|
||||
// asked for "no '文件' label, just the name without extension".
|
||||
label: stripExtension(file.name) || `文件 ${idx + 1}`,
|
||||
kind: "file",
|
||||
fileIndex: file.index,
|
||||
fileName: file.name,
|
||||
}));
|
||||
list.push({
|
||||
value: BODY_TAB_VALUE,
|
||||
label: "正文",
|
||||
kind: "body",
|
||||
fileName: run.result?.title || "scheduled-task-result",
|
||||
});
|
||||
return list;
|
||||
}, [markdownFiles, run.result?.title]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>(tabs[0]?.value ?? BODY_TAB_VALUE);
|
||||
|
||||
// When the run's MD-file set shifts (rare) — fall back to the first tab.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function loadAll() {
|
||||
if (files.length === 0) {
|
||||
setContents({});
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
if (!tabs.some((t) => t.value === activeTab)) {
|
||||
setActiveTab(tabs[0]?.value ?? BODY_TAB_VALUE);
|
||||
}
|
||||
}, [tabs, activeTab]);
|
||||
|
||||
// Fetch each MD file's text once. Keyed by file index inside the run.
|
||||
const [fileTexts, setFileTexts] = useState<Record<number, string>>({});
|
||||
const [fileLoading, setFileLoading] = useState<Record<number, boolean>>({});
|
||||
|
||||
const loadFile = useCallback(
|
||||
async (fileIndex: number) => {
|
||||
setFileLoading((s) => ({ ...s, [fileIndex]: true }));
|
||||
try {
|
||||
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 : "加载文件内容失败");
|
||||
}
|
||||
const file = await fetchPublicRunFile(run.id, fileIndex);
|
||||
const text = file.previewable && file.content != null ? file.content : "";
|
||||
setFileTexts((s) => ({ ...s, [fileIndex]: text }));
|
||||
} catch {
|
||||
// Race: the archive may have disappeared between list and fetch. Fall
|
||||
// back to empty content; the parent filter will drop this tab on the
|
||||
// next refetch tick.
|
||||
setFileTexts((s) => ({ ...s, [fileIndex]: "" }));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
setFileLoading((s) => ({ ...s, [fileIndex]: false }));
|
||||
}
|
||||
},
|
||||
[run.id],
|
||||
);
|
||||
|
||||
// Pull every MD file on first render — keeps tab-switching instant.
|
||||
useEffect(() => {
|
||||
for (const file of markdownFiles) {
|
||||
if (fileTexts[file.index] == null && !fileLoading[file.index]) {
|
||||
void loadFile(file.index);
|
||||
}
|
||||
}
|
||||
void loadAll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [runId, files]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [markdownFiles, run.id]);
|
||||
|
||||
// Editor state — separate from displayed source so users can cancel.
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<string>("");
|
||||
|
||||
const activeTabMeta = useMemo(
|
||||
() => tabs.find((t) => t.value === activeTab) ?? tabs[0],
|
||||
[tabs, activeTab],
|
||||
);
|
||||
const activeSource = useMemo(() => {
|
||||
if (!activeTabMeta) return "";
|
||||
if (activeTabMeta.kind === "body") return run.result?.content ?? "";
|
||||
return fileTexts[activeTabMeta.fileIndex!] ?? "";
|
||||
}, [activeTabMeta, run.result?.content, fileTexts]);
|
||||
|
||||
// Reset the editor state any time the tab changes underneath us.
|
||||
useEffect(() => {
|
||||
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]);
|
||||
setIsEditing(false);
|
||||
setDraft(activeSource);
|
||||
}, [activeTabMeta?.value, activeSource]);
|
||||
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!activeTabMeta) return;
|
||||
if (activeTabMeta.kind === "body") {
|
||||
await updatePublicRunBody(run.id, draft);
|
||||
} else {
|
||||
await updatePublicRunFile(run.id, activeTabMeta.fileIndex!, draft);
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
if (activeTabMeta?.kind === "file") {
|
||||
setFileTexts((s) => ({ ...s, [activeTabMeta.fileIndex!]: draft }));
|
||||
}
|
||||
toast.success("已保存");
|
||||
setIsEditing(false);
|
||||
// Force a refetch so the left-list run row picks up the change too.
|
||||
await queryClient.invalidateQueries({ queryKey: ["public-scheduled-task", taskId] });
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err instanceof Error ? err.message : "保存失败");
|
||||
},
|
||||
});
|
||||
|
||||
const activeFile =
|
||||
activeIndex != null ? files.find((f) => f.index === activeIndex) ?? null : null;
|
||||
const activeContent = activeFile ? contents[activeFile.index] : null;
|
||||
const handleDownloadWord = useCallback(async () => {
|
||||
if (!activeTabMeta) return;
|
||||
const markdown = isEditing ? draft : activeSource;
|
||||
if (!markdown.trim()) {
|
||||
toast.error("当前内容为空");
|
||||
return;
|
||||
}
|
||||
const fileName = activeTabMeta.fileName || run.result?.title || "scheduled-task-result";
|
||||
const title = activeTabMeta.kind === "body" ? run.result?.title : undefined;
|
||||
try {
|
||||
await downloadMarkdownAsDocx(markdown, fileName, title);
|
||||
toast.success("Word 文件已生成");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "生成 Word 失败");
|
||||
}
|
||||
}, [activeTabMeta, draft, isEditing, activeSource, run.result?.title]);
|
||||
|
||||
const isFileTab = activeTabMeta?.kind === "file";
|
||||
const fileIndex = activeTabMeta?.fileIndex;
|
||||
const fileIsLoading = isFileTab && fileIndex != null ? !!fileLoading[fileIndex] : false;
|
||||
const showEmptyHint = !activeSource && !fileIsLoading;
|
||||
|
||||
return (
|
||||
<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",
|
||||
)}
|
||||
<>
|
||||
<CardHeader className="flex flex-wrap items-start justify-between gap-2 space-y-0">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CardTitle className="line-clamp-2 text-base leading-snug break-all">
|
||||
{run.result?.title || "执行记录"}
|
||||
</CardTitle>
|
||||
</TooltipTrigger>
|
||||
{run.result?.title && (
|
||||
<TooltipContent side="bottom" className="max-w-[420px] break-all">
|
||||
{run.result.title}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
开始:{formatDate(run.started_at)} · 完成:{formatDate(run.finished_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{isFileTab && fileIndex != null && (
|
||||
<a
|
||||
className="bg-background hover:bg-accent inline-flex h-9 items-center gap-1 rounded-md border px-3 text-xs"
|
||||
href={publicRunFileDownloadUrl(run.id, fileIndex)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
下载原文件
|
||||
</a>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={handleDownloadWord}>
|
||||
<FileText className="size-4" />
|
||||
下载 Word
|
||||
</Button>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setDraft(activeSource);
|
||||
}}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
<FileText className="mr-1 inline size-3" />
|
||||
{file.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<X className="size-4" />
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Check className="size-4" />
|
||||
)}
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDraft(activeSource);
|
||||
setIsEditing(true);
|
||||
}}
|
||||
disabled={fileIsLoading}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{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}
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="min-h-0 flex-1 overflow-hidden p-0">
|
||||
{tabs.length === 0 ? (
|
||||
<div className="text-muted-foreground p-6 text-sm">无可用内容。</div>
|
||||
) : (
|
||||
<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>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex h-full min-h-0 flex-col gap-0 px-4 pb-3"
|
||||
>
|
||||
<TabsList
|
||||
variant="line"
|
||||
className="bg-card sticky top-0 z-10 h-auto flex-wrap justify-start gap-1 border-b py-1"
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="data-[state=active]:bg-accent/60 max-w-[220px]"
|
||||
title={tab.label}
|
||||
>
|
||||
<span className="truncate">{tab.label}</span>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col pt-3">
|
||||
{run.error && (
|
||||
<div className="text-destructive border-destructive/30 bg-destructive/5 mb-3 rounded-md border p-2 text-xs">
|
||||
{run.error}
|
||||
</div>
|
||||
)}
|
||||
{fileIsLoading ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在加载文件内容...
|
||||
</div>
|
||||
) : isEditing ? (
|
||||
<Textarea
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
className="min-h-0 flex-1 resize-none font-mono text-sm"
|
||||
spellCheck={false}
|
||||
/>
|
||||
) : (
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{showEmptyHint ? (
|
||||
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||
无内容。点击「编辑」开始添加。
|
||||
</div>
|
||||
) : (
|
||||
<MarkdownContent
|
||||
className="max-w-none pr-2"
|
||||
content={activeSource}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
/>
|
||||
)}
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -78,6 +78,7 @@ function displayUsername(value?: string | null): string {
|
||||
|
||||
const emptyForm = {
|
||||
name: "",
|
||||
description: "",
|
||||
schedule: "",
|
||||
timezone: "Asia/Shanghai",
|
||||
prompt: "",
|
||||
@@ -116,9 +117,13 @@ export default function ScheduledTasksPage() {
|
||||
|
||||
const sortedTasks = useMemo(
|
||||
() =>
|
||||
[...tasks].sort((a, b) =>
|
||||
String(a.next_run_at ?? "").localeCompare(String(b.next_run_at ?? "")),
|
||||
),
|
||||
[...tasks].sort((a, b) => {
|
||||
// Newest task first. Fall back to next_run_at so tasks that lack a
|
||||
// created_at (older rows / subscribed tasks) still order consistently.
|
||||
const aKey = a.created_at ?? a.next_run_at ?? "";
|
||||
const bKey = b.created_at ?? b.next_run_at ?? "";
|
||||
return String(bKey).localeCompare(String(aKey));
|
||||
}),
|
||||
[tasks],
|
||||
);
|
||||
const selectedTask = sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null;
|
||||
@@ -162,6 +167,7 @@ export default function ScheduledTasksPage() {
|
||||
setEditing(task);
|
||||
setForm({
|
||||
name: task.name,
|
||||
description: task.description ?? "",
|
||||
schedule: task.schedule_text,
|
||||
timezone: task.timezone,
|
||||
prompt: task.prompt,
|
||||
@@ -190,6 +196,7 @@ export default function ScheduledTasksPage() {
|
||||
if (creating) {
|
||||
const task = await actions.create.mutateAsync({
|
||||
name: form.name || "定时任务",
|
||||
description: form.description.trim() || null,
|
||||
schedule: cronSchedule,
|
||||
timezone: form.timezone || "Asia/Shanghai",
|
||||
prompt: form.prompt,
|
||||
@@ -202,6 +209,7 @@ export default function ScheduledTasksPage() {
|
||||
taskId: editing.task_id,
|
||||
data: {
|
||||
name: form.name,
|
||||
description: form.description.trim() || null,
|
||||
schedule: cronSchedule,
|
||||
timezone: form.timezone,
|
||||
prompt: form.prompt,
|
||||
@@ -324,9 +332,11 @@ export default function ScheduledTasksPage() {
|
||||
onClick={() => setSelectedTaskId(task.task_id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate font-medium">{task.name}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium leading-snug break-all">
|
||||
{task.name}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
{isRunningNow && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
@@ -351,7 +361,7 @@ export default function ScheduledTasksPage() {
|
||||
{task.schedule_text} · {task.timezone}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={task.enabled ? "default" : "secondary"}>
|
||||
<Badge variant={task.enabled ? "default" : "secondary"} className="shrink-0">
|
||||
{task.enabled ? "启用" : "暂停"}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -760,6 +770,14 @@ export default function ScheduledTasksPage() {
|
||||
}
|
||||
placeholder="任务名称"
|
||||
/>
|
||||
<Textarea
|
||||
className="min-h-16"
|
||||
value={form.description}
|
||||
onChange={(event) =>
|
||||
setForm((value) => ({ ...value, description: event.target.value }))
|
||||
}
|
||||
placeholder="任务描述(可选,将显示在公开页标题下方)"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-xs">执行频率</p>
|
||||
<ScheduleBuilder value={scheduleConfig} onChange={setScheduleConfig} />
|
||||
@@ -786,7 +804,7 @@ export default function ScheduledTasksPage() {
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">默认助手(不指定)</SelectItem>
|
||||
{agents.map((agent) => (
|
||||
<SelectItem key={agent.name} value={agent.name}>
|
||||
<SelectItem key={agent.id} value={agent.id}>
|
||||
{agent.name}
|
||||
{agent.description ? ` — ${agent.description}` : ""}
|
||||
</SelectItem>
|
||||
|
||||
Reference in new issue
Block a user