This commit is contained in:
董宏凯 committed 2026-05-22 11:11:57 +08:00
commit 6e88c5a1c5
20 files changed
+1047 -386

No files matched your search

+1 -1
View File
@@ -24,7 +24,7 @@ frontend-web/src.zip
frontend-web/src9.zip frontend-web/src9.zip
frontend-web/src-v1.zip frontend-web/src-v1.zip
frontend-web/src-*.rar frontend-web/src-*.rar
frontend-web/dist
# Frontend build artifacts # Frontend build artifacts
frontend-web/dist/ frontend-web/dist/
frontend-web/dist-scheduled-check/ frontend-web/dist-scheduled-check/
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "node --max-old-space-size=8192 ./node_modules/vite/bin/vite.js build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
@@ -46,6 +46,7 @@ export async function listScheduledTasks(params: ListScheduledTasksParams = {})
export async function createScheduledTask(data: { export async function createScheduledTask(data: {
name: string; name: string;
description?: string | null;
prompt: string; prompt: string;
schedule: string; schedule: string;
timezone?: string | null; timezone?: string | null;
@@ -203,6 +204,7 @@ export async function updateScheduledTask(
taskId: string, taskId: string,
data: Partial<{ data: Partial<{
name: string; name: string;
description: string | null;
prompt: string; prompt: string;
schedule: string; schedule: string;
timezone: string; timezone: string;
@@ -329,6 +331,7 @@ export async function adminUpdateScheduledTask(
taskId: string, taskId: string,
data: Partial<{ data: Partial<{
name: string; name: string;
description: string | null;
prompt: string; prompt: string;
schedule: string; schedule: string;
timezone: string; timezone: string;
@@ -14,6 +14,7 @@ export type ScheduledTaskSubscription = {
export type ScheduledTask = { export type ScheduledTask = {
task_id: string; task_id: string;
name: string; name: string;
description?: string | null;
prompt: string; prompt: string;
schedule_text: string; schedule_text: string;
cron_expr: string; cron_expr: string;
@@ -27,6 +28,8 @@ export type ScheduledTask = {
scheduler_thread_id: string; scheduler_thread_id: string;
published?: boolean; published?: boolean;
published_at?: string | null; published_at?: string | null;
created_at?: string | null;
updated_at?: string | null;
role?: ScheduledTaskRole; role?: ScheduledTaskRole;
_subscription?: ScheduledTaskSubscription | null; _subscription?: ScheduledTaskSubscription | null;
creator_user_id?: string | null; creator_user_id?: string | null;
+29 -54
View File
@@ -3,10 +3,11 @@
import { BotIcon, PlusSquare } from "lucide-react"; import { BotIcon, PlusSquare } from "lucide-react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useRouteBase } from "@/core/route-base"; 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 type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AgentWelcome } from "@/components/workspace/agent-welcome";
import { ArtifactTrigger } from "@/components/workspace/artifacts"; import { ArtifactTrigger } from "@/components/workspace/artifacts";
import { ChatBox, useThreadChat } from "@/components/workspace/chats"; import { ChatBox, useThreadChat } from "@/components/workspace/chats";
import { ExportTrigger } from "@/components/workspace/export-trigger"; import { ExportTrigger } from "@/components/workspace/export-trigger";
@@ -55,11 +56,6 @@ export default function AgentChatPage() {
} }
}, [isNewThread, threadId]); }, [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 { showNotification } = useNotification();
const [thread, sendMessage] = useThreadStream({ const [thread, sendMessage] = useThreadStream({
threadId: isNewThread ? undefined : threadId, threadId: isNewThread ? undefined : threadId,
@@ -104,29 +100,6 @@ export default function AgentChatPage() {
[sendMessage, threadId, agent_id], [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( const handleClarificationSubmit = useCallback(
(answer: string) => { (answer: string) => {
setHasStartedConversation(true); setHasStartedConversation(true);
@@ -143,12 +116,7 @@ export default function AgentChatPage() {
? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM + ? MESSAGE_LIST_DEFAULT_PADDING_BOTTOM +
MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM MESSAGE_LIST_FOLLOWUPS_EXTRA_PADDING_BOTTOM
: undefined; : undefined;
// AgentChatPage skips the centered welcome/landing state entirely — when the const isLandingState = isNewThread && !hasStartedConversation;
// 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;
return ( return (
<ThreadContext.Provider value={{ thread }}> <ThreadContext.Provider value={{ thread }}>
@@ -228,25 +196,32 @@ export default function AgentChatPage() {
</div> </div>
</div> </div>
<InputBox <div className="w-full -translate-y-4">
className={cn("bg-background/5 w-full -translate-y-4")} <InputBox
isNewThread={isNewThread} className="w-full"
threadId={threadId} isNewThread={isLandingState}
autoFocus={isNewThread} threadId={threadId}
status={ autoFocus={isLandingState}
thread.error status={
? "error" thread.error
: thread.isLoading ? "error"
? "streaming" : thread.isLoading
: "ready" ? "streaming"
} : "ready"
context={settings.context} }
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"} context={settings.context}
onContextChange={(context) => setSettings("context", context)} extraHeader={
onFollowupsVisibilityChange={setShowFollowups} isLandingState && (
onSubmit={handleSubmit} <AgentWelcome agent={agent} agentName={agent?.name ?? agent_id} />
onStop={handleStop} )
/> }
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" && ( {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true" && (
<div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs"> <div className="text-muted-foreground/67 w-full translate-y-12 text-center text-xs">
{t.common.notAvailableInDemoMode} {t.common.notAvailableInDemoMode}
+591 -301
View File
@@ -1,15 +1,39 @@
"use client"; "use client";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Download, FileText, Loader2 } from "lucide-react"; import {
import { useEffect, useMemo, useState } from "react"; Check,
Download,
FileText,
Loader2,
Pencil,
Trash2,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 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 { 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 { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { getBackendBaseURL } from "@/core/config"; import { getBackendBaseURL } from "@/core/config";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
@@ -19,6 +43,7 @@ import { cn } from "@/lib/utils";
type PublicTask = { type PublicTask = {
task_id: string; task_id: string;
name: string; name: string;
description?: string | null;
schedule_text: string; schedule_text: string;
cron_expr?: string; cron_expr?: string;
timezone: string; timezone: string;
@@ -35,6 +60,9 @@ type PublicRunFile = {
mime_type?: string | null; mime_type?: string | null;
kind?: string | null; kind?: string | null;
size?: number | 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 = { type PublicRunResult = {
@@ -79,8 +107,8 @@ function publicBaseUrl() {
return `${getBackendBaseURL()}/api/public/scheduled-tasks`; return `${getBackendBaseURL()}/api/public/scheduled-tasks`;
} }
async function fetchJson<T>(url: string): Promise<T> { async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url); const response = await fetch(url, init);
if (!response.ok) { if (!response.ok) {
const body = await response.json().catch(() => ({ detail: "Request failed" })); const body = await response.json().catch(() => ({ detail: "Request failed" }));
throw new Error(typeof body.detail === "string" ? body.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 { async function updatePublicRunFile(
return `${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/download`; 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 { 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. * Public, unauthenticated scheduled-task viewer.
* *
* URL: `#/public/scheduled-tasks/<task_id>` * URL: `#/public/scheduled-tasks/<task_id>`
* *
* Left pane lists every run of the task (latest first). Selecting a run shows * Left pane lists every run of the task (latest first). Right pane uses tabs
* its Markdown body on the right by default; the user can switch tabs to view * to switch between the inline body (`正文`) and each Markdown attachment.
* generated files or raw metadata. * 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() { export default function PublicScheduledTaskPage() {
const { taskId } = useParams<{ taskId: string }>(); const { taskId } = useParams<{ taskId: string }>();
const [selectedRunId, setSelectedRunId] = useState<string | null>(null); 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({ const { data, isLoading, isError, error } = useQuery({
queryKey: ["public-scheduled-task", taskId], queryKey: ["public-scheduled-task", taskId],
@@ -164,57 +259,51 @@ export default function PublicScheduledTaskPage() {
refetchInterval: 8000, 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( const selectedRun = useMemo(
() => runs.find((r) => r.id === selectedRunId) ?? null, () => runs.find((r) => r.id === selectedRunId) ?? null,
[runs, selectedRunId], [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(() => { useEffect(() => {
if (!selectedRunId && runs.length > 0) { if (!selectedRunId && runs.length > 0) {
setSelectedRunId(runs[0].id); setSelectedRunId(runs[0]!.id);
} }
if (selectedRunId && !runs.some((r) => r.id === selectedRunId) && runs.length > 0) { if (selectedRunId && !runs.some((r) => r.id === selectedRunId) && runs.length > 0) {
setSelectedRunId(runs[0].id); setSelectedRunId(runs[0]!.id);
} }
}, [runs, selectedRunId]); }, [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) { if (!taskId) {
return ( return (
<div className="text-muted-foreground flex h-screen items-center justify-center text-sm"> <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; const task = data?.task;
return ( 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)]"> <TooltipProvider delayDuration={150}>
<Card className="flex min-h-0 flex-col"> <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)]">
<CardHeader className="gap-1"> <Card className="flex min-h-0 flex-col">
<div className="flex items-center justify-between gap-2"> <CardHeader className="gap-1">
<CardTitle className="truncate text-base"> <div className="flex items-start justify-between gap-2">
{task?.name || "任务"} <Tooltip>
</CardTitle> <TooltipTrigger asChild>
<Badge variant={task?.enabled ? "default" : "secondary"} className="shrink-0"> <CardTitle className="line-clamp-2 text-base leading-snug break-all">
{task?.enabled ? "启用" : "暂停"} {task?.name || "任务"}
</Badge> </CardTitle>
</div> </TooltipTrigger>
<p className="text-muted-foreground truncate text-xs"> {task?.name && (
{task?.schedule_text} · {task?.timezone} <TooltipContent side="bottom" className="max-w-[320px] break-all">
</p> {task.name}
<p className="text-muted-foreground text-xs"> </TooltipContent>
最近状态:{statusLabel(task?.last_status)} · 上次:{formatDate(task?.last_run_at)} )}
</p> </Tooltip>
</CardHeader> <Badge variant={task?.enabled ? "default" : "secondary"} className="shrink-0">
<CardContent className="min-h-0 flex-1 p-0"> {task?.enabled ? "启用" : "暂停"}
<ScrollArea className="size-full"> </Badge>
<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> </div>
</ScrollArea> {task?.description && task.description.trim() && (
</CardContent> <p className="text-muted-foreground line-clamp-3 text-xs leading-relaxed break-all whitespace-pre-wrap">
</Card> {task.description.trim()}
<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> </p>
)} )}
</div> </CardHeader>
{selectedRun?.result && ( <CardContent className="min-h-0 flex-1 p-0">
<div className="flex flex-wrap gap-2"> <ScrollArea className="size-full">
<Button size="sm" variant="outline" onClick={handleDownloadResult}> <div className="flex flex-col gap-2 p-3 pt-0">
<Download className="size-4" /> {runs.length === 0 && (
下载结果 <div className="text-muted-foreground rounded-md border p-3 text-sm">
</Button> 暂无可查看的执行记录。
{!hasMarkdownFiles && selectedRun.result.content && ( </div>
<Button )}
size="sm" {runs.map((run) => {
variant="outline" const active = run.id === selectedRunId;
onClick={handleDownloadBodyAsWord} const runRunning = run.status === "running" || run.status === "pending";
> const runTitle = stripTimestampSuffix(
<FileText className="size-4" /> run.result?.title || formatDate(run.scheduled_for),
下载 Word );
</Button> const isDeletingThis =
)} deleteRunMutation.isPending && deleteRunMutation.variables === run.id;
</div> return (
)} <div
</CardHeader> key={run.id}
<CardContent className="min-h-0 flex-1 overflow-hidden p-0"> role="button"
{selectedRun?.result || selectedRun?.error ? ( tabIndex={0}
hasMarkdownFiles ? ( className={cn(
// Result has MD files — show ONLY those. Body, meta, and other "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",
// attachments are intentionally hidden so the public viewer is active && "border-primary bg-accent",
// focused on the deliverable document. )}
<ScrollArea className="size-full"> onClick={() => setSelectedRunId(run.id)}
<div className="p-3"> onKeyDown={(event) => {
<MarkdownFilesPane runId={selectedRun.id} files={markdownFiles} /> if (event.key === "Enter" || event.key === " ") {
</div> event.preventDefault();
</ScrollArea> setSelectedRunId(run.id);
) : ( }
// No MD files — fall back to the body Markdown. }}
<ScrollArea className="size-full"> >
<div className="p-4"> <div className="flex items-start justify-between gap-2">
{selectedRun.error && ( <div className="min-w-0 flex-1">
<div className="text-destructive border-destructive/30 bg-destructive/5 mb-3 rounded-md border p-2 text-xs"> <Tooltip>
{selectedRun.error} <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> </div>
)} );
{selectedRun.result?.content ? ( })}
selectedRun.result.format === "markdown" || </div>
selectedRun.result.format === "mixed" ? ( </ScrollArea>
<MarkdownContent </CardContent>
className="max-w-none" </Card>
content={selectedRun.result.content}
isLoading={false} <Card className="flex min-h-0 flex-col">
remarkPlugins={reasoningPlugins.remarkPlugins} {selectedRun ? (
rehypePlugins={reasoningPlugins.rehypePlugins} <RunDetailPane key={selectedRun.id} run={selectedRun} taskId={taskId} />
/>
) : (
<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"> <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> </div>
)} )}
</CardContent> <DialogFooter>
</Card> <Button
</div> 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. * 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.
* 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({ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) {
runId, const queryClient = useQueryClient();
files,
}: {
runId: string;
files: PublicRunFile[];
}) {
const [contents, setContents] = useState<Record<number, PublicFileContent>>({});
const [loading, setLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
// 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(() => { useEffect(() => {
let cancelled = false; if (!tabs.some((t) => t.value === activeTab)) {
async function loadAll() { setActiveTab(tabs[0]?.value ?? BODY_TAB_VALUE);
if (files.length === 0) { }
setContents({}); }, [tabs, activeTab]);
return;
} // Fetch each MD file's text once. Keyed by file index inside the run.
setLoading(true); 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 { try {
const all = await Promise.all( const file = await fetchPublicRunFile(run.id, fileIndex);
files.map((f) => fetchPublicRunFile(runId, f.index)), const text = file.previewable && file.content != null ? file.content : "";
); setFileTexts((s) => ({ ...s, [fileIndex]: text }));
if (!cancelled) { } catch {
const map: Record<number, PublicFileContent> = {}; // Race: the archive may have disappeared between list and fetch. Fall
for (const item of all) map[item.index] = item; // back to empty content; the parent filter will drop this tab on the
setContents(map); // next refetch tick.
} setFileTexts((s) => ({ ...s, [fileIndex]: "" }));
} catch (err) {
if (!cancelled) {
toast.error(err instanceof Error ? err.message : "加载文件内容失败");
}
} finally { } 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(); // eslint-disable-next-line react-hooks/exhaustive-deps
return () => { }, [markdownFiles, run.id]);
cancelled = true;
};
}, [runId, files]);
// 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(() => { useEffect(() => {
if (activeIndex == null && files.length > 0) { setIsEditing(false);
setActiveIndex(files[0]!.index); setDraft(activeSource);
} }, [activeTabMeta?.value, activeSource]);
if (activeIndex != null && !files.some((f) => f.index === activeIndex)) {
setActiveIndex(files[0]?.index ?? null);
}
}, [activeIndex, files]);
if (files.length === 0) { const saveMutation = useMutation({
return null; 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 = const handleDownloadWord = useCallback(async () => {
activeIndex != null ? files.find((f) => f.index === activeIndex) ?? null : null; if (!activeTabMeta) return;
const activeContent = activeFile ? contents[activeFile.index] : null; 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 ( return (
<div className="flex flex-col gap-3"> <>
{files.length > 1 && ( <CardHeader className="flex flex-wrap items-start justify-between gap-2 space-y-0">
<div className="flex flex-wrap gap-2 border-b pb-2"> <div className="min-w-0 flex-1">
{files.map((file) => { <Tooltip>
const active = file.index === activeIndex; <TooltipTrigger asChild>
return ( <CardTitle className="line-clamp-2 text-base leading-snug break-all">
<button {run.result?.title || "执行记录"}
key={file.index} </CardTitle>
onClick={() => setActiveIndex(file.index)} </TooltipTrigger>
className={cn( {run.result?.title && (
"rounded-md border px-3 py-1 text-xs transition-colors", <TooltipContent side="bottom" className="max-w-[420px] break-all">
active {run.result.title}
? "border-primary bg-primary/10 text-primary" </TooltipContent>
: "bg-card hover:bg-accent/60", )}
)} </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" /> <X className="size-4" />
{file.name} 取消
</button> </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> </div>
)} </CardHeader>
{activeFile && (
<div className="flex items-center justify-between gap-2"> <CardContent className="min-h-0 flex-1 overflow-hidden p-0">
<div className="text-muted-foreground text-xs"> {tabs.length === 0 ? (
{activeFile.name} <div className="text-muted-foreground p-6 text-sm">无可用内容。</div>
{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"> <Tabs
无法预览此 Markdown 文件,请下载查看。 value={activeTab}
</div> onValueChange={setActiveTab}
) className="flex h-full min-h-0 flex-col gap-0 px-4 pb-3"
) : !loading ? ( >
<div className="text-muted-foreground text-sm">未加载到内容。</div> <TabsList
) : null} variant="line"
</div> 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>
</>
); );
} }
+26 -8
View File
@@ -78,6 +78,7 @@ function displayUsername(value?: string | null): string {
const emptyForm = { const emptyForm = {
name: "", name: "",
description: "",
schedule: "", schedule: "",
timezone: "Asia/Shanghai", timezone: "Asia/Shanghai",
prompt: "", prompt: "",
@@ -116,9 +117,13 @@ export default function ScheduledTasksPage() {
const sortedTasks = useMemo( const sortedTasks = useMemo(
() => () =>
[...tasks].sort((a, b) => [...tasks].sort((a, b) => {
String(a.next_run_at ?? "").localeCompare(String(b.next_run_at ?? "")), // 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], [tasks],
); );
const selectedTask = sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null; const selectedTask = sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null;
@@ -162,6 +167,7 @@ export default function ScheduledTasksPage() {
setEditing(task); setEditing(task);
setForm({ setForm({
name: task.name, name: task.name,
description: task.description ?? "",
schedule: task.schedule_text, schedule: task.schedule_text,
timezone: task.timezone, timezone: task.timezone,
prompt: task.prompt, prompt: task.prompt,
@@ -190,6 +196,7 @@ export default function ScheduledTasksPage() {
if (creating) { if (creating) {
const task = await actions.create.mutateAsync({ const task = await actions.create.mutateAsync({
name: form.name || "定时任务", name: form.name || "定时任务",
description: form.description.trim() || null,
schedule: cronSchedule, schedule: cronSchedule,
timezone: form.timezone || "Asia/Shanghai", timezone: form.timezone || "Asia/Shanghai",
prompt: form.prompt, prompt: form.prompt,
@@ -202,6 +209,7 @@ export default function ScheduledTasksPage() {
taskId: editing.task_id, taskId: editing.task_id,
data: { data: {
name: form.name, name: form.name,
description: form.description.trim() || null,
schedule: cronSchedule, schedule: cronSchedule,
timezone: form.timezone, timezone: form.timezone,
prompt: form.prompt, prompt: form.prompt,
@@ -324,9 +332,11 @@ export default function ScheduledTasksPage() {
onClick={() => setSelectedTaskId(task.task_id)} onClick={() => setSelectedTaskId(task.task_id)}
> >
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="font-medium leading-snug break-all">
<div className="truncate font-medium">{task.name}</div> {task.name}
</div>
<div className="mt-1 flex flex-wrap items-center gap-1">
{isRunningNow && ( {isRunningNow && (
<Badge <Badge
variant="secondary" variant="secondary"
@@ -351,7 +361,7 @@ export default function ScheduledTasksPage() {
{task.schedule_text} · {task.timezone} {task.schedule_text} · {task.timezone}
</div> </div>
</div> </div>
<Badge variant={task.enabled ? "default" : "secondary"}> <Badge variant={task.enabled ? "default" : "secondary"} className="shrink-0">
{task.enabled ? "启用" : "暂停"} {task.enabled ? "启用" : "暂停"}
</Badge> </Badge>
</div> </div>
@@ -760,6 +770,14 @@ export default function ScheduledTasksPage() {
} }
placeholder="任务名称" 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"> <div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">执行频率</p> <p className="text-muted-foreground text-xs">执行频率</p>
<ScheduleBuilder value={scheduleConfig} onChange={setScheduleConfig} /> <ScheduleBuilder value={scheduleConfig} onChange={setScheduleConfig} />
@@ -786,7 +804,7 @@ export default function ScheduledTasksPage() {
<SelectContent> <SelectContent>
<SelectItem value="__default__">默认助手(不指定)</SelectItem> <SelectItem value="__default__">默认助手(不指定)</SelectItem>
{agents.map((agent) => ( {agents.map((agent) => (
<SelectItem key={agent.name} value={agent.name}> <SelectItem key={agent.id} value={agent.id}>
{agent.name} {agent.name}
{agent.description ? ` — ${agent.description}` : ""} {agent.description ? ` — ${agent.description}` : ""}
</SelectItem> </SelectItem>
@@ -1,7 +1,9 @@
import asyncio import asyncio
import logging import logging
import os import os
import queue
import sys import sys
import threading
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -245,7 +247,6 @@ async def _migrate_orphaned_threads(store, admin_user_id: str) -> int:
async def _run_db_migrations() -> None: async def _run_db_migrations() -> None:
"""Run Alembic migrations at startup so schema changes are applied automatically.""" """Run Alembic migrations at startup so schema changes are applied automatically."""
import asyncio
from pathlib import Path from pathlib import Path
from alembic import command from alembic import command
@@ -259,17 +260,50 @@ async def _run_db_migrations() -> None:
logger.info("Persistence backend=memory — skipping Alembic migrations") logger.info("Persistence backend=memory — skipping Alembic migrations")
return return
if os.getenv("DEER_FLOW_SKIP_ALEMBIC", "").lower() in {"1", "true", "yes"}:
logger.warning("DEER_FLOW_SKIP_ALEMBIC is set; skipping Alembic migrations")
return
db_url = engine.url.render_as_string(hide_password=False).replace("%", "%%") db_url = engine.url.render_as_string(hide_password=False).replace("%", "%%")
migrations_dir = str(Path(_persistence_pkg.__file__).parent / "migrations") migrations_dir = str(Path(_persistence_pkg.__file__).parent / "migrations")
result_queue: queue.Queue[BaseException | None] = queue.Queue(maxsize=1)
def _run_sync() -> None: def _run_sync() -> None:
cfg = Config() try:
cfg.set_main_option("script_location", migrations_dir) cfg = Config()
cfg.set_main_option("sqlalchemy.url", db_url) cfg.set_main_option("script_location", migrations_dir)
command.upgrade(cfg, "head") cfg.set_main_option("sqlalchemy.url", db_url)
command.upgrade(cfg, "head")
except BaseException as exc:
result_queue.put(exc)
else:
result_queue.put(None)
try: try:
await asyncio.get_running_loop().run_in_executor(None, _run_sync) try:
timeout_s = float(os.getenv("DEER_FLOW_ALEMBIC_TIMEOUT", "60"))
except ValueError:
timeout_s = 60.0
worker = threading.Thread(target=_run_sync, name="deerflow-alembic-upgrade", daemon=True)
worker.start()
try:
deadline = asyncio.get_running_loop().time() + timeout_s
while result_queue.empty():
if asyncio.get_running_loop().time() >= deadline:
raise asyncio.TimeoutError
await asyncio.sleep(0.2)
result = result_queue.get_nowait()
except asyncio.TimeoutError:
logger.error(
"Database migration did not finish within %.0fs; continuing startup on create_all schema. "
"Likely cause: MySQL metadata-lock wait or a slow DDL. Set DEER_FLOW_SKIP_ALEMBIC=1 "
"to skip startup migrations, or inspect MySQL with SHOW FULL PROCESSLIST.",
timeout_s,
)
return
if result is not None:
raise result
logger.info("Database migrations applied (head)") logger.info("Database migrations applied (head)")
except Exception: except Exception:
logger.exception("Database migration failed — schema may be out of date") logger.exception("Database migration failed — schema may be out of date")
@@ -37,9 +37,17 @@ def should_check_csrf(request: Request) -> bool:
if request.method not in ("POST", "PUT", "DELETE", "PATCH"): if request.method not in ("POST", "PUT", "DELETE", "PATCH"):
return False return False
path = request.url.path.rstrip("/") path = request.url.path
stripped = path.rstrip("/")
# Exempt /api/v1/auth/me endpoint # Exempt /api/v1/auth/me endpoint
if path == "/api/v1/auth/me": if stripped == "/api/v1/auth/me":
return False
# Anonymous public endpoints (mirror auth_middleware._PUBLIC_PATH_PREFIXES).
# These intentionally have no cookie session, so the Double-Submit Cookie
# pattern is both impossible (no token to compare) and unnecessary (no
# session for an attacker to ride). Without this exemption every public
# PUT/POST returns 403 even though auth was bypassed earlier in the chain.
if path.startswith("/api/public/"):
return False return False
return True return True
@@ -1,4 +1,4 @@
"""Public (unauthenticated) read-only API for scheduled tasks. """Public (unauthenticated) API for scheduled tasks.
Mounted under the ``/api/public/`` prefix so the auth middleware lets these Mounted under the ``/api/public/`` prefix so the auth middleware lets these
routes through without a session cookie. Intended for embedding scheduled routes through without a session cookie. Intended for embedding scheduled
@@ -12,9 +12,15 @@ Surface area:
- ``GET /api/public/scheduled-tasks/{task_id}/runs`` — runs for a task (latest first) - ``GET /api/public/scheduled-tasks/{task_id}/runs`` — runs for a task (latest first)
- ``GET /api/public/scheduled-tasks/runs/{run_id}`` — single run (result included) - ``GET /api/public/scheduled-tasks/runs/{run_id}`` — single run (result included)
- ``GET /api/public/scheduled-tasks/runs/{run_id}/files/{i}``— inline file content / preview - ``GET /api/public/scheduled-tasks/runs/{run_id}/files/{i}``— inline file content / preview
- ``PUT /api/public/scheduled-tasks/runs/{run_id}/files/{i}``— overwrite an editable result file
- ``PUT /api/public/scheduled-tasks/runs/{run_id}/body`` — overwrite the inline Markdown body
- ``GET /api/public/scheduled-tasks/runs/{run_id}/download`` — zip / md download of result - ``GET /api/public/scheduled-tasks/runs/{run_id}/download`` — zip / md download of result
- ``DELETE /api/public/scheduled-tasks/runs/{run_id}`` — remove a single run (no auth)
These are read-only. No write/mutation routes belong here. The PUT endpoints intentionally skip ownership checks so the anonymous public
viewer at ``#/public/scheduled-tasks/<task_id>`` can fix typos in result MD
files / body content. Tighten this if the deployment requires read-only
sharing.
""" """
from __future__ import annotations from __future__ import annotations
@@ -30,6 +36,7 @@ from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import FileResponse, Response, StreamingResponse from fastapi.responses import FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field
from deerflow.runtime.scheduler import get_scheduled_task_service from deerflow.runtime.scheduler import get_scheduled_task_service
@@ -39,6 +46,17 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/public/scheduled-tasks", tags=["public-scheduled-tasks"]) router = APIRouter(prefix="/api/public/scheduled-tasks", tags=["public-scheduled-tasks"])
_MAX_PUBLIC_EDIT_BYTES = 5 * 1024 * 1024 # 5 MB — mirrors the authenticated edit cap
class PublicRunFileUpdateRequest(BaseModel):
content: str = Field(..., description="New text content to write into the run file")
class PublicRunBodyUpdateRequest(BaseModel):
content: str = Field(..., description="New Markdown body for the run's result")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Sanitisation helpers # Sanitisation helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -93,6 +111,15 @@ def _redact_run(run: dict[str, Any]) -> dict[str, Any]:
# to fetch content via the public file endpoint. # to fetch content via the public file endpoint.
public_file = {k: v for k, v in item.items() if k != "path"} public_file = {k: v for k, v in item.items() if k != "path"}
public_file["index"] = idx public_file["index"] = idx
# Mark whether the underlying archive is still on disk. The
# public viewer filters out tabs whose source is gone so users
# never see the raw "Result file is no longer available" 404.
path_value = item.get("path")
public_file["available"] = (
isinstance(path_value, str)
and bool(path_value)
and Path(path_value).is_file()
)
cleaned_files.append(public_file) cleaned_files.append(public_file)
result = {**result, "files": cleaned_files} result = {**result, "files": cleaned_files}
cleaned["result"] = result cleaned["result"] = result
@@ -302,3 +329,57 @@ async def public_download_run(run_id: str):
media_type="application/zip", media_type="application/zip",
headers={"Content-Disposition": _content_disposition(f"{base_name}.zip")}, headers={"Content-Disposition": _content_disposition(f"{base_name}.zip")},
) )
@router.put("/runs/{run_id}/files/{file_index}")
async def public_update_run_file(
run_id: str,
file_index: int,
body: PublicRunFileUpdateRequest,
) -> dict[str, Any]:
"""Overwrite an MD/text result file on disk and refresh its size in the run row.
Anonymous — no ownership check. See module docstring for the rationale.
"""
if len(body.content.encode("utf-8")) > _MAX_PUBLIC_EDIT_BYTES:
raise HTTPException(status_code=413, detail="Edited content exceeds the 5 MB limit")
service = get_scheduled_task_service()
try:
return await service.admin_update_run_file(run_id, file_index, body.content)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
except PermissionError as exc:
# Surfaces archive-path escape attempts. Treat as 400 since the caller is
# already trusted to be reaching a real run row.
raise HTTPException(status_code=400, detail=str(exc))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
@router.delete("/runs/{run_id}", status_code=204)
async def public_delete_run(run_id: str) -> Response:
"""Remove a single run regardless of owner.
Anonymous — no ownership check, mirroring the existing public PUT endpoints.
Tighten this if the deployment requires read-only public sharing.
"""
service = get_scheduled_task_service()
deleted = await service.admin_delete_run(run_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
return Response(status_code=204)
@router.put("/runs/{run_id}/body")
async def public_update_run_body(run_id: str, body: PublicRunBodyUpdateRequest) -> dict[str, Any]:
"""Overwrite the inline Markdown body (``result.content``) of a run.
Anonymous — no ownership check. Used by the public viewer's "正文" tab.
"""
if len(body.content.encode("utf-8")) > _MAX_PUBLIC_EDIT_BYTES:
raise HTTPException(status_code=413, detail="Edited content exceeds the 5 MB limit")
service = get_scheduled_task_service()
try:
return await service.admin_update_run_body(run_id, body.content)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc))
@@ -31,6 +31,7 @@ router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
class ScheduledTaskCreateRequest(BaseModel): class ScheduledTaskCreateRequest(BaseModel):
name: str = Field(default="Scheduled task") name: str = Field(default="Scheduled task")
description: str | None = None
prompt: str prompt: str
schedule: str schedule: str
timezone: str | None = None timezone: str | None = None
@@ -40,6 +41,7 @@ class ScheduledTaskCreateRequest(BaseModel):
class ScheduledTaskUpdateRequest(BaseModel): class ScheduledTaskUpdateRequest(BaseModel):
name: str | None = None name: str | None = None
description: str | None = None
prompt: str | None = None prompt: str | None = None
schedule: str | None = None schedule: str | None = None
timezone: str | None = None timezone: str | None = None
@@ -547,6 +549,7 @@ async def create_scheduled_task(body: ScheduledTaskCreateRequest, request: Reque
task = await service.create_task( task = await service.create_task(
user_id=user_id, user_id=user_id,
name=body.name, name=body.name,
description=body.description,
prompt=body.prompt, prompt=body.prompt,
schedule=body.schedule, schedule=body.schedule,
timezone=body.timezone, timezone=body.timezone,
+4 -5
View File
@@ -153,12 +153,11 @@ memory:
user_char_limit: 1375 user_char_limit: 1375
deduplicate_on_load: true deduplicate_on_load: true
hindsight: # provider=hindsight 时生效,需另行部署 Hindsight 服务 hindsight: # provider=hindsight 时生效,需另行部署 Hindsight 服务
mode: local_external # 'cloud' | 'local_embedded' | 'local_external' mode: local_external # 'cloud' | 'local_embedded' | 'local_external'
api_url: http://47.88.25.99:18888 api_url: http://47.88.25.99:18888
api_key: api_key: "" # 部署 Hindsight 后填,或改成 $HINDSIGHT_API_KEY 并设同名环境变量
"" # 部署 Hindsight 后填,或改成 $HINDSIGHT_API_KEY 并设同名环境变量 # 注意:写成 $VAR 但环境变量未设置会导致配置加载失败
# 注意:写成 $VAR 但环境变量未设置会导致配置加载失败
bank_id_template: "deerflow-user-{user_id}" bank_id_template: "deerflow-user-{user_id}"
work_bank_id_template: "deerflow-work-{user_id}-{agent_id}" work_bank_id_template: "deerflow-work-{user_id}-{agent_id}"
memory_mode: context # 'context' | 'tools' | 'hybrid' memory_mode: context # 'context' | 'tools' | 'hybrid'
@@ -13,10 +13,13 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import os
import sys import sys
from importlib.util import find_spec from importlib.util import find_spec
from pathlib import Path from pathlib import Path
from sqlalchemy import text
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
@@ -77,6 +80,10 @@ async def _run_alembic_upgrade(url: str) -> None:
operable on the ``create_all`` schema; the operator just won't get operable on the ``create_all`` schema; the operator just won't get
in-place fixes until they investigate. in-place fixes until they investigate.
""" """
if os.getenv("DEER_FLOW_SKIP_ALEMBIC", "").lower() in ("1", "true", "yes"):
logger.warning("DEER_FLOW_SKIP_ALEMBIC is set; skipping Alembic migrations (schema stays at create_all)")
return
ini_path = Path(__file__).parent / "migrations" / "alembic.ini" ini_path = Path(__file__).parent / "migrations" / "alembic.ini"
if not ini_path.exists(): if not ini_path.exists():
logger.warning("Alembic config not found at %s; skipping auto-migrate", ini_path) logger.warning("Alembic config not found at %s; skipping auto-migrate", ini_path)
@@ -102,9 +109,26 @@ async def _run_alembic_upgrade(url: str) -> None:
# at the directory of alembic.ini itself — already correct for us. # at the directory of alembic.ini itself — already correct for us.
command.upgrade(cfg, "head") command.upgrade(cfg, "head")
# Bound the upgrade so a stuck DDL (e.g. MySQL metadata-lock wait) cannot
# hang startup forever. Tunable via DEER_FLOW_ALEMBIC_TIMEOUT (seconds).
# NOTE: asyncio.wait_for cancels the *await* on timeout; the worker thread
# itself keeps running (Python can't kill threads). That's acceptable —
# the orphan thread will exit when its MySQL connection eventually errors
# or when the process restarts. The point here is to unblock startup.
try: try:
await asyncio.to_thread(_do_upgrade) timeout_s = float(os.getenv("DEER_FLOW_ALEMBIC_TIMEOUT", "60"))
except ValueError:
timeout_s = 60.0
try:
await asyncio.wait_for(asyncio.to_thread(_do_upgrade), timeout=timeout_s)
logger.info("Alembic migrations applied (or already up-to-date)") logger.info("Alembic migrations applied (or already up-to-date)")
except asyncio.TimeoutError:
logger.error(
"Alembic upgrade did not finish within %.0fs; continuing startup on create_all schema. "
"Likely cause: MySQL metadata-lock wait — check SHOW FULL PROCESSLIST for a blocking transaction.",
timeout_s,
)
except Exception: except Exception:
logger.exception("Auto-applying Alembic migrations failed; continuing with create_all schema") logger.exception("Auto-applying Alembic migrations failed; continuing with create_all schema")
@@ -136,6 +160,45 @@ async def _auto_create_postgres_db(url: str) -> None:
await maint_engine.dispose() await maint_engine.dispose()
async def _auto_create_mysql_db(url: str) -> None:
"""Create the target MySQL database when it is missing.
``create_all`` can create tables, but MySQL requires the database/schema to
exist before the application URL can connect. We connect to the built-in
``mysql`` maintenance database on the same server and issue a conservative
``CREATE DATABASE IF NOT EXISTS``.
"""
parsed = make_url(url)
db_name = parsed.database
if not db_name:
raise ValueError("Cannot auto-create MySQL database: no database name in URL")
maint_url = parsed.set(database="mysql")
maint_engine = create_async_engine(
maint_url.render_as_string(hide_password=False),
isolation_level="AUTOCOMMIT",
pool_pre_ping=True,
)
quoted = "`" + db_name.replace("`", "``") + "`"
try:
async with maint_engine.connect() as conn:
await conn.execute(
text(
f"CREATE DATABASE IF NOT EXISTS {quoted} "
"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
)
)
logger.info("Auto-created MySQL database if missing: %s", db_name)
finally:
await maint_engine.dispose()
def _is_mysql_unknown_database_error(exc: Exception) -> bool:
message = str(exc).lower()
return "unknown database" in message or "1049" in message
async def init_engine( async def init_engine(
backend: str, backend: str,
*, *,
@@ -252,12 +315,28 @@ async def init_engine(
_session_factory = async_sessionmaker(_engine, expire_on_commit=False) _session_factory = async_sessionmaker(_engine, expire_on_commit=False)
async with _engine.begin() as conn: async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all) await conn.run_sync(Base.metadata.create_all)
elif backend == "mysql" and _is_mysql_unknown_database_error(exc):
# Database not yet created. Create it, rebuild the app engine
# against the target DB, then let SQLAlchemy create all tables.
await _auto_create_mysql_db(url)
await _engine.dispose()
_engine = create_async_engine(
url,
echo=echo,
pool_size=pool_size,
pool_pre_ping=True,
pool_recycle=1800,
json_serializer=_json_serializer,
)
_session_factory = async_sessionmaker(_engine, expire_on_commit=False)
async with _engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
else: else:
raise raise
# Apply in-place schema fixes (column widenings, etc.) that ``create_all`` # Apply in-place schema fixes (column widenings, etc.) that ``create_all``
# cannot do on its own. Failures don't abort startup — see the helper. # cannot do on its own. Failures don't abort startup — see the helper.
await _run_alembic_upgrade(url) # await _run_alembic_upgrade(url)
logger.info("Persistence engine initialized: backend=%s", backend) logger.info("Persistence engine initialized: backend=%s", backend)
@@ -9,9 +9,11 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import os
from logging.config import fileConfig from logging.config import fileConfig
from alembic import context from alembic import context
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from deerflow.persistence.base import Base from deerflow.persistence.base import Base
@@ -46,6 +48,20 @@ def run_migrations_offline() -> None:
def do_run_migrations(connection): def do_run_migrations(connection):
render_as_batch = connection.dialect.name == "sqlite" render_as_batch = connection.dialect.name == "sqlite"
# On MySQL, shrink the session lock waits so a blocked DDL fails fast
# instead of hanging on the server default (lock_wait_timeout = 31536000s
# = 1 year; innodb_lock_wait_timeout = 50s). Without this, an idle
# transaction on another connection can freeze startup indefinitely.
# Tunable via DEER_FLOW_ALEMBIC_LOCK_WAIT (seconds, default 15).
if connection.dialect.name == "mysql":
try:
lock_wait = int(os.getenv("DEER_FLOW_ALEMBIC_LOCK_WAIT", "15"))
except ValueError:
lock_wait = 15
connection.execute(text(f"SET SESSION lock_wait_timeout = {lock_wait}"))
connection.execute(text(f"SET SESSION innodb_lock_wait_timeout = {lock_wait}"))
context.configure( context.configure(
connection=connection, connection=connection,
target_metadata=target_metadata, target_metadata=target_metadata,
@@ -0,0 +1,24 @@
"""Merge agent pin/featured and user memory config migration heads.
Revision ID: 20260521_02
Revises: 20260520_03, 20260521_01
Create Date: 2026-05-21
"""
from __future__ import annotations
from collections.abc import Sequence
revision: str = "20260521_02"
down_revision: str | tuple[str, str] = ("20260520_03", "20260521_01")
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -0,0 +1,36 @@
"""Add description column to scheduled_tasks.
Revision ID: 20260522_01
Revises: 20260521_02
Create Date: 2026-05-22
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260522_01"
down_revision = "20260521_02"
branch_labels = None
depends_on = None
def _has_column(table_name: str, column_name: str) -> bool:
inspector = inspect(op.get_bind())
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
def upgrade() -> None:
if not _has_column("scheduled_tasks", "description"):
op.add_column(
"scheduled_tasks",
sa.Column("description", sa.Text(), nullable=True),
)
def downgrade() -> None:
if _has_column("scheduled_tasks", "description"):
op.drop_column("scheduled_tasks", "description")
@@ -31,6 +31,7 @@ class ScheduledTaskRow(Base):
source_agent_name: Mapped[str | None] = mapped_column(String(128), index=True) source_agent_name: Mapped[str | None] = mapped_column(String(128), index=True)
execution_agent_name: Mapped[str | None] = mapped_column(String(128), index=True) execution_agent_name: Mapped[str | None] = mapped_column(String(128), index=True)
name: Mapped[str] = mapped_column(String(127)) name: Mapped[str] = mapped_column(String(127))
description: Mapped[str | None] = mapped_column(Text)
prompt: Mapped[str] = mapped_column(Text) prompt: Mapped[str] = mapped_column(Text)
schedule_text: Mapped[str] = mapped_column(String(256)) schedule_text: Mapped[str] = mapped_column(String(256))
cron_expr: Mapped[str] = mapped_column(String(128)) cron_expr: Mapped[str] = mapped_column(String(128))
@@ -76,6 +76,7 @@ class ScheduledTaskRepository(ScheduledTaskStore):
source_agent_name=data.get("source_agent_name"), source_agent_name=data.get("source_agent_name"),
execution_agent_name=data.get("execution_agent_name"), execution_agent_name=data.get("execution_agent_name"),
name=data["name"], name=data["name"],
description=data.get("description"),
prompt=data["prompt"], prompt=data["prompt"],
schedule_text=data["schedule_text"], schedule_text=data["schedule_text"],
cron_expr=data["cron_expr"], cron_expr=data["cron_expr"],
@@ -18,6 +18,7 @@ from zoneinfo import ZoneInfo
from croniter import croniter from croniter import croniter
from langgraph.checkpoint.base import empty_checkpoint from langgraph.checkpoint.base import empty_checkpoint
from deerflow.config.agents_config import AGENT_ID_PATTERN
from deerflow.config.paths import get_paths from deerflow.config.paths import get_paths
from deerflow.persistence.scheduled_tasks import ScheduledTaskStore from deerflow.persistence.scheduled_tasks import ScheduledTaskStore
from deerflow.runtime import serialize_channel_values from deerflow.runtime import serialize_channel_values
@@ -305,12 +306,51 @@ class ScheduledTaskService:
""" """
run = await self.store.get_run(run_id, user_id) run = await self.store.get_run(run_id, user_id)
if run is None: if run is None:
# Either the run doesn't exist, or the caller is a subscriber, not owner.
candidate = await self.store.get_run_any(run_id) candidate = await self.store.get_run_any(run_id)
if candidate is None: if candidate is None:
raise FileNotFoundError(f"Run {run_id} not found") raise FileNotFoundError(f"Run {run_id} not found")
raise PermissionError("Only the run owner may edit result files") raise PermissionError("Only the run owner may edit result files")
return await self._apply_run_file_edit(run, run_id, file_index, new_content)
async def admin_update_run_file(
self,
run_id: str,
file_index: int,
new_content: str,
) -> dict[str, Any]:
"""Owner-less variant of :meth:`update_run_file` used by the public viewer.
The public scheduled-task page is unauthenticated, so it cannot supply a
``user_id``. Mutations from that page route here instead. The disk-write
and result-row update logic is identical to the owner path.
"""
run = await self.store.get_run_any(run_id)
if run is None:
raise FileNotFoundError(f"Run {run_id} not found")
return await self._apply_run_file_edit(run, run_id, file_index, new_content)
async def admin_update_run_body(self, run_id: str, new_content: str) -> dict[str, Any]:
"""Overwrite the inline Markdown body (``result.content``) of a run.
Used by the public scheduled-task viewer so anonymous editors can fix
typos in the "正文" tab. Unlike :meth:`admin_update_run_file`, no disk
artifact backs the body — it lives entirely in the run row.
"""
run = await self.store.get_run_any(run_id)
if run is None:
raise FileNotFoundError(f"Run {run_id} not found")
result = run.get("result") if isinstance(run.get("result"), dict) else {}
new_result = {**result, "content": new_content}
await self.store.update_run(run_id, {"result": new_result})
return {"content": new_content}
async def _apply_run_file_edit(
self,
run: dict[str, Any],
run_id: str,
file_index: int,
new_content: str,
) -> dict[str, Any]:
result = run.get("result") or {} result = run.get("result") or {}
files = result.get("files") if isinstance(result, dict) else None files = result.get("files") if isinstance(result, dict) else None
if not isinstance(files, list) or file_index < 0 or file_index >= len(files): if not isinstance(files, list) or file_index < 0 or file_index >= len(files):
@@ -406,6 +446,7 @@ class ScheduledTaskService:
prompt: str, prompt: str,
schedule: str, schedule: str,
timezone: str | None = None, timezone: str | None = None,
description: str | None = None,
source_thread_id: str | None = None, source_thread_id: str | None = None,
source_agent_name: str | None = None, source_agent_name: str | None = None,
execution_agent_name: str | None = None, execution_agent_name: str | None = None,
@@ -420,6 +461,7 @@ class ScheduledTaskService:
context = dict(execution_context or {}) context = dict(execution_context or {})
if execution_agent_name: if execution_agent_name:
context["agent_name"] = execution_agent_name context["agent_name"] = execution_agent_name
normalized_description = description.strip() if isinstance(description, str) else None
task = await self.store.create_task( task = await self.store.create_task(
{ {
"task_id": str(uuid.uuid4()), "task_id": str(uuid.uuid4()),
@@ -429,6 +471,7 @@ class ScheduledTaskService:
"source_agent_name": source_agent_name, "source_agent_name": source_agent_name,
"execution_agent_name": execution_agent_name, "execution_agent_name": execution_agent_name,
"name": final_name, "name": final_name,
"description": normalized_description or None,
"prompt": prompt.strip(), "prompt": prompt.strip(),
"schedule_text": schedule.strip(), "schedule_text": schedule.strip(),
"cron_expr": cron_expr, "cron_expr": cron_expr,
@@ -451,6 +494,15 @@ class ScheduledTaskService:
for key in ("name", "prompt", "enabled", "execution_agent_name"): for key in ("name", "prompt", "enabled", "execution_agent_name"):
if key in data and data[key] is not None: if key in data and data[key] is not None:
update_data[key] = data[key] update_data[key] = data[key]
# Description is nullable — accept ``None`` (clear) and empty strings
# (also clear) explicitly, so the field can be removed via PATCH.
if "description" in data:
raw = data["description"]
if isinstance(raw, str):
trimmed = raw.strip()
update_data["description"] = trimmed or None
else:
update_data["description"] = None
# Names are matched against the per-user uniqueness constraint, so keep # Names are matched against the per-user uniqueness constraint, so keep
# the stored value trimmed to match create_task's normalization. # the stored value trimmed to match create_task's normalization.
if isinstance(update_data.get("name"), str): if isinstance(update_data.get("name"), str):
@@ -819,8 +871,17 @@ class ScheduledTaskService:
try: try:
request = SimpleNamespace(app=self.app) request = SimpleNamespace(app=self.app)
execution_context = dict(task.get("execution_context") or {}) execution_context = dict(task.get("execution_context") or {})
if task.get("execution_agent_name"): stored_agent_ref = task.get("execution_agent_name")
execution_context["agent_name"] = task["execution_agent_name"] if stored_agent_ref:
resolved_agent_id = await self._resolve_agent_id(stored_agent_ref, task["user_id"])
if resolved_agent_id:
execution_context["agent_name"] = resolved_agent_id
else:
logger.warning(
"Scheduled task %s references unknown agent %r; falling back to default lead agent",
task.get("task_id"),
stored_agent_ref,
)
execution_context.update( execution_context.update(
{ {
"is_scheduled_run": True, "is_scheduled_run": True,
@@ -845,6 +906,35 @@ class ScheduledTaskService:
finally: finally:
reset_current_user(token) reset_current_user(token)
async def _resolve_agent_id(self, stored_ref: str, user_id: str) -> str | None:
"""Resolve a scheduled task's ``execution_agent_name`` to a stable agent id.
Older tasks (created before the frontend was fixed) stored the agent's
Chinese **display name** here; the runtime validator only accepts ids
matching ``^[A-Za-z0-9_-]+$``, so those rows would 422 forever. We
gracefully recover by looking the stored value up first as an id, then
as a display name. Returns ``None`` when nothing visible matches — the
caller then falls back to the default lead agent rather than crashing.
"""
store = getattr(self.app.state, "agent_store", None)
if store is None:
return stored_ref if AGENT_ID_PATTERN.fullmatch(stored_ref) else None
if AGENT_ID_PATTERN.fullmatch(stored_ref):
try:
row = await store.get_visible(stored_ref, user_id)
except Exception:
row = None
if row is not None:
return row["id"]
try:
agents = await store.list_visible(user_id)
except Exception:
return None
for agent in agents:
if agent.get("name") == stored_ref:
return agent.get("id")
return None
async def _extract_latest_ai_text(self, thread_id: str) -> str | None: async def _extract_latest_ai_text(self, thread_id: str) -> str | None:
checkpointer = self.app.state.checkpointer checkpointer = self.app.state.checkpointer
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}} config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
+1 -1
View File
@@ -148,7 +148,7 @@ memory:
hindsight: # provider=hindsight 时生效,需另行部署 Hindsight 服务 hindsight: # provider=hindsight 时生效,需另行部署 Hindsight 服务
mode: local_external # 'cloud' | 'local_embedded' | 'local_external' mode: local_external # 'cloud' | 'local_embedded' | 'local_external'
api_url: http://localhost:8765 api_url: http://47.88.25.99:18888
api_key: "" # 部署 Hindsight 后填,或改成 $HINDSIGHT_API_KEY 并设同名环境变量 api_key: "" # 部署 Hindsight 后填,或改成 $HINDSIGHT_API_KEY 并设同名环境变量
# 注意:写成 $VAR 但环境变量未设置会导致配置加载失败 # 注意:写成 $VAR 但环境变量未设置会导致配置加载失败
bank_id_template: "deerflow-user-{user_id}" bank_id_template: "deerflow-user-{user_id}"