Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xuhd
This commit is contained in:
commit
070cd07199
372 files changed
+86240
-4969
No files matched your search
@@ -0,0 +1,491 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Download,
|
||||
Edit3,
|
||||
Eye,
|
||||
Pause,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
||||
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,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
WorkspaceHeader,
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import {
|
||||
buildCron,
|
||||
DEFAULT_SCHEDULE_CONFIG,
|
||||
parseCron,
|
||||
ScheduleBuilder,
|
||||
type ScheduleConfig,
|
||||
statusLabel,
|
||||
statusVariant,
|
||||
} from "@/components/workspace/scheduled-tasks/schedule-builder";
|
||||
import {
|
||||
downloadScheduledTaskRun,
|
||||
type ScheduledTask,
|
||||
useAdminScheduledTaskActions,
|
||||
useAdminScheduledTaskRuns,
|
||||
useAllScheduledTasks,
|
||||
} from "@/core/scheduled-tasks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
return value ? new Date(value).toLocaleString() : "暂无";
|
||||
}
|
||||
|
||||
/** Render a creator-like identifier without the email domain part. */
|
||||
function displayUsername(value?: string | null): string {
|
||||
const raw = (value ?? "").trim();
|
||||
if (!raw) return "";
|
||||
const at = raw.indexOf("@");
|
||||
return at === -1 ? raw : raw.slice(0, at);
|
||||
}
|
||||
|
||||
const emptyForm = {
|
||||
name: "",
|
||||
timezone: "Asia/Shanghai",
|
||||
prompt: "",
|
||||
};
|
||||
|
||||
export default function AdminScheduledTasksPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
// ?task= carries the task to re-select when returning from a run detail.
|
||||
const initialTaskId = searchParams.get("task");
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const { data: tasks = [], isLoading } = useAllScheduledTasks({ search });
|
||||
const actions = useAdminScheduledTaskActions();
|
||||
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(
|
||||
initialTaskId,
|
||||
);
|
||||
// One-shot scroll target after a back-navigation.
|
||||
const pendingScrollRef = useRef<string | null>(initialTaskId);
|
||||
const [editing, setEditing] = useState<ScheduledTask | null>(null);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [scheduleConfig, setScheduleConfig] = useState<ScheduleConfig>(
|
||||
DEFAULT_SCHEDULE_CONFIG,
|
||||
);
|
||||
|
||||
const sortedTasks = useMemo(
|
||||
() =>
|
||||
[...tasks].sort((a, b) =>
|
||||
String(a.next_run_at ?? "").localeCompare(String(b.next_run_at ?? "")),
|
||||
),
|
||||
[tasks],
|
||||
);
|
||||
const selectedTask =
|
||||
sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null;
|
||||
const { data: runs = [], isLoading: runsLoading } =
|
||||
useAdminScheduledTaskRuns(selectedTaskId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedTaskId && sortedTasks[0]) {
|
||||
setSelectedTaskId(sortedTasks[0].task_id);
|
||||
}
|
||||
if (
|
||||
selectedTaskId &&
|
||||
!sortedTasks.some((task) => task.task_id === selectedTaskId)
|
||||
) {
|
||||
setSelectedTaskId(sortedTasks[0]?.task_id ?? null);
|
||||
}
|
||||
}, [selectedTaskId, sortedTasks]);
|
||||
|
||||
const openEdit = (task: ScheduledTask) => {
|
||||
setEditing(task);
|
||||
setForm({
|
||||
name: task.name,
|
||||
timezone: task.timezone,
|
||||
prompt: task.prompt,
|
||||
});
|
||||
setScheduleConfig(
|
||||
parseCron(task.cron_expr) ??
|
||||
parseCron(task.schedule_text) ??
|
||||
DEFAULT_SCHEDULE_CONFIG,
|
||||
);
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
setScheduleConfig(DEFAULT_SCHEDULE_CONFIG);
|
||||
};
|
||||
|
||||
const saveTask = async () => {
|
||||
if (!editing) return;
|
||||
try {
|
||||
await actions.update.mutateAsync({
|
||||
taskId: editing.task_id,
|
||||
data: {
|
||||
name: form.name,
|
||||
schedule: buildCron(scheduleConfig),
|
||||
timezone: form.timezone,
|
||||
prompt: form.prompt,
|
||||
},
|
||||
});
|
||||
toast.success("任务已更新");
|
||||
closeDialog();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEnabled = async (task: ScheduledTask) => {
|
||||
try {
|
||||
await actions.update.mutateAsync({
|
||||
taskId: task.task_id,
|
||||
data: { enabled: !task.enabled },
|
||||
});
|
||||
toast.success(task.enabled ? "任务已暂停" : "任务已启用");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "操作失败");
|
||||
}
|
||||
};
|
||||
|
||||
const removeTask = async (task: ScheduledTask) => {
|
||||
if (!window.confirm(`确认删除任务「${task.name}」?该操作不可撤销。`)) return;
|
||||
try {
|
||||
await actions.remove.mutateAsync(task.task_id);
|
||||
toast.success("任务已删除");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
const triggerTask = async (task: ScheduledTask) => {
|
||||
try {
|
||||
await actions.trigger.mutateAsync(task.task_id);
|
||||
toast.success("已触发执行");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "触发失败");
|
||||
}
|
||||
};
|
||||
|
||||
const downloadRun = async (runId: string) => {
|
||||
try {
|
||||
await downloadScheduledTaskRun(runId);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "下载失败");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRun = async (runId: string) => {
|
||||
if (!window.confirm("确认删除这条执行记录?该操作不可撤销。")) return;
|
||||
try {
|
||||
await actions.removeRun.mutateAsync(runId);
|
||||
toast.success("执行记录已删除");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "删除失败");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<WorkspaceContainer>
|
||||
<WorkspaceHeader />
|
||||
<WorkspaceBody>
|
||||
<div className="grid size-full min-h-0 grid-cols-1 gap-4 p-4 xl:grid-cols-[460px_minmax(0,1fr)]">
|
||||
<Card className="flex min-h-0 flex-col">
|
||||
<CardHeader className="gap-3">
|
||||
<div>
|
||||
<CardTitle>定时任务管理</CardTitle>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
管理员视图:查看、编辑、删除全部用户的定时任务
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setSearch(searchInput.trim());
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={searchInput}
|
||||
onChange={(event) => setSearchInput(event.target.value)}
|
||||
placeholder="按任务名称搜索"
|
||||
/>
|
||||
<Button type="submit" variant="outline">
|
||||
<Search className="size-4" />
|
||||
搜索
|
||||
</Button>
|
||||
</form>
|
||||
</CardHeader>
|
||||
<CardContent className="min-h-0 flex-1 p-0">
|
||||
<ScrollArea className="size-full">
|
||||
<div className="flex flex-col gap-3 p-4 pt-0">
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
正在加载任务...
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && sortedTasks.length === 0 && (
|
||||
<div className="text-muted-foreground rounded-md border p-4 text-sm">
|
||||
没有匹配的定时任务。
|
||||
</div>
|
||||
)}
|
||||
{sortedTasks.map((task) => (
|
||||
<button
|
||||
key={task.task_id}
|
||||
ref={(el) => {
|
||||
if (el && pendingScrollRef.current === task.task_id) {
|
||||
pendingScrollRef.current = null;
|
||||
el.scrollIntoView({ block: "center" });
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"bg-card text-card-foreground rounded-md border p-4 text-left transition-colors hover:bg-accent/60",
|
||||
selectedTaskId === task.task_id &&
|
||||
"border-primary bg-accent",
|
||||
)}
|
||||
onClick={() => setSelectedTaskId(task.task_id)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">
|
||||
{task.name}
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 truncate text-xs">
|
||||
{task.schedule_text} · {task.timezone}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={task.enabled ? "default" : "secondary"}>
|
||||
{task.enabled ? "启用" : "暂停"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-3 grid gap-1 text-xs">
|
||||
<div>
|
||||
创建者:
|
||||
{displayUsername(task.creator_email) ||
|
||||
task.creator_user_id ||
|
||||
"未知"}
|
||||
</div>
|
||||
<div>下次执行:{formatDate(task.next_run_at)}</div>
|
||||
<div>上次执行:{formatDate(task.last_run_at)}</div>
|
||||
<div>最近状态:{statusLabel(task.last_status)}</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title="编辑"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
openEdit(task);
|
||||
}}
|
||||
>
|
||||
<Edit3 className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title={task.enabled ? "暂停" : "启用"}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void toggleEnabled(task);
|
||||
}}
|
||||
>
|
||||
{task.enabled ? (
|
||||
<Pause className="size-4" />
|
||||
) : (
|
||||
<Play className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
title="立即执行"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void triggerTask(task);
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="destructive"
|
||||
title="删除"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void removeTask(task);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="flex min-h-0 flex-col">
|
||||
<CardHeader className="gap-2">
|
||||
<CardTitle>执行记录</CardTitle>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{selectedTask ? selectedTask.name : "请选择左侧任务"}
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent className="min-h-0 flex-1 p-0">
|
||||
<ScrollArea className="size-full">
|
||||
<div className="flex flex-col gap-3 p-4 pt-0">
|
||||
{runsLoading && (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
正在加载执行记录...
|
||||
</div>
|
||||
)}
|
||||
{!selectedTask && (
|
||||
<div className="text-muted-foreground rounded-md border p-4 text-sm">
|
||||
请先选择一个定时任务。
|
||||
</div>
|
||||
)}
|
||||
{!runsLoading && selectedTask && runs.length === 0 && (
|
||||
<div className="text-muted-foreground rounded-md border p-4 text-sm">
|
||||
这个任务还没有执行记录。
|
||||
</div>
|
||||
)}
|
||||
{runs.map((run) => (
|
||||
<div
|
||||
key={run.id}
|
||||
className="rounded-md border p-4 transition-colors hover:bg-accent/40"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">
|
||||
{run.result?.title || formatDate(run.scheduled_for)}
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
计划:{formatDate(run.scheduled_for)} · 开始:
|
||||
{formatDate(run.started_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{(run.retry_count ?? 0) > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
已重试 {run.retry_count}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant={statusVariant(run.status)}>
|
||||
{statusLabel(run.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
{run.error && (
|
||||
<div className="text-destructive mt-3 line-clamp-2 text-sm">
|
||||
{run.error}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/page/workspace/scheduled/runs/${run.id}?from=admin&task=${encodeURIComponent(run.task_id)}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
查看结果
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!run.result}
|
||||
onClick={() => void downloadRun(run.id)}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
下载结果
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => void deleteRun(run.id)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</WorkspaceBody>
|
||||
|
||||
<Dialog open={!!editing} onOpenChange={(open) => !open && closeDialog()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑定时任务</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(event) =>
|
||||
setForm((value) => ({ ...value, name: event.target.value }))
|
||||
}
|
||||
placeholder="任务名称"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-muted-foreground text-xs">执行频率</p>
|
||||
<ScheduleBuilder
|
||||
value={scheduleConfig}
|
||||
onChange={setScheduleConfig}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
value={form.timezone}
|
||||
onChange={(event) =>
|
||||
setForm((value) => ({
|
||||
...value,
|
||||
timezone: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="时区,例如:Asia/Shanghai"
|
||||
/>
|
||||
<Textarea
|
||||
className="min-h-40"
|
||||
value={form.prompt}
|
||||
onChange={(event) =>
|
||||
setForm((value) => ({ ...value, prompt: event.target.value }))
|
||||
}
|
||||
placeholder="任务 Prompt"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={closeDialog}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={saveTask}>保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import {
|
||||
WorkspaceBody,
|
||||
WorkspaceContainer,
|
||||
@@ -20,6 +23,7 @@ export default function ChatsPage() {
|
||||
const { t } = useI18n();
|
||||
const { data: threads } = useThreads();
|
||||
const [search, setSearch] = useState("");
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${t.pages.chats} - ${t.pages.appName}`;
|
||||
@@ -38,7 +42,17 @@ export default function ChatsPage() {
|
||||
<WorkspaceHeader></WorkspaceHeader>
|
||||
<WorkspaceBody>
|
||||
<div className="flex size-full flex-col">
|
||||
<header className="flex shrink-0 items-center justify-center pt-8">
|
||||
<header className="flex shrink-0 flex-col items-center gap-3 pt-8">
|
||||
<div className="flex w-full max-w-(--container-width-md) items-center justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setImportOpen(true)}
|
||||
>
|
||||
<DownloadIcon className="size-4" />
|
||||
{t.threadShare.importChats}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
type="search"
|
||||
className="h-12 w-full max-w-(--container-width-md) text-xl"
|
||||
@@ -70,6 +84,10 @@ export default function ChatsPage() {
|
||||
</main>
|
||||
</div>
|
||||
</WorkspaceBody>
|
||||
<ImportConversationsDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
/>
|
||||
</WorkspaceContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { MessageList } from "@/components/workspace/messages";
|
||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||
import { SubtasksProvider } from "@/core/tasks/context";
|
||||
import { getPublicShareSnapshot } from "@/core/thread-shares/api";
|
||||
import type { AgentThreadState } from "@/core/threads";
|
||||
|
||||
/**
|
||||
* Public, read-only conversation viewer.
|
||||
*
|
||||
* Reachable at `#/share/<code>` with NO authentication — anyone with the link
|
||||
* can open it. It renders a frozen snapshot of a `view`-mode shared
|
||||
* conversation; there is no input box, so viewers cannot send new messages.
|
||||
*/
|
||||
export default function PublicSharePage() {
|
||||
const { shareCode } = useParams<{ shareCode: string }>();
|
||||
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ["public-share", shareCode],
|
||||
queryFn: () => getPublicShareSnapshot(shareCode!),
|
||||
enabled: !!shareCode,
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
// A minimal static stand-in for the live LangGraph stream. MessageList only
|
||||
// reads `messages`, `values.artifacts`, `isLoading` and `isThreadLoading`.
|
||||
const thread = useMemo(() => {
|
||||
const messages = data?.messages ?? [];
|
||||
return {
|
||||
messages,
|
||||
values: {
|
||||
title: data?.title ?? "",
|
||||
messages,
|
||||
artifacts: data?.artifacts ?? [],
|
||||
todos: [],
|
||||
},
|
||||
isLoading: false,
|
||||
isThreadLoading: false,
|
||||
error: undefined,
|
||||
interrupt: undefined,
|
||||
} as unknown as BaseStream<AgentThreadState>;
|
||||
}, [data]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-background text-muted-foreground flex h-screen items-center justify-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
正在加载分享内容…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<div className="bg-background flex h-screen flex-col items-center justify-center gap-2 px-6 text-center">
|
||||
<p className="text-lg font-medium">分享不可用</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
该分享链接不存在、已被撤销,或不是只读分享。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-background flex h-screen flex-col">
|
||||
<header className="flex shrink-0 flex-col gap-1 border-b px-6 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="truncate text-base font-semibold">
|
||||
{data.title || "未命名对话"}
|
||||
</h1>
|
||||
<span className="bg-muted text-muted-foreground shrink-0 rounded-full px-2 py-0.5 text-xs">
|
||||
只读分享
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
这是一个只读对话快照,你可以查看全部内容,但无法继续提问。
|
||||
</p>
|
||||
</header>
|
||||
<main className="min-h-0 flex-1">
|
||||
<SubtasksProvider>
|
||||
<ArtifactsProvider>
|
||||
<ThreadContext.Provider
|
||||
value={{ thread, isMock: true, publicShareCode: shareCode }}
|
||||
>
|
||||
<MessageList
|
||||
threadId={shareCode ?? "public-share"}
|
||||
thread={thread}
|
||||
paddingBottom={48}
|
||||
/>
|
||||
</ThreadContext.Provider>
|
||||
</ArtifactsProvider>
|
||||
</SubtasksProvider>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown, Download, Edit3, FileText, ListChecks, Loader2, Save, Trash2, X } from "lucide-react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Activity, ChevronDown, ChevronLeft, Download, Edit3, FileText, Loader2, Save, Trash2, X } from "lucide-react";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -24,9 +24,11 @@ import {
|
||||
getScheduledTaskRunFileContent,
|
||||
type ScheduledTaskResultFileContent,
|
||||
type ScheduledTaskRun,
|
||||
type ScheduledTaskRunMessage,
|
||||
updateScheduledTaskRunFile,
|
||||
useScheduledTaskActions,
|
||||
useScheduledTaskRun,
|
||||
useScheduledTaskRunMessages,
|
||||
} from "@/core/scheduled-tasks";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -97,12 +99,117 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
|
||||
|
||||
type EditState = { draft: string; saving: boolean };
|
||||
|
||||
/** Flatten a LangChain-style message content (string | parts | nested) to text. */
|
||||
function messageText(content: unknown): string {
|
||||
if (content == null) return "";
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((part) => {
|
||||
if (typeof part === "string") return part;
|
||||
if (part && typeof part === "object" && "text" in part) {
|
||||
return String((part as { text?: unknown }).text ?? "");
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
if (typeof content === "object" && "content" in content) {
|
||||
return messageText((content as { content?: unknown }).content);
|
||||
}
|
||||
return JSON.stringify(content, null, 2);
|
||||
}
|
||||
|
||||
const EXECUTION_ROLE: Record<string, { label: string; tone: string }> = {
|
||||
"llm.human.input": { label: "触发输入", tone: "bg-muted text-foreground" },
|
||||
"llm.ai.response": { label: "智能体", tone: "bg-primary/10 text-primary" },
|
||||
"llm.tool.result": { label: "工具结果", tone: "bg-amber-500/10 text-amber-600 dark:text-amber-400" },
|
||||
};
|
||||
|
||||
/** One agent message rendered inside the execution-process tab. */
|
||||
function ExecutionMessage({ message }: { message: ScheduledTaskRunMessage }) {
|
||||
const role = EXECUTION_ROLE[message.event_type] ?? {
|
||||
label: message.event_type,
|
||||
tone: "bg-muted text-foreground",
|
||||
};
|
||||
const raw = message.content;
|
||||
const obj = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
|
||||
const text = messageText(raw);
|
||||
const toolName = typeof obj?.name === "string" ? obj.name : null;
|
||||
const toolCalls = Array.isArray(obj?.tool_calls)
|
||||
? (obj!.tool_calls as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const isAi = message.event_type === "llm.ai.response";
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<div className="flex items-center gap-2 border-b px-3 py-1.5">
|
||||
<span className={cn("rounded px-1.5 py-0.5 text-xs font-medium", role.tone)}>
|
||||
{role.label}
|
||||
{toolName ? ` · ${toolName}` : ""}
|
||||
</span>
|
||||
{message.created_at && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{new Date(message.created_at).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{text ? (
|
||||
isAi ? (
|
||||
<MarkdownContent className="max-w-none" content={text} isLoading={false} rehypePlugins={[]} />
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap break-words text-xs">{text}</pre>
|
||||
)
|
||||
) : (
|
||||
!toolCalls.length && <div className="text-muted-foreground text-xs">(无文本内容)</div>
|
||||
)}
|
||||
{toolCalls.length > 0 && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
{toolCalls.map((call, index) => (
|
||||
<div key={index} className="bg-muted rounded px-2 py-1 text-xs">
|
||||
<span className="font-medium">调用工具:{String(call.name ?? "未知")}</span>
|
||||
{call.args != null && (
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words">
|
||||
{typeof call.args === "string" ? call.args : JSON.stringify(call.args, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ScheduledTaskRunDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const { run_id } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const actions = useScheduledTaskActions();
|
||||
const { data: run, isLoading } = useScheduledTaskRun(run_id);
|
||||
const result = run?.result ?? null;
|
||||
// A run still executing — drives live polling of its agent messages.
|
||||
const runLive = run?.status === "running" || run?.status === "pending";
|
||||
const hasExecution = !!run?.agent_run_id;
|
||||
const { data: messagesData, isLoading: messagesLoading } = useScheduledTaskRunMessages(
|
||||
hasExecution ? run_id : null,
|
||||
{ live: runLive },
|
||||
);
|
||||
const runMessages = messagesData?.messages ?? [];
|
||||
|
||||
// Back navigation returns to the originating list (admin vs. mine) and
|
||||
// re-selects the task the user came from (carried in ?task=).
|
||||
const handleBack = useCallback(() => {
|
||||
const base =
|
||||
searchParams.get("from") === "admin"
|
||||
? "/page/workspace/admin/scheduled-tasks"
|
||||
: "/page/workspace/scheduled/tasks";
|
||||
const taskId = run?.task_id ?? searchParams.get("task");
|
||||
navigate(taskId ? `${base}?task=${encodeURIComponent(taskId)}` : base);
|
||||
}, [navigate, run?.task_id, searchParams]);
|
||||
const [fileContents, setFileContents] = useState<ScheduledTaskResultFileContent[]>([]);
|
||||
const [filesLoading, setFilesLoading] = useState(false);
|
||||
const [showMeta, setShowMeta] = useState(false);
|
||||
@@ -257,7 +364,7 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
try {
|
||||
await actions.removeRun.mutateAsync(run.id);
|
||||
toast.success("执行记录已删除");
|
||||
navigate("/page/workspace/scheduled/tasks");
|
||||
handleBack();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "删除失败");
|
||||
}
|
||||
@@ -281,9 +388,9 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
<ChevronDown className={cn("size-3 transition-transform", showMeta && "rotate-180")} />
|
||||
执行信息
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => navigate("/page/workspace/scheduled/tasks")}>
|
||||
<ListChecks className="size-3" />
|
||||
返回记录
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleBack}>
|
||||
<ChevronLeft className="size-3" />
|
||||
返回
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleDownloadResult} disabled={!result}>
|
||||
<Download className="size-3" />
|
||||
@@ -330,28 +437,45 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
{/* 内容区 */}
|
||||
{!result && run && (
|
||||
{run && !result && !hasExecution && (
|
||||
<div className="text-muted-foreground p-4 text-sm">
|
||||
这条记录暂时还没有结果,任务可能仍在执行中。
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
<Tabs defaultValue={fileCount > 0 ? "files" : "body"} className="flex min-h-0 flex-1 flex-col">
|
||||
{run && (result || hasExecution) && (
|
||||
<Tabs
|
||||
defaultValue={result ? (fileCount > 0 ? "files" : "body") : "execution"}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="border-b px-3 pt-1">
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="body" className="text-xs">
|
||||
<FileText className="mr-1 size-3" />
|
||||
正文
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="files" className="text-xs">
|
||||
结果文件{fileCount > 0 && `(${fileCount})`}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="meta" className="text-xs">
|
||||
元数据
|
||||
</TabsTrigger>
|
||||
{result && (
|
||||
<TabsTrigger value="body" className="text-xs">
|
||||
<FileText className="mr-1 size-3" />
|
||||
正文
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{result && (
|
||||
<TabsTrigger value="files" className="text-xs">
|
||||
结果文件{fileCount > 0 && `(${fileCount})`}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{hasExecution && (
|
||||
<TabsTrigger value="execution" className="text-xs">
|
||||
<Activity className="mr-1 size-3" />
|
||||
执行过程{runMessages.length > 0 && `(${runMessages.length})`}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{result && (
|
||||
<TabsTrigger value="meta" className="text-xs">
|
||||
元数据
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{result && (
|
||||
<>
|
||||
<TabsContent value="body" className="m-0 p-4">
|
||||
{result.format === "markdown" || result.format === "mixed" ? (
|
||||
<MarkdownContent
|
||||
@@ -471,6 +595,31 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
{resultMetadata}
|
||||
</pre>
|
||||
</TabsContent>
|
||||
</>
|
||||
)}
|
||||
{hasExecution && (
|
||||
<TabsContent value="execution" className="m-0 p-3">
|
||||
{runLive && (
|
||||
<div className="text-primary border-primary/30 bg-primary/5 mb-3 flex items-center gap-2 rounded-md border p-2 text-xs">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
任务执行中,下方消息每 4 秒自动刷新…
|
||||
</div>
|
||||
)}
|
||||
{messagesLoading && runMessages.length === 0 && (
|
||||
<div className="text-muted-foreground text-sm">正在加载执行消息...</div>
|
||||
)}
|
||||
{!messagesLoading && runMessages.length === 0 && (
|
||||
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||
暂无执行消息,任务可能还未开始执行。
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
{runMessages.map((message) => (
|
||||
<ExecutionMessage key={message.seq} message={message} />
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
BellOff,
|
||||
BellRing,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
TagsIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -51,199 +52,17 @@ import {
|
||||
useScheduledTasks,
|
||||
usePublishedScheduledTasks,
|
||||
} from "@/core/scheduled-tasks";
|
||||
import {
|
||||
buildCron,
|
||||
DEFAULT_SCHEDULE_CONFIG,
|
||||
parseCron,
|
||||
ScheduleBuilder,
|
||||
type ScheduleConfig,
|
||||
statusLabel,
|
||||
statusVariant,
|
||||
} from "@/components/workspace/scheduled-tasks/schedule-builder";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// ---- 时间配置 ----
|
||||
type ScheduleFreq = "hourly" | "daily" | "weekly" | "monthly";
|
||||
|
||||
interface ScheduleConfig {
|
||||
freq: ScheduleFreq;
|
||||
minute: number;
|
||||
hour: number;
|
||||
weekday: number;
|
||||
date: number;
|
||||
}
|
||||
|
||||
const DEFAULT_SCHEDULE_CONFIG: ScheduleConfig = {
|
||||
freq: "daily",
|
||||
minute: 0,
|
||||
hour: 9,
|
||||
weekday: 1,
|
||||
date: 1,
|
||||
};
|
||||
|
||||
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
const MINUTES = [0, 15, 30, 45];
|
||||
const DATES = Array.from({ length: 31 }, (_, i) => i + 1);
|
||||
|
||||
function buildCron(cfg: ScheduleConfig): string {
|
||||
// Keep the minute slot when the user picked one for daily/weekly/monthly too,
|
||||
// so "每天 9:30" doesn't silently truncate to 9:00 on save → reload round trips.
|
||||
const m = Number.isFinite(cfg.minute) ? cfg.minute : 0;
|
||||
switch (cfg.freq) {
|
||||
case "hourly":
|
||||
return `${m} * * * *`;
|
||||
case "daily":
|
||||
return `${m} ${cfg.hour} * * *`;
|
||||
case "weekly":
|
||||
return `${m} ${cfg.hour} * * ${cfg.weekday}`;
|
||||
case "monthly":
|
||||
return `${m} ${cfg.hour} ${cfg.date} * *`;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a 5-field cron expression into the form picker state.
|
||||
//
|
||||
// Bug we keep getting bitten by: the edit dialog used to call
|
||||
// `parseCron(task.schedule_text)` and `schedule_text` may be the raw user
|
||||
// input (free-text like "每天 9 点") rather than a real cron string. The
|
||||
// fallback `DEFAULT_SCHEDULE_CONFIG` then made the picker silently
|
||||
// reinterpret the task as "every day at 09:00" — or, when natural-language
|
||||
// resolved to "0 * * * *" on the backend, the picker honestly reflected
|
||||
// that as "每小时 0 分". Either way the user lost their original intent.
|
||||
//
|
||||
// Caller now prefers `task.cron_expr` (the canonical schedule produced by
|
||||
// `_schedule_to_cron`); this function stays the single source of truth for
|
||||
// cron → picker, and also handles the looser step-syntax variants
|
||||
// (e.g. "0 [STAR]/3 * * *") we sometimes generate.
|
||||
function parseCron(cron: string | null | undefined): ScheduleConfig | null {
|
||||
if (!cron) return null;
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
const [min, hr, date, , wd] = parts;
|
||||
const minute = parseInt(min ?? "", 10);
|
||||
const hour = parseInt(hr ?? "", 10);
|
||||
|
||||
// Hourly: ``M * * * *`` — match before the daily branch so a minute-only
|
||||
// cron isn't misread as daily.
|
||||
if (hr === "*" && date === "*" && wd === "*") {
|
||||
if (!isNaN(minute)) {
|
||||
return { ...DEFAULT_SCHEDULE_CONFIG, freq: "hourly", minute };
|
||||
}
|
||||
}
|
||||
// ``0 */N * * *`` (every N hours): map to hourly with that minute; the
|
||||
// picker doesn't model "every N hours" yet, so this is the safest fallback
|
||||
// that at least doesn't silently round to 9:00.
|
||||
if ((hr ?? "").startsWith("*/") && date === "*" && wd === "*") {
|
||||
if (!isNaN(minute)) {
|
||||
return { ...DEFAULT_SCHEDULE_CONFIG, freq: "hourly", minute };
|
||||
}
|
||||
}
|
||||
if (date === "*" && wd === "*") {
|
||||
if (!isNaN(hour)) {
|
||||
return {
|
||||
...DEFAULT_SCHEDULE_CONFIG,
|
||||
freq: "daily",
|
||||
hour,
|
||||
minute: isNaN(minute) ? 0 : minute,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (date === "*" && wd !== "*") {
|
||||
const weekday = parseInt(wd ?? "", 10);
|
||||
if (!isNaN(hour) && !isNaN(weekday)) {
|
||||
return {
|
||||
...DEFAULT_SCHEDULE_CONFIG,
|
||||
freq: "weekly",
|
||||
hour,
|
||||
weekday,
|
||||
minute: isNaN(minute) ? 0 : minute,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (wd === "*" && date !== "*") {
|
||||
const d = parseInt(date ?? "", 10);
|
||||
if (!isNaN(hour) && !isNaN(d)) {
|
||||
return {
|
||||
...DEFAULT_SCHEDULE_CONFIG,
|
||||
freq: "monthly",
|
||||
hour,
|
||||
date: d,
|
||||
minute: isNaN(minute) ? 0 : minute,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ScheduleBuilder({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: ScheduleConfig;
|
||||
onChange: (cfg: ScheduleConfig) => void;
|
||||
}) {
|
||||
const set = (patch: Partial<ScheduleConfig>) => onChange({ ...value, ...patch });
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select value={value.freq} onValueChange={(v) => set({ freq: v as ScheduleFreq })}>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hourly">每小时</SelectItem>
|
||||
<SelectItem value="daily">每天</SelectItem>
|
||||
<SelectItem value="weekly">每周</SelectItem>
|
||||
<SelectItem value="monthly">每月</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{value.freq === "hourly" && (
|
||||
<Select value={String(value.minute)} onValueChange={(v) => set({ minute: parseInt(v) })}>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MINUTES.map((m) => (
|
||||
<SelectItem key={m} value={String(m)}>{m} 分</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{value.freq === "weekly" && (
|
||||
<Select value={String(value.weekday)} onValueChange={(v) => set({ weekday: parseInt(v) })}>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WEEKDAYS.map((d, i) => (
|
||||
<SelectItem key={i} value={String(i)}>{d}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{value.freq === "monthly" && (
|
||||
<Select value={String(value.date)} onValueChange={(v) => set({ date: parseInt(v) })}>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATES.map((d) => (
|
||||
<SelectItem key={d} value={String(d)}>第 {d} 日</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{value.freq !== "hourly" && (
|
||||
<Select value={String(value.hour)} onValueChange={(v) => set({ hour: parseInt(v) })}>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HOURS.map((h) => (
|
||||
<SelectItem key={h} value={String(h)}>{h} 时</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null) {
|
||||
return value ? new Date(value).toLocaleString() : "暂无";
|
||||
}
|
||||
@@ -256,28 +75,6 @@ function displayUsername(value?: string | null): string {
|
||||
return at === -1 ? raw : raw.slice(0, at);
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
const emptyForm = {
|
||||
name: "",
|
||||
schedule: "",
|
||||
@@ -288,6 +85,10 @@ const emptyForm = {
|
||||
|
||||
export default function ScheduledTasksPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
// When returning from a run-detail page the task id is carried in ?task=,
|
||||
// so the list re-selects (and scrolls to) the task the user was viewing.
|
||||
const initialTaskId = searchParams.get("task");
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagId, setTagId] = useState<string | null>(null);
|
||||
// Name + tag filtering is resolved server-side via the /api/scheduled-tasks query.
|
||||
@@ -298,7 +99,9 @@ export default function ScheduledTasksPage() {
|
||||
const [activeTab, setActiveTab] = useState<"mine" | "plaza">("mine");
|
||||
const { data: published = [], isLoading: publishedLoading } =
|
||||
usePublishedScheduledTasks(activeTab === "plaza");
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(initialTaskId);
|
||||
// One-shot scroll target: scroll the task into view after a back-navigation.
|
||||
const pendingScrollRef = useRef<string | null>(initialTaskId);
|
||||
const [elementUserId, setElementUserId] = useState("");
|
||||
const [editing, setEditing] = useState<ScheduledTask | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
@@ -504,6 +307,12 @@ export default function ScheduledTasksPage() {
|
||||
return (
|
||||
<button
|
||||
key={task.task_id}
|
||||
ref={(el) => {
|
||||
if (el && pendingScrollRef.current === task.task_id) {
|
||||
pendingScrollRef.current = null;
|
||||
el.scrollIntoView({ block: "center" });
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"bg-card text-card-foreground rounded-md border p-4 text-left transition-colors hover:bg-accent/60",
|
||||
selectedTaskId === task.task_id && "border-primary bg-accent",
|
||||
@@ -802,7 +611,11 @@ export default function ScheduledTasksPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/page/workspace/scheduled/runs/${run.id}`)}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/page/workspace/scheduled/runs/${run.id}?from=mine&task=${encodeURIComponent(run.task_id)}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
查看结果
|
||||
|
||||
@@ -18,6 +18,7 @@ import ChatPage from "./ChatPage";
|
||||
import AgentsPage from "./AgentsPage";
|
||||
import AgentChatPage from "./AgentChatPage";
|
||||
import NewAgentPage from "./NewAgentPage";
|
||||
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
import LlmMetricsPage from "./LlmMetricsPage";
|
||||
@@ -193,6 +194,18 @@ export default function WorkspaceRoutes({
|
||||
</WorkspaceLayout>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/scheduled-tasks"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<WorkspaceLayout {...layoutProps}>
|
||||
<AdminScheduledTasksPage />
|
||||
</WorkspaceLayout>
|
||||
) : (
|
||||
<Navigate to="/page/workspace/chats/new" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="admin/tags"
|
||||
element={
|
||||
|
||||
Reference in new issue
Block a user