This commit is contained in:
董宏凯 committed 2026-05-18 22:45:25 +08:00
commit 1e5e2581c8
86 files changed
+5887 -671

No files matched your search

-2
View File
@@ -47,11 +47,9 @@ offline-backend-20260505/bundle/start_backend.sh
offline-backend-20260505/bundle/stop_backend.sh
# offline-backend-20260512 runtime artifacts (large files, local config, data)
offline-backend-20260512/.env
offline-backend-20260512/.env.example
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
offline-backend-20260512/SHA256SUMS
offline-backend-20260512/config.yaml
offline-backend-20260512/config.yaml.example
offline-backend-20260512/data/
offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
+3
View File
@@ -13,6 +13,7 @@ import { HashRouter, useLocation } from "react-router-dom";
import LandingPage from "./pages/LandingPage";
import LoginPage from "./pages/LoginPage";
import PageRoutes from "./pages/PageRoutes";
import PublicSharePage from "./pages/PublicSharePage";
import { NotFoundPage } from "./pages/NotFoundPage";
function AppProviders({ children }: { children: React.ReactNode }) {
@@ -47,6 +48,8 @@ export function App() {
<Route path="/login" element={<LoginPage />} />
<Route path="/login/:username" element={<LoginPage />} />
<Route path="/landing" element={<LandingPage />} />
{/* Public read-only conversation viewer — no authentication. */}
<Route path="/share/:shareCode" element={<PublicSharePage />} />
<Route path="/page/*" element={<PageRoutes />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
@@ -6,6 +6,13 @@ import type { AgentThreadState } from "@/core/threads";
export interface ThreadContextType {
thread: BaseStream<AgentThreadState>;
isMock?: boolean;
/**
* When set, the conversation is rendered from a public read-only share.
* Artifact/image URLs then resolve to the unauthenticated
* `/api/public/shares/{code}/files/...` endpoint instead of the
* auth-gated thread artifact route.
*/
publicShareCode?: string;
}
export const ThreadContext = createContext<ThreadContextType | undefined>(
@@ -2,6 +2,8 @@ import type { Message } from "@langchain/langgraph-sdk";
import { FileIcon, Loader2Icon } from "lucide-react";
import {
memo,
useCallback,
useContext,
useMemo,
type AnchorHTMLAttributes,
type ImgHTMLAttributes,
@@ -23,6 +25,7 @@ import {
import { Task, TaskTrigger } from "@/components/ai-elements/task";
import { Badge } from "@/components/ui/badge";
import { resolveArtifactURL } from "@/core/artifacts/utils";
import { getBackendBaseURL } from "@/core/config";
import { useI18n } from "@/core/i18n/hooks";
import {
extractContentFromMessage,
@@ -38,9 +41,33 @@ import { cn } from "@/lib/utils";
import { CopyButton } from "../copy-button";
import { ThreadContext } from "./context";
import { MarkdownContent } from "./markdown-content";
import { MessageTokenUsage } from "./message-token-usage";
/**
* Resolve an in-conversation file path to a fetchable URL.
*
* On a public read-only share (`ThreadContext.publicShareCode` set) artifact
* URLs point at the unauthenticated `/api/public/shares/{code}/files/...`
* endpoint; otherwise they use the normal auth-gated thread artifact route.
*/
function useArtifactResolver(threadId: string): (path: string) => string {
const publicShareCode = useContext(ThreadContext)?.publicShareCode;
return useCallback(
(path: string) => {
if (publicShareCode) {
const normalized = path.startsWith("/") ? path : `/${path}`;
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
publicShareCode,
)}/files${normalized}`;
}
return resolveArtifactURL(path, threadId);
},
[publicShareCode, threadId],
);
}
export function MessageListItem({
className,
message,
@@ -102,6 +129,7 @@ function MessageImage({
threadId: string;
maxWidth?: string;
}) {
const resolveArtifact = useArtifactResolver(threadId);
if (!src) return null;
const imgClassName = cn("overflow-hidden rounded-lg", `max-w-[${maxWidth}]`);
@@ -110,7 +138,7 @@ function MessageImage({
return <img className={imgClassName} src={src} alt={alt} {...props} />;
}
const url = src.startsWith("/mnt/") ? resolveArtifactURL(src, threadId) : src;
const url = src.startsWith("/mnt/") ? resolveArtifact(src) : src;
return (
<a href={url} target="_blank" rel="noopener noreferrer">
@@ -134,6 +162,7 @@ function MessageContent_({
}) {
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const isHuman = message.type === "human";
const resolveArtifact = useArtifactResolver(threadId);
const components = useMemo(
() => ({
img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
@@ -141,7 +170,7 @@ function MessageContent_({
),
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
if (href?.startsWith("/mnt/")) {
const url = resolveArtifactURL(href, threadId);
const url = resolveArtifact(href);
return (
<a
{...props}
@@ -154,7 +183,7 @@ function MessageContent_({
return <a {...props} href={href} />;
},
}),
[threadId],
[threadId, resolveArtifact],
);
const rawContent = extractContentFromMessage(message);
@@ -347,6 +376,7 @@ function RichFileCard({
threadId: string;
}) {
const { t } = useI18n();
const resolveArtifact = useArtifactResolver(threadId);
const isUploading = file.status === "uploading";
const isImage = isImageFile(file.filename);
@@ -379,7 +409,7 @@ function RichFileCard({
if (!file.path) return null;
const fileUrl = resolveArtifactURL(file.path, threadId);
const fileUrl = resolveArtifact(file.path);
if (isImage) {
return (
@@ -42,6 +42,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
import { getAPIClient } from "@/core/api";
import { useI18n } from "@/core/i18n/hooks";
import {
@@ -76,6 +77,9 @@ export function RecentChatList() {
const [renameThreadId, setRenameThreadId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
// Share dialog state — the conversation whose share code is being shown.
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
const handleDelete = useCallback(
(threadId: string) => {
deleteThread({ threadId });
@@ -115,18 +119,10 @@ export function RecentChatList() {
}
}, [renameThread, renameThreadId, renameValue]);
const handleShare = useCallback(
async (thread: AgentThread) => {
const shareUrl = `${window.location.origin}${pathOfThread(thread)}`;
try {
await navigator.clipboard.writeText(shareUrl);
toast.success(t.clipboard.linkCopied);
} catch {
toast.error(t.clipboard.failedToCopyToClipboard);
}
},
[t],
);
const handleShare = useCallback((thread: AgentThread) => {
// Opens the share-code dialog; the recipient imports a copy via that code.
setShareThreadId(thread.thread_id);
}, []);
const handleExport = useCallback(
async (thread: AgentThread, format: "markdown" | "json") => {
@@ -173,89 +169,89 @@ export function RecentChatList() {
<div className="flex w-full flex-col gap-1">
{visibleThreads.map((thread) => {
const isActive = pathOfThread(thread) === pathname;
return (
<SidebarMenuItem
key={thread.thread_id}
className="group/side-menu-item"
>
<SidebarMenuButton isActive={isActive} asChild>
<Link
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
>
{titleOfThread(thread)}
</Link>
</SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="bg-background/50 hover:bg-background"
>
<MoreHorizontal />
<span className="sr-only">{t.common.more}</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={"right"}
align={"start"}
>
<DropdownMenuItem
onSelect={() =>
handleRenameClick(
thread.thread_id,
titleOfThread(thread),
)
}
>
<Pencil className="text-muted-foreground" />
<span>{t.common.rename}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => handleShare(thread)}
>
<Share2 className="text-muted-foreground" />
<span>{t.common.share}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Download className="text-muted-foreground" />
<span>{t.common.export}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "markdown")
}
>
<FileText className="text-muted-foreground" />
<span>{t.common.exportAsMarkdown}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "json")
}
>
<FileJson className="text-muted-foreground" />
<span>{t.common.exportAsJSON}</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => handleDelete(thread.thread_id)}
>
<Trash2 className="text-muted-foreground" />
<span>{t.common.delete}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarMenuItem>
);
})}
return (
<SidebarMenuItem
key={thread.thread_id}
className="group/side-menu-item"
>
<SidebarMenuButton isActive={isActive} asChild>
<Link
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
href={pathOfThread(thread)}
>
{titleOfThread(thread)}
</Link>
</SidebarMenuButton>
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="bg-background/50 hover:bg-background"
>
<MoreHorizontal />
<span className="sr-only">{t.common.more}</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-48 rounded-lg"
side={"right"}
align={"start"}
>
<DropdownMenuItem
onSelect={() =>
handleRenameClick(
thread.thread_id,
titleOfThread(thread),
)
}
>
<Pencil className="text-muted-foreground" />
<span>{t.common.rename}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => handleShare(thread)}
>
<Share2 className="text-muted-foreground" />
<span>{t.common.share}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Download className="text-muted-foreground" />
<span>{t.common.export}</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "markdown")
}
>
<FileText className="text-muted-foreground" />
<span>{t.common.exportAsMarkdown}</span>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
handleExport(thread, "json")
}
>
<FileJson className="text-muted-foreground" />
<span>{t.common.exportAsJSON}</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => handleDelete(thread.thread_id)}
>
<Trash2 className="text-muted-foreground" />
<span>{t.common.delete}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</SidebarMenuItem>
);
})}
</div>
</SidebarMenu>
</SidebarGroupContent>
@@ -291,6 +287,15 @@ export function RecentChatList() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Share Dialog */}
<ShareConversationDialog
threadId={shareThreadId}
open={shareThreadId !== null}
onOpenChange={(o) => {
if (!o) setShareThreadId(null);
}}
/>
</>
);
}
@@ -0,0 +1,220 @@
"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// ---- 时间配置 ----
export type ScheduleFreq = "hourly" | "daily" | "weekly" | "monthly";
export interface ScheduleConfig {
freq: ScheduleFreq;
minute: number;
hour: number;
weekday: number;
date: number;
}
export 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);
export 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. Callers should prefer `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 */3 * * *") we sometimes generate.
export 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;
}
export 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>
);
}
export function statusLabel(status?: string | null) {
switch (status) {
case "succeeded":
return "成功";
case "failed":
return "失败";
case "running":
return "执行中";
case "pending":
return "等待中";
default:
return status || "未知";
}
}
export 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";
}
@@ -1,10 +1,24 @@
"use client";
import { Loader2, PencilIcon } from "lucide-react";
import {
ChevronDownIcon,
FileCode2Icon,
FileTextIcon,
GlobeIcon,
Loader2,
PencilIcon,
SparklesIcon,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
@@ -23,15 +37,18 @@ import {
} from "@/core/skills/hooks";
import type { Skill } from "@/core/skills/type";
import { reasoningPlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
/**
* Whether the current user may edit a skill's detail text.
* Whether the current user may edit a skill's detail text and view its raw
* SKILL.md source.
*
* Admins can edit any skill; the publisher (owner of a custom skill) can edit
* Admins qualify for any skill; the publisher (owner of a custom skill) for
* their own. Built-in skills have no human owner, so only admins qualify.
* Mirrors the backend authz in `PUT /api/skills/{name}/detail`.
* Mirrors the backend authz in `PUT /api/skills/{name}/detail` and
* `GET /api/skills/{name}/content`.
*/
function canEditSkillDetail(skill: Skill | null | undefined): boolean {
function canManageSkill(skill: Skill | null | undefined): boolean {
if (!skill) return false;
const auth = getStoredAuth();
if (!auth) return false;
@@ -45,8 +62,9 @@ function canEditSkillDetail(skill: Skill | null | undefined): boolean {
/**
* Reusable skill detail dialog. Given a skill name it fetches the skill
* metadata (including the admin/owner-authored `detail`) and the raw SKILL.md
* source, and lets authorized users edit the detail inline.
* metadata (including the admin/owner-authored `detail`) and lets authorized
* users edit the detail inline. The raw SKILL.md source is shown only to the
* publisher or an admin — everyone else sees just the description and detail.
*/
export function SkillDetailDialog({
skillName,
@@ -62,17 +80,22 @@ export function SkillDetailDialog({
// Only fetch while the dialog is open to avoid background requests.
const activeName = open ? skillName : null;
const { data: skill, isLoading: isSkillLoading } = useSkill(activeName);
const {
data: contentData,
isLoading: isContentLoading,
error: contentError,
} = useSkillContent(activeName);
const setDetail = useSetSkillDetail();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const [showSource, setShowSource] = useState(false);
const canManage = useMemo(() => canManageSkill(skill), [skill]);
// The SKILL.md source endpoint is publisher/admin-only — only request it
// when the viewer is actually allowed to see it.
const {
data: contentData,
isLoading: isContentLoading,
error: contentError,
} = useSkillContent(open && canManage ? skillName : null);
// Reset transient UI whenever the dialog closes or switches to another skill.
useEffect(() => {
if (!open) {
@@ -81,8 +104,6 @@ export function SkillDetailDialog({
}
}, [open, skillName]);
const canEdit = useMemo(() => canEditSkillDetail(skill), [skill]);
function startEditing() {
setDraft(skill?.detail ?? "");
setEditing(true);
@@ -101,22 +122,54 @@ export function SkillDetailDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] flex-col gap-3 sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{skill?.name ?? skillName ?? ""}</DialogTitle>
<DialogDescription>{skill?.description}</DialogDescription>
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl">
{/* Header */}
<DialogHeader className="space-y-0 border-b px-6 py-4 text-left">
<div className="flex items-start gap-3">
<div className="bg-primary/10 text-primary flex size-9 shrink-0 items-center justify-center rounded-lg">
<SparklesIcon className="size-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<DialogTitle className="truncate text-base">
{skill?.name ?? skillName ?? ""}
</DialogTitle>
{skill?.builtin && (
<Badge variant="secondary" className="text-xs">
{tx.badgeBuiltin}
</Badge>
)}
{skill?.published && (
<Badge
variant="outline"
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
>
<GlobeIcon className="size-3" />
{tx.badgePublished}
</Badge>
)}
</div>
<DialogDescription className="mt-1 text-sm leading-relaxed">
{skill?.description || "—"}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-auto pr-1">
{/* Body */}
<div className="min-h-0 flex-1 space-y-5 overflow-auto px-6 py-5">
{/* Detail — human-authored long-form introduction */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">{tx.detailSectionTitle}</h3>
{canEdit && !editing && (
<h3 className="text-foreground/90 flex items-center gap-1.5 text-sm font-semibold">
<FileTextIcon className="text-muted-foreground size-4" />
{tx.detailSectionTitle}
</h3>
{canManage && !editing && (
<Button
size="sm"
variant="ghost"
className="h-7"
className="text-muted-foreground hover:text-foreground h-7 px-2"
onClick={startEditing}
>
<PencilIcon className="size-3.5" />
@@ -124,15 +177,17 @@ export function SkillDetailDialog({
</Button>
)}
</div>
{editing ? (
<div className="space-y-2">
<Textarea
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
placeholder={tx.detailPlaceholder}
className="max-h-[320px] min-h-40 resize-y overflow-y-auto text-sm"
className="max-h-[320px] min-h-44 resize-y overflow-y-auto text-sm leading-relaxed"
autoFocus
/>
<div className="flex justify-end gap-2">
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
variant="outline"
@@ -154,12 +209,12 @@ export function SkillDetailDialog({
</div>
</div>
) : isSkillLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<div className="text-muted-foreground flex items-center gap-2 rounded-lg border border-dashed px-4 py-8 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : skill?.detail ? (
<div className="bg-muted/40 rounded-md border p-3 text-sm">
<div className="bg-muted/30 rounded-lg border p-4 text-sm leading-relaxed">
<MarkdownContent
content={skill.detail}
isLoading={false}
@@ -168,44 +223,55 @@ export function SkillDetailDialog({
/>
</div>
) : (
<p className="text-muted-foreground text-sm">
{tx.detailEmpty}
{!canEdit ? ` ${tx.detailAdminOnlyHint}` : ""}
</p>
)}
</section>
{/* SKILL.md source — collapsed by default */}
<section className="space-y-2">
<button
type="button"
className="text-muted-foreground hover:text-foreground text-sm font-medium"
onClick={() => setShowSource((v) => !v)}
>
{showSource ? "▾ " : "▸ "}
{tx.contentSectionTitle}
</button>
{showSource && (
<div className="bg-muted/40 overflow-auto rounded-md border">
{isContentLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-10 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : contentError ? (
<div className="text-destructive p-4 text-sm">
{contentError instanceof Error
? contentError.message
: String(contentError)}
</div>
) : (
<pre className="text-foreground/90 max-h-[320px] overflow-auto whitespace-pre-wrap wrap-break-word p-4 font-mono text-xs leading-relaxed">
{contentData?.content}
</pre>
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center">
<FileTextIcon className="mx-auto mb-2 size-6 opacity-40" />
<p className="text-sm">{tx.detailEmpty}</p>
{!canManage && (
<p className="mt-1 text-xs">{tx.detailAdminOnlyHint}</p>
)}
</div>
)}
</section>
{/* SKILL.md source — publisher / admin only, collapsed by default */}
{canManage && (
<section>
<Collapsible open={showSource} onOpenChange={setShowSource}>
<CollapsibleTrigger className="bg-muted/30 hover:bg-muted/60 flex w-full items-center justify-between rounded-lg border px-4 py-2.5 text-sm font-semibold transition-colors">
<span className="flex items-center gap-1.5">
<FileCode2Icon className="text-muted-foreground size-4" />
{tx.contentSectionTitle}
</span>
<ChevronDownIcon
className={cn(
"text-muted-foreground size-4 transition-transform",
showSource && "rotate-180",
)}
/>
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<div className="bg-muted/50 max-h-[340px] overflow-auto rounded-lg border">
{isContentLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : contentError ? (
<div className="text-destructive p-4 text-sm">
{contentError instanceof Error
? contentError.message
: String(contentError)}
</div>
) : (
<pre className="text-foreground/80 p-4 font-mono text-xs leading-relaxed break-words whitespace-pre-wrap">
{contentData?.content}
</pre>
)}
</div>
</CollapsibleContent>
</Collapsible>
</section>
)}
</div>
</DialogContent>
</Dialog>
@@ -0,0 +1,328 @@
"use client";
import {
CheckIcon,
CopyIcon,
DownloadIcon,
ExternalLinkIcon,
Loader2,
Trash2Icon,
} from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useI18n } from "@/core/i18n/hooks";
import type {
SharePreview,
ThreadShare,
ThreadShareMode,
} from "@/core/thread-shares/api";
import { buildPublicShareUrl, extractShareCode } from "@/core/thread-shares/api";
import {
useCreateShare,
useImportShare,
usePreviewShare,
useRevokeShare,
} from "@/core/thread-shares/hooks";
import { cn } from "@/lib/utils";
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/**
* Dialog that produces a share link for a single conversation. Two modes:
*
* - `import` — mints a short code; a recipient enters it in the import dialog
* to copy the conversation into their own account.
* - `view` — mints a public, read-only link that anyone can open without
* logging in (a static page; viewers cannot send new messages).
*/
export function ShareConversationDialog({
threadId,
open,
onOpenChange,
}: {
threadId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const tx = t.threadShare;
const createShare = useCreateShare();
const revokeShare = useRevokeShare();
const [mode, setMode] = useState<ThreadShareMode>("view");
const [share, setShare] = useState<ThreadShare | null>(null);
const [copied, setCopied] = useState(false);
// Reset to the default mode (read-only link) every time the dialog opens.
useEffect(() => {
if (open) setMode("view");
}, [open, threadId]);
// Mint / reuse the link whenever the dialog opens or the mode changes.
useEffect(() => {
if (!open || !threadId) return;
setShare(null);
setCopied(false);
createShare
.mutateAsync({ threadId, mode })
.then(setShare)
.catch((err) => toast.error(errorMessage(err)));
// createShare is stable enough; re-running only on open/threadId/mode is intended.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, threadId, mode]);
const isView = mode === "view";
const publicUrl =
isView && share ? buildPublicShareUrl(share.share_code) : "";
async function handleCopy() {
if (!share) return;
const text = isView ? publicUrl : share.share_code;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
toast.success(isView ? "链接已复制" : tx.codeCopied);
setTimeout(() => setCopied(false), 1500);
} catch {
toast.error(t.clipboard.failedToCopyToClipboard);
}
}
async function handleRevoke() {
if (!share || !window.confirm(tx.revokeConfirm)) return;
try {
await revokeShare.mutateAsync(share.share_code);
toast.success(tx.revokeSuccess);
onOpenChange(false);
} catch (err) {
toast.error(errorMessage(err));
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{tx.shareTitle}</DialogTitle>
<DialogDescription>
{isView
? "生成一个只读链接,任何人无需登录都能查看这个对话,但不能继续提问。"
: tx.shareDescription}
</DialogDescription>
</DialogHeader>
{/* Mode selector */}
<div className="bg-muted flex gap-1 rounded-lg p-1">
{(
[
{ value: "view" as const, label: "只读链接" },
{ value: "import" as const, label: "导入副本" },
]
).map((option) => (
<button
key={option.value}
type="button"
onClick={() => setMode(option.value)}
className={cn(
"flex-1 rounded-md px-3 py-1.5 text-sm transition-colors",
mode === option.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.label}
</button>
))}
</div>
{!share ? (
<div className="text-muted-foreground flex items-center gap-2 py-8 text-sm">
<Loader2 className="size-4 animate-spin" />
{tx.generating}
</div>
) : (
<div className="space-y-3">
<div>
<p className="text-muted-foreground mb-1 text-xs">
{isView ? "只读链接" : tx.shareCodeLabel}
</p>
<div className="flex items-center gap-2">
{isView ? (
<Input
readOnly
value={publicUrl}
onFocus={(e) => e.currentTarget.select()}
className="flex-1 font-mono text-xs"
/>
) : (
<code className="bg-muted flex-1 rounded-md px-3 py-2 text-center font-mono text-lg tracking-[0.3em]">
{share.share_code}
</code>
)}
<Button variant="outline" onClick={() => void handleCopy()}>
{copied ? (
<CheckIcon className="size-4" />
) : (
<CopyIcon className="size-4" />
)}
{isView ? "复制链接" : tx.copyCode}
</Button>
</div>
</div>
{isView ? (
<div className="space-y-3">
<p className="text-muted-foreground text-xs">
拿到链接的人都能查看这个对话的全部内容(只读),无法继续发送问题。撤销后链接立即失效。
</p>
<Button
variant="outline"
size="sm"
onClick={() => window.open(publicUrl, "_blank", "noopener")}
>
<ExternalLinkIcon className="size-3.5" />
在新标签页打开
</Button>
</div>
) : (
<p className="text-muted-foreground text-xs">{tx.shareHint}</p>
)}
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={revokeShare.isPending}
onClick={() => void handleRevoke()}
>
<Trash2Icon className="size-3.5" />
{tx.revoke}
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}
/** Dialog where a user pastes a share code to import someone's conversation. */
export function ImportConversationsDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const { t } = useI18n();
const tx = t.threadShare;
const [input, setInput] = useState("");
const [preview, setPreview] = useState<SharePreview | null>(null);
const previewShare = usePreviewShare();
const importShare = useImportShare();
// Reset state whenever the dialog closes.
useEffect(() => {
if (!open) {
setInput("");
setPreview(null);
}
}, [open]);
async function handlePreview() {
const code = extractShareCode(input);
if (!code) {
toast.error(tx.emptyCode);
return;
}
try {
setPreview(await previewShare.mutateAsync(code));
} catch (err) {
setPreview(null);
toast.error(errorMessage(err));
}
}
async function handleImport() {
const code = preview?.share_code ?? extractShareCode(input);
if (!code) {
toast.error(tx.emptyCode);
return;
}
try {
const result = await importShare.mutateAsync(code);
if (result.imported > 0) {
toast.success(tx.importSuccess);
} else {
toast.info(tx.alreadyImported);
}
onOpenChange(false);
} catch (err) {
toast.error(errorMessage(err));
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{tx.importTitle}</DialogTitle>
<DialogDescription>{tx.importDescription}</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input
value={input}
onChange={(e) => {
setInput(e.target.value);
setPreview(null);
}}
placeholder={tx.codeInputPlaceholder}
className="font-mono"
/>
<Button
variant="outline"
onClick={() => void handlePreview()}
disabled={previewShare.isPending || !input.trim()}
>
{previewShare.isPending && (
<Loader2 className="size-4 animate-spin" />
)}
{tx.preview}
</Button>
</div>
{preview && (
<div className="space-y-2 rounded-lg border p-3 text-sm">
<p className="font-medium">
{tx.previewConversation.replace("{title}", preview.title)}
</p>
<p className="text-muted-foreground text-xs">
{tx.previewFrom.replace("{user}", preview.shared_by)}
</p>
<Button
className="w-full"
onClick={() => void handleImport()}
disabled={importShare.isPending}
>
{importShare.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<DownloadIcon className="size-4" />
)}
{importShare.isPending ? tx.importing : tx.doImport}
</Button>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -13,7 +13,7 @@ import {
} from "@/components/ui/sidebar";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
import { BotIcon, CalendarClock, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
export function WorkspaceNavChatList() {
const { t } = useI18n();
@@ -77,7 +77,7 @@ export function WorkspaceNavChatList() {
{isAdmin && (
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/admin")}
isActive={pathname === "/page/workspace/admin/users"}
asChild
>
<Link className="text-sidebar-foreground" href="/page/workspace/admin/users">
@@ -87,6 +87,22 @@ export function WorkspaceNavChatList() {
</SidebarMenuButton>
</SidebarMenuItem>
)}
{isAdmin && (
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/admin/scheduled-tasks"}
asChild
>
<Link
className="text-sidebar-foreground"
href="/page/workspace/admin/scheduled-tasks"
>
<CalendarClock />
<span>定时任务管理</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroup>
);
@@ -250,6 +250,35 @@ export const enUS: Translations = {
searchChats: "Search chats",
},
// Conversation sharing
threadShare: {
importChats: "Import chat",
shareTitle: "Share conversation",
shareDescription:
'Share this conversation with others. They enter the code under "Import chat" to copy it into their own account and keep chatting.',
generating: "Generating code...",
shareCodeLabel: "Share code",
copyCode: "Copy",
codeCopied: "Share code copied",
shareHint:
"Anyone who has the code can copy this conversation — share it carefully.",
revoke: "Revoke share",
revokeConfirm: "Revoking makes the code unusable immediately. Continue?",
revokeSuccess: "Share revoked",
importTitle: "Import a conversation",
importDescription:
"Enter a share code to copy the shared conversation into your account.",
codeInputPlaceholder: "Paste a share code",
preview: "Preview",
previewConversation: "Conversation: {title}",
previewFrom: "From user {user}",
doImport: "Import",
importing: "Importing...",
importSuccess: "Conversation imported",
alreadyImported: "You have already imported this conversation",
emptyCode: "Enter a share code first",
},
// Page titles (document title)
pages: {
appName: "CM Assistant",
@@ -194,6 +194,32 @@ export interface Translations {
searchChats: string;
};
// Conversation sharing
threadShare: {
importChats: string;
shareTitle: string;
shareDescription: string;
generating: string;
shareCodeLabel: string;
copyCode: string;
codeCopied: string;
shareHint: string;
revoke: string;
revokeConfirm: string;
revokeSuccess: string;
importTitle: string;
importDescription: string;
codeInputPlaceholder: string;
preview: string;
previewConversation: string;
previewFrom: string;
doImport: string;
importing: string;
importSuccess: string;
alreadyImported: string;
emptyCode: string;
};
// Page titles (document title)
pages: {
appName: string;
@@ -238,6 +238,33 @@ export const zhCN: Translations = {
searchChats: "搜索对话",
},
// Conversation sharing
threadShare: {
importChats: "导入对话",
shareTitle: "分享对话",
shareDescription:
"把这个对话分享给他人。对方在「导入对话」里填入分享码,即可把它复制一份到自己账号并继续聊天。",
generating: "生成分享码中...",
shareCodeLabel: "分享码",
copyCode: "复制",
codeCopied: "分享码已复制",
shareHint: "知道分享码的人都能复制这个对话,请谨慎分享。",
revoke: "撤销分享",
revokeConfirm: "撤销后该分享码立即失效,确定撤销?",
revokeSuccess: "已撤销分享",
importTitle: "导入对话",
importDescription: "填入对方给你的分享码,把 TA 分享的那个对话复制到你的账号。",
codeInputPlaceholder: "粘贴分享码",
preview: "预览",
previewConversation: "对话:{title}",
previewFrom: "来自用户 {user}",
doImport: "导入",
importing: "导入中...",
importSuccess: "对话已导入",
alreadyImported: "你已经导入过这个对话了",
emptyCode: "请先填入分享码",
},
// Page titles (document title)
pages: {
appName: "CM 助手",
@@ -8,6 +8,7 @@ import type {
ScheduledTaskResultFileContent,
ScheduledTaskRun,
ScheduledTaskRunFileUpdateResponse,
ScheduledTaskRunMessagesResponse,
ScheduledTaskSubscription,
SchedulerThread,
} from "./types";
@@ -79,6 +80,13 @@ export async function deleteScheduledTaskRun(runId: string) {
);
}
/** Agent messages for a run's underlying agent run — used to inspect a stuck/running run. */
export async function getScheduledTaskRunMessages(runId: string) {
return parseResponse<ScheduledTaskRunMessagesResponse>(
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}/messages`),
);
}
export function scheduledTaskRunDownloadUrl(runId: string) {
return `${baseUrl()}/runs/${encodeURIComponent(runId)}/download`;
}
@@ -296,3 +304,61 @@ export async function unsubscribeScheduledTask(taskId: string) {
}),
);
}
// --- Admin (cross-user management) ---
/** List every scheduled task across all users. Admin-only endpoint. */
export async function listAllScheduledTasks(search?: string) {
const qs = new URLSearchParams();
if (search) qs.set("search", search);
const query = qs.toString();
return parseResponse<ScheduledTask[]>(
await apiFetch(`${baseUrl()}/admin/all${query ? `?${query}` : ""}`),
);
}
/** List runs for any task regardless of owner. Admin-only endpoint. */
export async function adminListScheduledTaskRuns(taskId: string) {
return parseResponse<ScheduledTaskRun[]>(
await apiFetch(`${baseUrl()}/admin/${encodeURIComponent(taskId)}/runs`),
);
}
/** Update any task regardless of owner. Admin-only endpoint. */
export async function adminUpdateScheduledTask(
taskId: string,
data: Partial<{
name: string;
prompt: string;
schedule: string;
timezone: string;
enabled: boolean;
execution_agent_name: string | null;
}>,
) {
return parseResponse<ScheduledTask>(
await apiFetch(`${baseUrl()}/admin/${encodeURIComponent(taskId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
);
}
/** Delete any task regardless of owner. Admin-only endpoint. */
export async function adminDeleteScheduledTask(taskId: string) {
return parseResponse<{ success: boolean }>(
await apiFetch(`${baseUrl()}/admin/${encodeURIComponent(taskId)}`, {
method: "DELETE",
}),
);
}
/** Trigger an immediate run of any task regardless of owner. Admin-only. */
export async function adminTriggerScheduledTask(taskId: string) {
return parseResponse<{ success: boolean; task: ScheduledTask }>(
await apiFetch(`${baseUrl()}/admin/${encodeURIComponent(taskId)}/trigger`, {
method: "POST",
}),
);
}
@@ -6,12 +6,18 @@ import {
} from "@tanstack/react-query";
import {
adminDeleteScheduledTask,
adminListScheduledTaskRuns,
adminTriggerScheduledTask,
adminUpdateScheduledTask,
createScheduledTask,
deleteScheduledTask,
deleteScheduledTaskRun,
getSchedulerThread,
getScheduledTaskDeliveryProfile,
getScheduledTaskRun,
getScheduledTaskRunMessages,
listAllScheduledTasks,
listPublishedScheduledTasks,
listScheduledTaskRuns,
listScheduledTasks,
@@ -57,12 +63,34 @@ export function useScheduledTaskRuns(taskId?: string | null) {
});
}
/** A run still in flight — used to drive live polling of the run/messages. */
function runIsLive(status?: string | null): boolean {
return status === "running" || status === "pending";
}
export function useScheduledTaskRun(runId?: string | null) {
return useQuery({
queryKey: ["scheduled-tasks", "runs", runId],
queryFn: () => getScheduledTaskRun(runId!),
enabled: !!runId,
refetchOnWindowFocus: false,
// Keep refreshing while the run is still executing so the detail page
// reflects status / result changes without a manual reload.
refetchInterval: (query) => (runIsLive(query.state.data?.status) ? 5000 : false),
});
}
/** Agent messages for a run; polls every 4s while the run is still executing. */
export function useScheduledTaskRunMessages(
runId?: string | null,
options: { live?: boolean } = {},
) {
return useQuery({
queryKey: ["scheduled-tasks", "runs", runId, "messages"],
queryFn: () => getScheduledTaskRunMessages(runId!),
enabled: !!runId,
refetchOnWindowFocus: false,
refetchInterval: options.live ? 4000 : false,
});
}
@@ -74,6 +102,64 @@ export function useScheduledTaskDeliveryProfile() {
});
}
/** Admin: every scheduled task across all users. */
export function useAllScheduledTasks(
params: { search?: string; enabled?: boolean } = {},
) {
const search = params.search ?? "";
return useQuery({
queryKey: ["scheduled-tasks", "admin", "list", search],
queryFn: () => listAllScheduledTasks(search),
enabled: params.enabled ?? true,
refetchOnWindowFocus: false,
placeholderData: keepPreviousData,
});
}
/** Admin: runs for any task regardless of owner. */
export function useAdminScheduledTaskRuns(taskId?: string | null) {
return useQuery({
queryKey: ["scheduled-tasks", "admin", taskId, "runs"],
queryFn: () => adminListScheduledTaskRuns(taskId!),
enabled: !!taskId,
refetchOnWindowFocus: false,
});
}
/** Admin mutations: edit / delete / trigger any task. */
export function useAdminScheduledTaskActions() {
const queryClient = useQueryClient();
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
return {
update: useMutation({
mutationFn: ({
taskId,
data,
}: {
taskId: string;
data: Parameters<typeof adminUpdateScheduledTask>[1];
}) => adminUpdateScheduledTask(taskId, data),
onSuccess: invalidate,
}),
remove: useMutation({
mutationFn: adminDeleteScheduledTask,
onSuccess: invalidate,
}),
// The DELETE /runs/{id} route is admin-aware, so the same call deletes
// any user's run when the caller is an admin.
removeRun: useMutation({
mutationFn: deleteScheduledTaskRun,
onSuccess: invalidate,
}),
trigger: useMutation({
mutationFn: adminTriggerScheduledTask,
onSuccess: invalidate,
}),
};
}
export function usePublishedScheduledTasks(enabled: boolean = true) {
return useQuery({
queryKey: ["scheduled-tasks", "published"],
@@ -94,6 +94,7 @@ export type ScheduledTaskRun = {
status: "pending" | "running" | "succeeded" | "failed" | string;
error?: string | null;
result?: ScheduledTaskRunResult | null;
retry_count?: number | null;
created_at?: string | null;
updated_at?: string | null;
can_edit?: boolean;
@@ -106,3 +107,19 @@ export type ScheduledTaskRunFileUpdateResponse = {
mime_type?: string | null;
kind?: string | null;
};
/** One agent message produced by a scheduled run's underlying agent run. */
export type ScheduledTaskRunMessage = {
seq: number;
event_type: string;
category: string;
// string for human input; object (LangChain message dump) for ai/tool.
content: unknown;
metadata?: Record<string, unknown> | null;
created_at?: string | null;
};
export type ScheduledTaskRunMessagesResponse = {
messages: ScheduledTaskRunMessage[];
has_more: boolean;
};
+137
View File
@@ -0,0 +1,137 @@
import type { Message } from "@langchain/langgraph-sdk";
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
/**
* Share modes:
* - `import` — recipient (a logged-in user) copies the conversation into
* their own account and can keep chatting in the copy.
* - `view` — a public, read-only static page anyone can open without
* logging in; viewers cannot send new messages.
*/
export type ThreadShareMode = "import" | "view";
export interface ThreadShare {
share_code: string;
owner_user_id: string;
thread_id: string;
revoked: boolean;
mode: ThreadShareMode;
created_at: string | null;
}
/** Read-only conversation snapshot returned by the public view-share endpoint. */
export interface PublicShareSnapshot {
share_code: string;
title: string;
mode: string;
messages: Message[];
artifacts: string[];
created_at: string | null;
}
export interface SharePreview {
share_code: string;
shared_by: string;
title: string;
}
export interface ImportResult {
imported: number;
skipped: number;
}
const baseUrl = () => `${getBackendBaseURL()}/api/thread-shares`;
async function parseError(response: Response, fallback: string): Promise<Error> {
const data = (await response.json().catch(() => ({}))) as { detail?: unknown };
const detail = data.detail;
const message =
typeof detail === "string" && detail
? detail
: `${fallback} (HTTP ${response.status})`;
return new Error(message);
}
/** Create (or reuse) a share code for one conversation in the given mode. */
export async function createShare(
threadId: string,
mode: ThreadShareMode = "import",
): Promise<ThreadShare> {
const response = await apiFetch(baseUrl(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ thread_id: threadId, mode }),
});
if (!response.ok) throw await parseError(response, "创建分享失败");
return response.json() as Promise<ThreadShare>;
}
/** List the current user's own share links. */
export async function listShares(): Promise<ThreadShare[]> {
const response = await apiFetch(baseUrl());
if (!response.ok) throw await parseError(response, "加载分享列表失败");
const json = (await response.json()) as { shares: ThreadShare[] };
return json.shares;
}
/** Revoke a share link so it can no longer be previewed or imported. */
export async function revokeShare(shareCode: string): Promise<void> {
const response = await apiFetch(`${baseUrl()}/${encodeURIComponent(shareCode)}`, {
method: "DELETE",
});
if (!response.ok) throw await parseError(response, "撤销分享失败");
}
/** Preview a share link before importing it. */
export async function previewShare(shareCode: string): Promise<SharePreview> {
const response = await apiFetch(
`${baseUrl()}/${encodeURIComponent(shareCode)}/preview`,
);
if (!response.ok) throw await parseError(response, "无法读取该分享");
return response.json() as Promise<SharePreview>;
}
/** Import every conversation behind a share link into the current account. */
export async function importShare(shareCode: string): Promise<ImportResult> {
const response = await apiFetch(
`${baseUrl()}/${encodeURIComponent(shareCode)}/import`,
{ method: "POST" },
);
if (!response.ok) throw await parseError(response, "导入失败");
return response.json() as Promise<ImportResult>;
}
/**
* Fetch the public read-only snapshot of a `view`-mode share.
*
* Uses a plain `fetch` (no auth header) — the `/api/public/` endpoint is
* intentionally world-readable so anyone with the link can open it.
*/
export async function getPublicShareSnapshot(
shareCode: string,
): Promise<PublicShareSnapshot> {
const response = await fetch(
`${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(shareCode)}`,
);
if (!response.ok) throw await parseError(response, "无法打开该分享");
return response.json() as Promise<PublicShareSnapshot>;
}
/** Build the shareable public URL for a `view`-mode share code. */
export function buildPublicShareUrl(shareCode: string): string {
// The app uses HashRouter, so the public viewer lives at `#/share/<code>`.
return `${window.location.origin}${window.location.pathname}#/share/${shareCode}`;
}
/**
* Extract a share code from arbitrary pasted text — accepts either a raw code
* or a URL/link that contains one. Share codes are 6-12 chars from the
* unambiguous alphabet (no 0/1/O/I/L).
*/
export function extractShareCode(input: string): string {
const trimmed = input.trim();
const matches = trimmed.toUpperCase().match(/[A-HJ-NP-Z2-9]{6,12}/g);
return matches && matches.length > 0 ? matches[matches.length - 1] : trimmed;
}
@@ -0,0 +1,61 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createShare,
importShare,
listShares,
previewShare,
revokeShare,
type ThreadShareMode,
} from "./api";
export function useShares(enabled = true) {
return useQuery({
queryKey: ["thread-shares"],
queryFn: listShares,
enabled,
});
}
export function useCreateShare() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
threadId,
mode = "import",
}: {
threadId: string;
mode?: ThreadShareMode;
}) => createShare(threadId, mode),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["thread-shares"] });
},
});
}
export function useRevokeShare() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (shareCode: string) => revokeShare(shareCode),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["thread-shares"] });
},
});
}
export function usePreviewShare() {
return useMutation({
mutationFn: (shareCode: string) => previewShare(shareCode),
});
}
export function useImportShare() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (shareCode: string) => importShare(shareCode),
onSuccess: () => {
// Imported conversations show up in the user's own thread list.
void queryClient.invalidateQueries({ queryKey: ["threads"] });
},
});
}
@@ -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>
);
}
+19 -1
View File
@@ -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>
);
}
+105
View File
@@ -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>
)}
+29 -216
View File
@@ -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";
@@ -184,6 +185,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={
+7 -8
View File
@@ -1,14 +1,13 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import sourceIdentifierPlugin from "vite-plugin-source-identifier";
import { defineConfig } from "vite";
import path from "node:path";
import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
const rootDir = fileURLToPath(new URL(".", import.meta.url));
export default defineConfig({
plugins: [react(), sourceIdentifierPlugin()],
export default defineConfig({
plugins: [react()],
resolve: {
alias: [
{
+61
View File
@@ -0,0 +1,61 @@
# ========================
# EDIT BEFORE DEPLOYMENT
# ========================
# This file is loaded by start_backend.scheduler.sh through docker --env-file.
# Put server-specific values here. Do not configure Python mirrors only on the
# host shell; values in this file are what the container will receive.
# AI provider keys. Replace placeholders and rotate any test key before release.
DEEPSEEK_API_KEY=sk-cea9133f82e24e9f939136746b05d5f8
OPENAI_API_KEY=your-openai-api-key
ANTHROPIC_API_KEY=your-anthropic-api-key
TAVILY_API_KEY=your-tavily-api-key
SERPER_API_KEY=your-serper-api-key
# Auth and frontend access.
# AUTH_JWT_SECRET must stay stable across restarts, or existing logins expire.
# GATEWAY_CORS_ORIGINS must include the exact frontend origins, separated by commas.
AUTH_JWT_SECRET=i3_BiBK44sYHiFIkChhDs46y57ZewJKO3diQDw01anI
GATEWAY_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
GATEWAY_CORS_ALLOW_ALL=0
GATEWAY_ENABLE_DOCS=true
GATEWAY_WORKERS=2
DEER_FLOW_AUTH_DISABLED=false
AUTH_TRUSTED_PROXIES=
# Scheduler dependency behavior.
# Keep SCHEDULER_INSTALL_DEPS=1 if croniter is not already baked into the image.
# Set the Python mirror below if the server cannot access public PyPI.
SCHEDULER_INSTALL_DEPS=1
SCHEDULER_PIP_PACKAGES=croniter==6.2.2
SCHEDULED_TASKS_ENABLED=true
# Element / Matrix delivery for scheduled task results.
# If disabled or not configured, scheduled tasks still run but no Element DM is sent.
ELEMENT_ENABLED=false
ELEMENT_HOMESERVER_URL=
ELEMENT_ACCESS_TOKEN=
ELEMENT_BOT_USERNAME=scheduled-bot
ELEMENT_BOT_PASSWORD=
ELEMENT_AUTO_REGISTER=false
ELEMENT_STATE_FILE=/app/backend/.deer-flow/element-bot-token
# Python private package index used by uv/pip inside the backend container and
# propagated into sandbox containers for agent-installed dependencies.
# Uncomment and set these to your internal mirror when public PyPI is blocked.
# UV_INDEX_URL=http://your-python-mirror/simple
# PIP_INDEX_URL=http://your-python-mirror/simple
# UV_TRUSTED_HOST=your-python-mirror
# PIP_TRUSTED_HOST=your-python-mirror
# PIP_EXTRA_INDEX_URL=
# UV_EXTRA_INDEX_URL=
# HTTP_PROXY=
# HTTPS_PROXY=
# NO_PROXY=
# Alternative generic names also supported by the startup script.
# PYTHON_PACKAGE_INDEX_URL=http://your-python-mirror/simple
# PYTHON_EXTRA_INDEX_URL=
# PYTHON_TRUSTED_HOST=your-python-mirror
MYSQL_DATABASE_URL=mysql+asyncmy://kland:kland%4088124932@192.168.0.123:3306/deerflow?charset=utf8mb4
@@ -0,0 +1,255 @@
# Linux Docker 版:业务库切换 MySQL 发版说明
适用场景:Linux 服务器上使用 `offline-backend-20260512/start_backend.sh` 启动 Docker 后端。
原启动脚本保持不变:
```bash
bash start_backend.sh
```
单独新增切换脚本:
```bash
bash switch_database_to_mysql.sh
```
## 一、切换后的数据布局
迁移到 MySQL 的业务表:
```text
users
threads_meta
runs
run_events
feedback
agents
skills
scheduled_tasks
scheduled_task_runs
scheduled_task_subscriptions
scheduled_task_delivery_profiles
scheduler_threads
llm_call_metrics
notifications
recommended_questions
tags
tag_assignments
thread_shares
```
继续留在 SQLite:
```text
checkpoints
writes
```
SQLite 路径:
```text
宿主机: offline-backend-20260512/data/data/deerflow.db
容器内: /app/backend/.deer-flow/data/deerflow.db
```
## 二、前置条件
MySQL 建议提前建库:
```sql
CREATE DATABASE deerflow
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
```
本次 Linux 内网库连接信息按以下方式配置:
```bash
MYSQL_DATABASE_URL='mysql+asyncmy://root:1qaz%40WSX@172.16.4.xxx:3306/deerflow?charset=utf8mb4'
```
注意:密码里的 `@` 在 URL 中必须写成 `%40`,所以 `1qaz@WSX` 要写成 `1qaz%40WSX`。
离线包 `wheelhouse` 中需要有:
```text
asyncmy-0.2.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
```
当前包已经放入该 wheel。
如果使用内网 pip 私服,在 `.env` 中配置:
```env
PIP_INDEX_URL=http://your-pip-mirror/simple
PIP_TRUSTED_HOST=your-pip-mirror
UV_INDEX_URL=http://your-pip-mirror/simple
UV_TRUSTED_HOST=your-pip-mirror
```
这些变量会传给后端容器和 AIO 沙箱容器。
## 三、发版步骤
进入部署目录:
```bash
cd offline-backend-20260512
```
先启动一次后端,让 SQLite schema 自动升级到当前代码版本:
```bash
bash start_backend.sh
```
确认服务正常后停止:
```bash
bash stop_backend.sh
```
执行一键切换:
```bash
MYSQL_DATABASE_URL='mysql+asyncmy://root:1qaz%40WSX@172.16.4.xxx:3306/deerflow?charset=utf8mb4' \
bash switch_database_to_mysql.sh
```
如果数据库 `deerflow` 已经由 DBA 提前建好,或者当前账号不能访问 MySQL 系统库 `mysql`,可以跳过自动建库:
```bash
CREATE_DATABASE=0 \
MYSQL_DATABASE_URL='mysql+asyncmy://root:1qaz%40WSX@172.16.4.xxx:3306/deerflow?charset=utf8mb4' \
bash switch_database_to_mysql.sh
```
如果目标 MySQL 表已有旧数据,并确认要清空后重迁:
```bash
TRUNCATE_TARGET=1 \
MYSQL_DATABASE_URL='mysql+asyncmy://root:1qaz%40WSX@172.16.4.xxx:3306/deerflow?charset=utf8mb4' \
bash switch_database_to_mysql.sh
```
切换完成后,继续用原启动脚本:
```bash
bash start_backend.sh
```
## 四、脚本会自动做什么
`switch_database_to_mysql.sh` 会自动:
1. 检查 Docker 是否可用。
2. 检查后端容器是否已停止。
3. 备份:
```text
data/backups/mysql-switch-YYYYMMDD_HHMMSS/config.yaml.bak
data/backups/mysql-switch-YYYYMMDD_HHMMSS/env.bak
data/backups/mysql-switch-YYYYMMDD_HHMMSS/deerflow.db.bak
```
4. 启动一次临时迁移容器。
5. 从 `/app/wheelhouse` 安装 `asyncmy` 到持久目录:
```text
容器内: /app/backend/.deer-flow/python-vendor
宿主机: offline-backend-20260512/data/python-vendor
```
6. 创建/升级 MySQL schema。
7. 默认清理失败重跑留下的空表:
```text
DROP_EMPTY_TARGET_SCHEMA=1
```
该清理只会删除已知业务表中“全部为空”的半成品表;如果任意表已有数据,脚本不会删除,会继续交给迁移校验保护。
8. 从 SQLite 迁移业务表到 MySQL。
9. 校验每张业务表行数。
10. 修改 `config.yaml`:
```yaml
database:
backend: mysql
mysql_url: $MYSQL_DATABASE_URL
pool_size: 10
echo_sql: false
checkpointer:
type: sqlite
connection_string: /app/backend/.deer-flow/data/deerflow.db
```
11. 将 `MYSQL_DATABASE_URL` 写入 `.env`。
## 五、验证
健康检查:
```bash
curl http://<server-ip>:8001/health
```
功能验证:
```text
登录/退出
历史会话列表
打开旧会话
新建对话
定时任务
分享链接
后端日志无 MySQL driver 或连接错误
```
## 六、回滚
停止后端:
```bash
bash stop_backend.sh
```
恢复备份:
```bash
cp data/backups/mysql-switch-YYYYMMDD_HHMMSS/config.yaml.bak config.yaml
cp data/backups/mysql-switch-YYYYMMDD_HHMMSS/env.bak .env
cp data/backups/mysql-switch-YYYYMMDD_HHMMSS/deerflow.db.bak data/data/deerflow.db
```
重新启动:
```bash
bash start_backend.sh
```
## 七、注意事项
- `MYSQL_DATABASE_URL` 不要写 `127.0.0.1` 指向宿主机 MySQL。容器内的 `127.0.0.1` 是容器自己。
- 如果 MySQL 在宿主机,使用宿主机内网 IP 或 Docker 可访问主机名。
- 当前 Linux 内网 MySQL 示例使用 `172.16.4.xxx:3306`。
- 密码含 `@`、`#`、`?`、`/` 等 URL 特殊字符时必须 URL 编码;本次 `1qaz@WSX` 写成 `1qaz%40WSX`。
- 已同步 Windows 实测问题修复:
```text
老 MySQL 767 bytes 索引长度限制:已收短被索引/唯一约束覆盖的字符串列。
老 MySQL 不支持 JSON 类型:MySQL 下 JSON 字段自动使用 TEXT 存储,应用层仍读写 dict/list。
失败重跑的半成品空表:switch_database_to_mysql.sh 默认传入 --drop-empty-target-schema 自动清理。
```
- `data/data/deerflow.db` 不能删除,因为 `checkpoints/writes` 仍在里面。
- 后续归档文件建议放:
```text
容器内: /app/backend/.deer-flow/archives/conversations
宿主机: offline-backend-20260512/data/archives/conversations
```
该路径已在现有 Docker 持久卷内,不需要改启动脚本。
@@ -0,0 +1,230 @@
# Windows 本地版:业务库切换 MySQL 发版说明
适用场景:Windows 机器上直接在 `offline-backend-20260512/backend` 目录本地启动后端,不使用 Docker。
原本地启动方式保持不变:
```powershell
cd offline-backend-20260512\backend
$env:PYTHONPATH='.'
uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001
```
单独新增切换脚本:
```powershell
cd offline-backend-20260512
.\switch_database_to_mysql.ps1
```
## 一、切换后的数据布局
迁移到 MySQL 的业务表:
```text
users
threads_meta
runs
run_events
feedback
agents
skills
scheduled_tasks
scheduled_task_runs
scheduled_task_subscriptions
scheduled_task_delivery_profiles
scheduler_threads
llm_call_metrics
notifications
recommended_questions
tags
tag_assignments
thread_shares
```
继续留在 SQLite:
```text
checkpoints
writes
```
Windows 本地默认 SQLite 路径:
```text
offline-backend-20260512\backend\.deer-flow\data\deerflow.db
```
如果你的本地 `DEER_FLOW_HOME` 或 SQLite 路径不是默认值,执行切换脚本时用 `-SqliteDb` 指定真实文件。
## 二、前置条件
本机需要:
```text
Python 3.12+
uv
Windows 能访问的 MySQL
```
MySQL 建议提前建库:
```sql
CREATE DATABASE deerflow
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
```
如果 MySQL 就在 Windows 本机,连接串可以使用 `127.0.0.1`:
```text
mysql+asyncmy://user:password@127.0.0.1:3306/deerflow?charset=utf8mb4
```
切换脚本会在本地 `backend` 的 uv 环境中安装:
```text
asyncmy==0.2.10
```
如果本机不能访问公网 pip,请先在当前 PowerShell 配置内网 pip 私服:
```powershell
$env:PIP_INDEX_URL='http://your-pip-mirror/simple'
$env:PIP_TRUSTED_HOST='your-pip-mirror'
$env:UV_INDEX_URL='http://your-pip-mirror/simple'
$env:UV_TRUSTED_HOST='your-pip-mirror'
```
## 三、发版步骤
先启动一次后端,让 SQLite schema 自动升级到当前代码版本:
```powershell
cd offline-backend-20260512\backend
$env:PYTHONPATH='.'
uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001
```
确认服务正常后按 `Ctrl + C` 停止后端。
回到发版目录,执行一键切换:
```powershell
cd offline-backend-20260512
$env:MYSQL_DATABASE_URL='mysql+asyncmy://user:password@127.0.0.1:3306/deerflow?charset=utf8mb4'
.\switch_database_to_mysql.ps1
```
也可以直接用参数传入:
```powershell
.\switch_database_to_mysql.ps1 -MysqlDatabaseUrl 'mysql+asyncmy://user:password@127.0.0.1:3306/deerflow?charset=utf8mb4'
```
如果 SQLite 不在默认路径:
```powershell
.\switch_database_to_mysql.ps1 `
-SqliteDb 'D:\deerflow-data\data\deerflow.db' `
-MysqlDatabaseUrl 'mysql+asyncmy://user:password@127.0.0.1:3306/deerflow?charset=utf8mb4'
```
如果目标 MySQL 表已有旧数据,并确认要清空后重迁:
```powershell
.\switch_database_to_mysql.ps1 `
-TruncateTarget 1 `
-MysqlDatabaseUrl 'mysql+asyncmy://user:password@127.0.0.1:3306/deerflow?charset=utf8mb4'
```
切换完成后,继续用原本地启动方式:
```powershell
cd backend
$env:PYTHONPATH='.'
uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001
```
## 四、脚本会自动做什么
`switch_database_to_mysql.ps1` 会自动:
1. 检查 `uv`、配置文件、迁移脚本、SQLite 文件是否存在。
2. 备份:
```text
backend\.deer-flow\backups\mysql-switch-YYYYMMDD_HHMMSS\config.yaml.bak
backend\.deer-flow\backups\mysql-switch-YYYYMMDD_HHMMSS\env.bak
backend\.deer-flow\backups\mysql-switch-YYYYMMDD_HHMMSS\deerflow.db.bak
```
3. 将 `MYSQL_DATABASE_URL` 写入 `.env`。
4. 如果 `backend\.venv` 不存在,自动执行 `uv venv`。
5. 执行 `uv pip install asyncmy==0.2.10`。
6. 创建/升级 MySQL schema。
7. 从 SQLite 迁移业务表到 MySQL。
8. 校验每张业务表行数。
9. 修改 `config.yaml`:
```yaml
database:
backend: mysql
mysql_url: $MYSQL_DATABASE_URL
pool_size: 10
echo_sql: false
checkpointer:
type: sqlite
connection_string: D:\...\offline-backend-20260512\backend\.deer-flow\data\deerflow.db
```
## 五、验证
启动后端后检查:
```powershell
(Invoke-WebRequest -UseBasicParsing http://127.0.0.1:8001/health).StatusCode
```
功能验证:
```text
登录/退出
历史会话列表
打开旧会话
新建对话
定时任务
分享链接
后端日志无 MySQL driver 或连接错误
```
## 六、回滚
停止本地后端进程后恢复备份:
```powershell
Copy-Item backend\.deer-flow\backups\mysql-switch-YYYYMMDD_HHMMSS\config.yaml.bak config.yaml -Force
Copy-Item backend\.deer-flow\backups\mysql-switch-YYYYMMDD_HHMMSS\env.bak .env -Force
Copy-Item backend\.deer-flow\backups\mysql-switch-YYYYMMDD_HHMMSS\deerflow.db.bak backend\.deer-flow\data\deerflow.db -Force
```
重新启动本地后端即可。
## 七、归档文件存储
后续 90 天前对话归档建议放在:
```text
offline-backend-20260512\backend\.deer-flow\archives\conversations
```
如果你设置了自定义 `DEER_FLOW_HOME`,则放在:
```text
%DEER_FLOW_HOME%\archives\conversations
```
Windows 本地版不涉及 Docker 挂载。这个目录只需要跟随你的本地数据目录一起备份即可。
归档文件被手动删除一部分时,正确行为应是该历史会话无法恢复并返回明确提示,不应该影响系统启动或 90 天内会话使用。归档功能正式实现时,需要把“归档文件缺失”作为可预期异常处理。
+19 -1
View File
@@ -214,7 +214,7 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
|--------|-----------|
| **Models** (`/api/models`) | `GET /` - list models; `GET /{name}` - model details |
| **MCP** (`/api/mcp`) | `GET /config` - get config; `PUT /config` - update config (saves to extensions_config.json) |
| **Skills** (`/api/skills`) | `GET /` - list skills (optional `search` fuzzy-name + `tag_id` filters; each item carries `tags` + owner-authored `detail`); `GET /{name}` - details; `PUT /{name}` - update enabled; `PUT /{name}/detail` - set the long-form `detail` text (custom skill: owner or admin; built-in/public: admin only — materializes a `skills` row on demand); `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) |
| **Skills** (`/api/skills`) | `GET /` - list skills (optional `search` fuzzy-name + `tag_id` filters; each item carries `tags` + owner-authored `detail`); `GET /{name}` - details; `PUT /{name}` - update enabled; `PUT /{name}/detail` - set the long-form `detail` text (custom skill: owner or admin; built-in/public: admin only — materializes a `skills` row on demand); `GET /{name}/content` - raw SKILL.md source, restricted to an admin or the publisher (custom-skill owner); other users get 403 and see only description + detail; `POST /install` - install from .skill archive (accepts standard optional frontmatter like `version`, `author`, `compatibility`) |
| **Memory** (`/api/memory`) | `GET /` - memory data; `POST /reload` - force reload; `GET /config` - config; `GET /status` - config + data |
| **Agents** (`/api/agents`) | `GET /` - list built-in, owned, and published agents (optional `search` + `tag_id` filters; each item carries `tags`); `POST /` - create user-owned agent; `GET/PUT/DELETE /{id}` - read/update/delete by stable agent id; `GET /check` - validate non-empty display name (duplicates allowed) |
| **Tags** (`/api/tags`) | `GET /` - list tags (optional `tag_type` filter; open to all); `POST /` - create tag (admin); `PUT /{id}` / `DELETE /{id}` - update/delete tag (admin); `GET /assignments/{target_type}/{target_id}` - tags on a resource; `PUT /assignments/{target_type}/{target_id}` - replace a resource's tags (admin). Tags are typed `agent`/`skill`/`scheduled_task` and may only tag resources of their own type. |
@@ -225,13 +225,31 @@ FastAPI application on port 8001 with health check at `GET /health`. Set `GATEWA
| **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/messages` - paginated messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /../messages` - thread messages with feedback; `GET /../token-usage` - aggregate tokens |
| **Feedback** (`/api/threads/{id}/runs/{rid}/feedback`) | `PUT /` - upsert feedback; `DELETE /` - delete user feedback; `POST /` - create feedback; `GET /` - list feedback; `GET /stats` - aggregate stats; `DELETE /{fid}` - delete specific |
| **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id |
| **Thread Shares** (`/api/thread-shares`) | Per-conversation sharing with two modes (`thread_shares.mode`). **`import`**: `POST /` `{thread_id, mode}` - create (or reuse) a revocable share code; `GET /{code}/preview` + `POST /{code}/import` - copy that conversation into the caller's account (fresh `thread_id` via `app/gateway/thread_copy.py` + new `threads_meta` row + `user-data` copy; dedup on `metadata.shared_origin`). **`view`**: a public, read-only static page — see Public Shares below. Both modes: `GET /` - list my codes; `DELETE /{code}` - revoke. |
| **Public Shares** (`/api/public/shares`) | **Unauthenticated** (the `/api/public/` prefix is whitelisted in `auth_middleware._PUBLIC_PATH_PREFIXES`). `GET /{code}` - read-only conversation snapshot (title + serialized messages + artifacts) for a `view`-mode share; messages are read straight from the latest checkpoint. `GET /{code}/files/{path}` - serves an **image** referenced by that conversation (images only — non-image paths 404; SVG forced to download). Missing/revoked/non-`view` codes all return 404. |
| **Scheduled Tasks** (`/api/scheduled-tasks`) | Per-user cron-like agent tasks. `GET/POST /` - list/create owned tasks; `PATCH/DELETE /{id}` - update/delete (owner); `POST /{id}/{pause,resume,trigger}`; `GET /{id}/runs`, `GET /runs/{rid}`, run-file preview/edit/download; `GET /runs/{rid}/messages` - agent messages for the run's underlying agent run (used to inspect a stuck/running run; authorized via `_load_run`); `DELETE /runs/{rid}` - owner-scoped, with an admin fallback so admins can delete any user's run; publish/subscribe under `/{id}/{publish,unpublish,subscribe}` and `GET /published`. **Admin (cross-user):** `GET /admin/all` - every task; `PATCH/DELETE /admin/{id}` - edit/delete any task; `POST /admin/{id}/trigger`; `GET /admin/{id}/runs`. Admin routes require `system_role == "admin"`. |
Proxied through nginx: `/api/langgraph/*` → LangGraph, all other `/api/*` → Gateway.
### Scheduled Task Runtime
`ScheduledTaskService` (`deerflow/runtime/scheduler/service.py`) polls due tasks every `poll_interval_seconds` and executes each in its own throwaway thread, serialized by a shared `_agent_run_lock`.
**Stuck-run recovery**: each execution attempt is bounded by `_RUN_ATTEMPT_TIMEOUT_SECONDS` (30 min) via `asyncio.wait_for`. On timeout the run is retried up to `_MAX_RUN_RETRIES` (3) times, then marked `failed`. A watchdog (`_reconcile_stuck_runs`, run every poll) recovers runs left in `running` with no in-process owner — e.g. orphaned by a crash or restart — re-driving them through the same retry budget so a task can never stay perpetually "executing". The `scheduled_task_runs.retry_count` column tracks consumed retries.
**Unique task names**: task names are unique per user (`uq_scheduled_tasks_user_name`). `create_task`/`update_task`/`admin_update_task` proactively check via `_assert_name_available()` and raise `DuplicateTaskNameError` on a collision; the router translates that into HTTP `409` instead of letting the raw DB `IntegrityError` surface as a `500`.
### Custom Agent Persistence
Custom agent metadata lives in the `agents` ORM table (`deerflow.persistence.agents`). `agents.id` is the stable runtime id and the filesystem directory name under `.deer-flow/agents/{id}/`; `agents.name` is display-only, can contain Chinese characters, and is not unique. `user_id IS NULL` marks built-in agents, user-owned agents are visible only to their owner unless `published = true`, and update/delete operations require ownership. The runtime accepts `agent_id` in context/configurable, and non-default `assistant_id` values are treated as agent ids after visibility checks.
### Portable Column Types (`deerflow/persistence/types.py`)
Two `TypeDecorator`s keep ORM columns correct across the `sqlite` / `postgres` / `mysql` backends. **All ORM timestamp/JSON columns must use these, not the raw SQLAlchemy types.**
- **`PortableJSON`** — native `JSON` on SQLite/Postgres; `TEXT` (with manual `json.dumps`/`loads`) on MySQL, where older servers lack JSON support.
- **`BeijingDateTime`** — stores timestamps as Beijing (UTC+8) wall-clock time. The app records time with `datetime.now(UTC)`, but MySQL `DATETIME` / SQLite have no timezone and persist whatever wall clock the driver serializes — so a raw `DateTime` column would store UTC and read 8 hours behind Beijing. `BeijingDateTime` converts UTC→Beijing on bind and re-attaches `+08:00` on result, so the database holds Beijing time while Python still gets timezone-aware datetimes (instant comparisons stay correct, including in WHERE clauses). PostgreSQL `timestamptz` stores the instant unambiguously, so values pass through unchanged there. Uses a fixed UTC+8 offset (China has no DST), so no `tzdata` dependency.
### Sandbox System (`packages/harness/deerflow/sandbox/`)
**Interface**: Abstract `Sandbox` with `execute_command`, `read_file`, `write_file`, `list_dir`
@@ -42,6 +42,7 @@ from app.gateway.routers import (
system_settings,
tags,
thread_runs,
thread_shares,
threads,
uploads,
writing,
@@ -255,7 +256,7 @@ async def _run_db_migrations() -> None:
logger.info("Persistence backend=memory — skipping Alembic migrations")
return
db_url = str(engine.url)
db_url = engine.url.render_as_string(hide_password=False).replace("%", "%%")
migrations_dir = str(Path(_persistence_pkg.__file__).parent / "migrations")
def _run_sync() -> None:
@@ -709,6 +710,10 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Thread Runs API (LangGraph Platform-compatible runs lifecycle)
app.include_router(thread_runs.router)
app.include_router(thread_shares.router)
# Public, unauthenticated read-only share snapshots (/api/public/shares/*).
app.include_router(thread_shares.public_router)
# Stateless Runs API (stream/wait without a pre-existing thread)
app.include_router(runs.router)
@@ -23,11 +23,16 @@ from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_us
from deerflow.runtime.user_context import reset_current_user, set_current_user
# Paths that never require authentication.
#
# ``/api/public/`` is a namespace reserved for intentionally world-readable,
# read-only endpoints (e.g. public conversation share snapshots). Only mount
# safe read-only routes under it — everything there bypasses auth entirely.
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
"/health",
"/docs",
"/redoc",
"/openapi.json",
"/api/public/",
)
# Exact auth paths that are public (login/register/status check).
@@ -122,6 +122,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
app.state.tag_store = make_tag_store(sf)
from deerflow.persistence.thread_shares import make_thread_share_store
app.state.thread_share_store = make_thread_share_store(sf)
from deerflow.persistence.ai_writing_sessions import make_ai_writing_session_store
app.state.ai_writing_session_store = make_ai_writing_session_store(sf)
@@ -230,6 +234,14 @@ def get_tag_store(request: Request) -> TagStore:
return val
def get_thread_share_store(request: Request):
"""Return the conversation share-link store."""
val = getattr(request.app.state, "thread_share_store", None)
if val is None:
raise HTTPException(status_code=503, detail="Thread share store not available")
return val
def get_ai_writing_session_store(request: Request):
"""Return the AI writing session store, or None when not configured."""
return getattr(request.app.state, "ai_writing_session_store", None)
@@ -14,8 +14,8 @@ from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import FileResponse, Response, StreamingResponse
from pydantic import BaseModel, Field
from app.gateway.deps import get_current_user, get_local_provider, get_tag_store
from deerflow.runtime.scheduler import get_scheduled_task_service
from app.gateway.deps import get_current_user, get_local_provider, get_optional_user_from_request, get_run_event_store, get_run_store, get_tag_store
from deerflow.runtime.scheduler import DuplicateTaskNameError, get_scheduled_task_service
router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
@@ -61,6 +61,26 @@ async def _user_id(request: Request) -> str:
return user_id
async def _is_admin(request: Request) -> bool:
"""True when the caller is an admin (or auth is disabled entirely)."""
user = await get_optional_user_from_request(request)
if user is None:
return True
return getattr(user, "system_role", None) == "admin"
async def _load_run(service, request: Request, run_id: str, user_id: str) -> dict[str, Any] | None:
"""Owner/subscriber run lookup, with an admin fallback to any run.
Lets admins open run results for tasks they do not own (the admin
management view links into the shared run-detail page).
"""
run = await service.get_run(user_id, run_id)
if run is None and await _is_admin(request):
run = await service.admin_get_run(run_id)
return run
async def _enrich_with_creator(tasks: list[dict[str, Any]] | dict[str, Any] | None) -> Any:
"""Attach ``creator_email`` / ``creator_user_id`` to one or many task dicts.
@@ -208,12 +228,48 @@ async def list_scheduled_task_runs(task_id: str, request: Request, limit: int =
async def get_scheduled_task_run(run_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
run = await service.get_run(user_id, run_id)
run = await _load_run(service, request, run_id, user_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
return run
@router.get("/runs/{run_id}/messages")
async def get_scheduled_task_run_messages(run_id: str, request: Request, limit: int = 500) -> dict:
"""Agent messages produced by a scheduled run's underlying agent run.
Lets the run-detail page show what the agent is doing — useful when a run
is stuck in ``running``. Authorization reuses :func:`_load_run` (owner /
subscriber / admin); the event-store query then passes ``user_id=None``
because access has already been checked.
"""
service = get_scheduled_task_service()
user_id = await _user_id(request)
run = await _load_run(service, request, run_id, user_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
agent_run_id = run.get("agent_run_id")
if not agent_run_id:
return {"messages": [], "has_more": False}
# Each scheduled run executes on its own throwaway runtime thread — NOT the
# run row's ``scheduler_thread_id`` — so resolve the real thread from the
# run store before querying its message events.
run_store = get_run_store(request)
agent_run = await run_store.get(agent_run_id, user_id=None)
if agent_run is None:
return {"messages": [], "has_more": False}
capped = max(1, min(limit, 1000))
event_store = get_run_event_store(request)
rows = await event_store.list_messages_by_run(
agent_run["thread_id"],
agent_run_id,
limit=capped + 1,
user_id=None,
)
has_more = len(rows) > capped
return {"messages": rows[:capped] if has_more else rows, "has_more": has_more}
@router.delete("/runs/{run_id}")
async def delete_scheduled_task_run(run_id: str, request: Request) -> dict:
service = get_scheduled_task_service()
@@ -221,7 +277,15 @@ async def delete_scheduled_task_run(run_id: str, request: Request) -> dict:
try:
deleted = await service.delete_run(user_id, run_id)
except PermissionError as exc:
raise HTTPException(status_code=403, detail=str(exc))
# Owner-only for regular users; admins may still delete any run.
if await _is_admin(request):
deleted = await service.admin_delete_run(run_id)
else:
raise HTTPException(status_code=403, detail=str(exc))
# A run the caller does not own returns deleted=False — fall back to an
# admin-scoped delete so admins can clean up other users' runs.
if not deleted and await _is_admin(request):
deleted = await service.admin_delete_run(run_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
return {"success": True}
@@ -231,7 +295,7 @@ async def delete_scheduled_task_run(run_id: str, request: Request) -> dict:
async def get_scheduled_task_run_file(run_id: str, file_index: int, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
run = await service.get_run(user_id, run_id)
run = await _load_run(service, request, run_id, user_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
item, path = _run_file_path(run, file_index)
@@ -279,7 +343,7 @@ async def update_scheduled_task_run_file(
async def download_scheduled_task_run(run_id: str, request: Request):
service = get_scheduled_task_service()
user_id = await _user_id(request)
run = await service.get_run(user_id, run_id)
run = await _load_run(service, request, run_id, user_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
@@ -328,7 +392,7 @@ async def download_scheduled_task_run(run_id: str, request: Request):
async def download_scheduled_task_run_file(run_id: str, file_index: int, request: Request):
service = get_scheduled_task_service()
user_id = await _user_id(request)
run = await service.get_run(user_id, run_id)
run = await _load_run(service, request, run_id, user_id)
if run is None:
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
item, path = _run_file_path(run, file_index)
@@ -361,15 +425,18 @@ async def update_delivery_profile(body: ScheduledTaskDeliveryProfileUpdateReques
async def create_scheduled_task(body: ScheduledTaskCreateRequest, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
task = await service.create_task(
user_id=user_id,
name=body.name,
prompt=body.prompt,
schedule=body.schedule,
timezone=body.timezone,
execution_agent_name=body.execution_agent_name,
execution_context=body.execution_context,
)
try:
task = await service.create_task(
user_id=user_id,
name=body.name,
prompt=body.prompt,
schedule=body.schedule,
timezone=body.timezone,
execution_agent_name=body.execution_agent_name,
execution_context=body.execution_context,
)
except DuplicateTaskNameError:
raise HTTPException(status_code=409, detail="已存在同名定时任务,请换一个名称")
await _enrich_with_creator(task)
return task
@@ -378,7 +445,10 @@ async def create_scheduled_task(body: ScheduledTaskCreateRequest, request: Reque
async def update_scheduled_task(task_id: str, body: ScheduledTaskUpdateRequest, request: Request) -> dict:
service = get_scheduled_task_service()
user_id = await _user_id(request)
task = await service.update_task(user_id, task_id, body.model_dump(exclude_unset=True))
try:
task = await service.update_task(user_id, task_id, body.model_dump(exclude_unset=True))
except DuplicateTaskNameError:
raise HTTPException(status_code=409, detail="已存在同名定时任务,请换一个名称")
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
await _enrich_with_creator(task)
@@ -497,3 +567,85 @@ async def delete_scheduled_task_subscription(task_id: str, request: Request) ->
if not deleted:
raise HTTPException(status_code=404, detail="Subscription not found")
return {"success": True}
# --- Admin (cross-user management) ---
async def _require_admin(request: Request) -> None:
"""Reject non-admins. Lets the call through when auth is disabled."""
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="定时任务管理仅限管理员")
@router.get("/admin/all")
async def admin_list_scheduled_tasks(
request: Request,
search: str | None = Query(default=None, description="Case-insensitive fuzzy match on task name"),
) -> list[dict]:
"""List every scheduled task across all users. Admin-only."""
await _require_admin(request)
service = get_scheduled_task_service()
tasks = await service.admin_list_tasks()
await _enrich_with_creator(tasks)
tag_store = get_tag_store(request)
assignments = await tag_store.list_assignments("scheduled_task", [str(t.get("task_id")) for t in tasks])
for task in tasks:
task["tags"] = assignments.get(str(task.get("task_id")), [])
if search:
needle = search.strip().lower()
tasks = [t for t in tasks if needle in str(t.get("name", "")).lower()]
return tasks
@router.get("/admin/{task_id}/runs")
async def admin_list_scheduled_task_runs(task_id: str, request: Request, limit: int = 50) -> list[dict]:
"""List runs for any task regardless of owner. Admin-only."""
await _require_admin(request)
service = get_scheduled_task_service()
return await service.admin_list_runs(task_id, limit=max(1, min(limit, 200)))
@router.patch("/admin/{task_id}")
async def admin_update_scheduled_task(task_id: str, body: ScheduledTaskUpdateRequest, request: Request) -> dict:
"""Update any task regardless of owner. Admin-only."""
await _require_admin(request)
service = get_scheduled_task_service()
try:
task = await service.admin_update_task(task_id, body.model_dump(exclude_unset=True))
except DuplicateTaskNameError:
raise HTTPException(status_code=409, detail="已存在同名定时任务,请换一个名称")
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
await _enrich_with_creator(task)
return task
@router.delete("/admin/{task_id}")
async def admin_delete_scheduled_task(task_id: str, request: Request) -> dict:
"""Delete any task regardless of owner. Admin-only."""
await _require_admin(request)
service = get_scheduled_task_service()
deleted = await service.admin_delete_task(task_id)
if not deleted:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
try:
await get_tag_store(request).unassign_all("scheduled_task", task_id)
except Exception: # noqa: BLE001 — tag cleanup must not block deletion
pass
return {"success": True}
@router.post("/admin/{task_id}/trigger")
async def admin_trigger_scheduled_task(task_id: str, request: Request) -> dict:
"""Trigger an immediate run of any task regardless of owner. Admin-only."""
await _require_admin(request)
service = get_scheduled_task_service()
task = await service.admin_trigger_task(task_id)
if task is None:
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
return {"success": True, "task": task}
@@ -45,6 +45,24 @@ def _is_admin_user(request: Request) -> bool:
return getattr(user, "system_role", None) == "admin"
def _can_view_skill_source(
category: SkillCategory,
owner_user_id: str | None,
user_id: str,
*,
is_admin: bool,
) -> bool:
"""Whether the raw SKILL.md source is visible to a user.
Admins may view any skill's source; otherwise only the publisher (the
owner of a custom skill) qualifies. Built-in / public skills have no human
owner, so for non-admins they are never source-visible.
"""
if is_admin:
return True
return category == SkillCategory.CUSTOM and owner_user_id is not None and owner_user_id == user_id
async def _get_owned_skill_or_raise(store: SkillStore, name: str, user_id: str) -> dict[str, Any]:
owned = await store.get_owned(name, user_id)
if owned is not None:
@@ -870,15 +888,27 @@ class SkillContentResponse(BaseModel):
"/skills/{skill_name}/content",
response_model=SkillContentResponse,
summary="Get Skill Markdown Content",
description="Retrieve the raw SKILL.md content of a specific skill (public or custom).",
description="Retrieve the raw SKILL.md content of a skill. Only an admin or the skill's publisher (owner of a custom skill) may read it; other users see only the description and detail.",
)
async def get_skill_content(skill_name: str, config: AppConfig = Depends(get_config)) -> SkillContentResponse:
async def get_skill_content(
skill_name: str,
request: Request,
config: AppConfig = Depends(get_config),
skill_store: SkillStore = Depends(get_skill_store),
) -> SkillContentResponse:
try:
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
skill = next((s for s in skills if s.name == skill_name), None)
if skill is None:
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
# The raw SKILL.md is implementation detail — only an admin or the
# publisher (owner of a custom skill) may view it. Everyone else is
# limited to the description + detail surfaced by the list/detail APIs.
record = await skill_store.get_any(skill_name)
owner_user_id = record.get("owner_user_id") if record else None
if not _can_view_skill_source(skill.category, owner_user_id, _current_user_id(request), is_admin=_is_admin_user(request)):
raise HTTPException(status_code=403, detail="Only the skill publisher or an admin can view the SKILL.md source")
content = skill.skill_file.read_text(encoding="utf-8")
return SkillContentResponse(name=skill.name, category=skill.category, content=content)
except HTTPException:
@@ -0,0 +1,340 @@
"""Conversation sharing API.
A user creates a share code for *one* of their conversations; redeeming the
code copies that conversation into the redeemer's account as an independent
copy they fully own and can keep chatting in. See ``app/gateway/thread_copy.py``
for the checkpoint duplication.
"""
from __future__ import annotations
import logging
import mimetypes
import shutil
import uuid
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from app.gateway.deps import get_checkpointer, get_thread_share_store, get_thread_store
from app.gateway.thread_copy import copy_thread_state
from deerflow.config.paths import get_paths
from deerflow.persistence.thread_meta.base import ThreadMetaStore
from deerflow.persistence.thread_shares.base import ThreadShareStore
from deerflow.runtime import serialize_channel_values
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/thread-shares", tags=["thread-shares"])
# Public, unauthenticated router. Its prefix is whitelisted in
# ``auth_middleware._PUBLIC_PATH_PREFIXES`` — only mount read-only endpoints
# that are intentionally world-readable here.
public_router = APIRouter(prefix="/api/public/shares", tags=["thread-shares-public"])
_UNTITLED = "未命名对话"
# Valid share modes. "import" copies the conversation into the recipient's
# account; "view" is a public read-only static page anyone can open.
_SHARE_MODES = ("import", "view")
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
# ---------------------------------------------------------------------------
# Request / response models
# ---------------------------------------------------------------------------
class CreateShareRequest(BaseModel):
thread_id: str = Field(..., description="The conversation to share")
mode: str = Field(
default="import",
description='"import" — recipient copies the conversation; "view" — public read-only page',
)
class ShareResponse(BaseModel):
share_code: str = Field(..., description="Short code that recipients enter to import")
owner_user_id: str = Field(..., description="User who created the share")
thread_id: str = Field(..., description="The shared conversation")
revoked: bool = Field(default=False, description="Whether the code has been revoked")
mode: str = Field(default="import", description='"import" or "view"')
created_at: str | None = Field(default=None, description="Creation timestamp (ISO-8601)")
class PublicShareSnapshotResponse(BaseModel):
"""Read-only conversation snapshot served to anonymous viewers."""
share_code: str
title: str = Field(..., description="Conversation title")
mode: str = Field(default="view")
messages: list[dict] = Field(default_factory=list, description="Serialized conversation messages")
artifacts: list[str] = Field(default_factory=list, description="Artifact virtual paths produced in the conversation")
created_at: str | None = Field(default=None, description="When the share was created (ISO-8601)")
class SharesListResponse(BaseModel):
shares: list[ShareResponse]
class SharePreviewResponse(BaseModel):
share_code: str
shared_by: str = Field(..., description="Owner user id of the shared conversation")
title: str = Field(..., description="Title of the shared conversation")
class ImportResultResponse(BaseModel):
imported: int = Field(..., description="1 when the conversation was copied, else 0")
skipped: int = Field(..., description="1 when it was already imported, else 0")
# ---------------------------------------------------------------------------
# Owner side — create / list / revoke
# ---------------------------------------------------------------------------
@router.post("", response_model=ShareResponse, summary="Create a share code for one conversation")
async def create_share(
body: CreateShareRequest,
request: Request,
share_store: ThreadShareStore = Depends(get_thread_share_store),
thread_store: ThreadMetaStore = Depends(get_thread_store),
) -> ShareResponse:
me = _current_user_id(request)
mode = body.mode if body.mode in _SHARE_MODES else "import"
# Only the conversation's owner may share it.
meta = await thread_store.get(body.thread_id, user_id=me)
if meta is None:
raise HTTPException(status_code=404, detail="Conversation not found")
# Reuse an existing active code (of the same mode) so re-sharing is idempotent.
existing = await share_store.get_active_for_thread(me, body.thread_id, mode=mode)
if existing is not None:
return ShareResponse(**existing)
record = await share_store.create(me, body.thread_id, mode=mode)
return ShareResponse(**record)
@router.get("", response_model=SharesListResponse, summary="List my conversation share codes")
async def list_shares(
request: Request,
share_store: ThreadShareStore = Depends(get_thread_share_store),
) -> SharesListResponse:
records = await share_store.list_for_owner(_current_user_id(request))
return SharesListResponse(shares=[ShareResponse(**r) for r in records])
@router.delete("/{share_code}", summary="Revoke a conversation share code")
async def revoke_share(
share_code: str,
request: Request,
share_store: ThreadShareStore = Depends(get_thread_share_store),
) -> dict[str, bool]:
ok = await share_store.revoke(share_code, _current_user_id(request))
if not ok:
raise HTTPException(status_code=404, detail="Share code not found")
return {"success": True}
# ---------------------------------------------------------------------------
# Recipient side — preview / import
# ---------------------------------------------------------------------------
async def _resolve_active_share(share_code: str, share_store: ThreadShareStore) -> dict:
share = await share_store.get(share_code)
if share is None:
raise HTTPException(status_code=404, detail="Share code not found")
if share.get("revoked"):
raise HTTPException(status_code=410, detail="This share code has been revoked")
return share
@router.get("/{share_code}/preview", response_model=SharePreviewResponse, summary="Preview a shared conversation")
async def preview_share(
share_code: str,
share_store: ThreadShareStore = Depends(get_thread_share_store),
thread_store: ThreadMetaStore = Depends(get_thread_store),
) -> SharePreviewResponse:
share = await _resolve_active_share(share_code, share_store)
owner = share["owner_user_id"]
meta = await thread_store.get(share["thread_id"], user_id=owner)
if meta is None:
raise HTTPException(status_code=404, detail="The shared conversation no longer exists")
return SharePreviewResponse(
share_code=share_code,
shared_by=owner,
title=meta.get("display_name") or _UNTITLED,
)
def _copy_thread_files(origin_thread_id: str, owner_user_id: str, new_thread_id: str, recipient_user_id: str) -> None:
"""Copy a thread's user-data directory (uploads + outputs/artifacts)."""
paths = get_paths()
src_data = paths.thread_dir(origin_thread_id, user_id=owner_user_id) / "user-data"
if not src_data.is_dir():
return
dst_data = paths.thread_dir(new_thread_id, user_id=recipient_user_id) / "user-data"
shutil.copytree(src_data, dst_data, dirs_exist_ok=True)
@router.post("/{share_code}/import", response_model=ImportResultResponse, summary="Import a shared conversation")
async def import_share(
share_code: str,
request: Request,
share_store: ThreadShareStore = Depends(get_thread_share_store),
thread_store: ThreadMetaStore = Depends(get_thread_store),
) -> ImportResultResponse:
share = await _resolve_active_share(share_code, share_store)
if share.get("mode") == "view":
raise HTTPException(status_code=400, detail="This is a view-only share — open its link to read it instead of importing")
owner = share["owner_user_id"]
origin_id = share["thread_id"]
recipient = _current_user_id(request)
if owner == recipient:
raise HTTPException(status_code=400, detail="You cannot import your own share code")
src = await thread_store.get(origin_id, user_id=owner)
if src is None:
raise HTTPException(status_code=404, detail="The shared conversation no longer exists")
# Dedup: skip if this user already imported this conversation.
mine = await thread_store.search(user_id=recipient, limit=2000)
already = {(t.get("metadata") or {}).get("shared_origin") for t in mine}
if origin_id in already:
return ImportResultResponse(imported=0, skipped=1)
new_id = str(uuid.uuid4())
checkpointer = get_checkpointer(request)
await copy_thread_state(checkpointer, origin_id, new_id)
metadata = dict(src.get("metadata") or {})
metadata.update({"shared_from_code": share_code, "shared_origin": origin_id, "shared_by": owner})
await thread_store.create(
new_id,
assistant_id=src.get("assistant_id"),
user_id=recipient,
display_name=src.get("display_name"),
metadata=metadata,
)
try:
_copy_thread_files(origin_id, owner, new_id, recipient)
except Exception as exc: # noqa: BLE001 — file copy failure must not lose the conversation
logger.warning("Failed to copy files for thread %s -> %s: %s", origin_id, new_id, exc)
logger.info("Share %s imported by %s as thread %s", share_code, recipient, new_id)
return ImportResultResponse(imported=1, skipped=0)
# ---------------------------------------------------------------------------
# Public read-only view — no authentication required
# ---------------------------------------------------------------------------
async def _load_thread_snapshot(checkpointer, thread_id: str) -> tuple[list[dict], list[str]]:
"""Return (messages, artifacts) from a thread's latest checkpoint.
Reads straight from the checkpointer — the same source the live chat
streams from — so the static snapshot matches the conversation exactly.
"""
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
checkpoint_tuple = await checkpointer.aget_tuple(config)
if checkpoint_tuple is None:
return [], []
checkpoint = getattr(checkpoint_tuple, "checkpoint", {}) or {}
values = serialize_channel_values(checkpoint.get("channel_values", {}) or {})
messages = values.get("messages")
artifacts = values.get("artifacts")
return (
[m for m in messages if isinstance(m, dict)] if isinstance(messages, list) else [],
[a for a in artifacts if isinstance(a, str)] if isinstance(artifacts, list) else [],
)
@public_router.get(
"/{share_code}",
response_model=PublicShareSnapshotResponse,
summary="Public read-only snapshot of a view-shared conversation",
)
async def get_public_share_snapshot(
share_code: str,
share_store: ThreadShareStore = Depends(get_thread_share_store),
thread_store: ThreadMetaStore = Depends(get_thread_store),
checkpointer=Depends(get_checkpointer),
) -> PublicShareSnapshotResponse:
"""Serve a frozen, read-only conversation. Anyone with the link can read it.
Only ``mode="view"`` shares are exposed; missing, revoked, or import-mode
codes all return 404 so the endpoint never reveals which case applies.
"""
share = await share_store.get(share_code)
if share is None or share.get("revoked") or share.get("mode") != "view":
raise HTTPException(status_code=404, detail="Share not found")
meta = await thread_store.get(share["thread_id"], user_id=share["owner_user_id"])
if meta is None:
raise HTTPException(status_code=404, detail="The shared conversation no longer exists")
messages, artifacts = await _load_thread_snapshot(checkpointer, share["thread_id"])
return PublicShareSnapshotResponse(
share_code=share_code,
title=meta.get("display_name") or _UNTITLED,
mode="view",
messages=messages,
artifacts=artifacts,
created_at=share.get("created_at"),
)
@public_router.get(
"/{share_code}/files/{path:path}",
summary="Public image attachment from a view-shared conversation",
)
async def get_public_share_file(
share_code: str,
path: str,
share_store: ThreadShareStore = Depends(get_thread_share_store),
) -> FileResponse:
"""Serve an image referenced by a view-shared conversation.
Restricted to images only — the public share page renders inline images,
but the endpoint must not become a way to read arbitrary thread files.
Non-image paths return 404 just like a missing file.
"""
share = await share_store.get(share_code)
if share is None or share.get("revoked") or share.get("mode") != "view":
raise HTTPException(status_code=404, detail="Share not found")
# Resolve the virtual path inside the *owner's* thread directory.
try:
actual = get_paths().resolve_virtual_path(share["thread_id"], path, user_id=share["owner_user_id"])
except ValueError as exc:
status = 403 if "traversal" in str(exc) else 400
raise HTTPException(status_code=status, detail=str(exc))
actual_path = Path(actual)
if not actual_path.exists() or not actual_path.is_file():
raise HTTPException(status_code=404, detail="File not found")
mime_type, _ = mimetypes.guess_type(actual_path.name)
if not mime_type or not mime_type.startswith("image/"):
# Only images are public; everything else is treated as not found.
raise HTTPException(status_code=404, detail="File not found")
# SVG can carry scripts — serve it as a download so it can never execute
# as a top-level document in the app origin. Raster images render inline.
is_svg = mime_type == "image/svg+xml"
return FileResponse(
actual_path,
media_type=mime_type,
content_disposition_type="attachment" if is_svg else "inline",
headers={"Cache-Control": "public, max-age=300"},
)
@@ -0,0 +1,67 @@
"""Copy a conversation's LangGraph checkpoint state from one thread to another.
Used by the conversation-sharing feature: redeeming a share link duplicates the
sharer's threads into the redeemer's account. Each duplicated thread needs its
full checkpoint history copied under a fresh ``thread_id``.
The copy goes through the checkpointer's public API (``alist`` / ``aput`` /
``aput_writes``) rather than raw SQL, so it is independent of the checkpoint
table schema and works for every backend (SQLite, Postgres, in-memory).
"""
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
async def copy_thread_state(checkpointer, src_thread_id: str, dst_thread_id: str) -> bool:
"""Duplicate every checkpoint of ``src_thread_id`` under ``dst_thread_id``.
Returns True when at least one checkpoint was copied, False when the source
thread has no checkpoints (e.g. a thread that was never run).
The destination keeps the source's checkpoint ids — they only need to be
unique within a ``thread_id``, and reusing them preserves the parent chain.
"""
src_config = {"configurable": {"thread_id": src_thread_id}}
# alist yields newest-first; reverse so parents are written before children.
tuples = [t async for t in checkpointer.alist(src_config)]
if not tuples:
return False
copied = 0
for tup in reversed(tuples):
configurable = (tup.config or {}).get("configurable", {})
checkpoint_ns = configurable.get("checkpoint_ns", "") or ""
parent_id = ((tup.parent_config or {}).get("configurable", {}) or {}).get("checkpoint_id")
put_config: dict = {"configurable": {"thread_id": dst_thread_id, "checkpoint_ns": checkpoint_ns}}
if parent_id:
# aput reads the parent checkpoint id from the incoming config.
put_config["configurable"]["checkpoint_id"] = parent_id
new_versions = tup.checkpoint.get("channel_versions", {}) or {}
await checkpointer.aput(put_config, tup.checkpoint, tup.metadata or {}, new_versions)
# pending_writes is a list of (task_id, channel, value); aput_writes
# wants them grouped per task as (channel, value) pairs.
if tup.pending_writes:
by_task: dict[str, list] = {}
for task_id, channel, value in tup.pending_writes:
by_task.setdefault(task_id, []).append((channel, value))
checkpoint_id = tup.checkpoint.get("id")
for task_id, writes in by_task.items():
writes_config = {
"configurable": {
"thread_id": dst_thread_id,
"checkpoint_ns": checkpoint_ns,
"checkpoint_id": checkpoint_id,
}
}
await checkpointer.aput_writes(writes_config, writes, task_id)
copied += 1
logger.info("Copied %d checkpoint(s) from thread %s to %s", copied, src_thread_id, dst_thread_id)
return copied > 0
@@ -43,9 +43,27 @@ logger = logging.getLogger(__name__)
DEFAULT_IMAGE = "enterprise-public-cn-beijing.cr.volces.com/vefaas-public/all-in-one-sandbox:latest"
DEFAULT_PORT = 8080
DEFAULT_CONTAINER_PREFIX = "deer-flow-sandbox"
DEFAULT_IDLE_TIMEOUT = 600 # 10 minutes in seconds
DEFAULT_REPLICAS = 3 # Maximum concurrent sandbox containers
IDLE_CHECK_INTERVAL = 60 # Check every 60 seconds
DEFAULT_IDLE_TIMEOUT = 600 # 10 minutes in seconds
DEFAULT_REPLICAS = 3 # Maximum concurrent sandbox containers
IDLE_CHECK_INTERVAL = 60 # Check every 60 seconds
PACKAGE_MANAGER_ENV_KEYS = (
"PIP_INDEX_URL",
"PIP_EXTRA_INDEX_URL",
"PIP_TRUSTED_HOST",
"PIP_CERT",
"PIP_CLIENT_CERT",
"UV_INDEX_URL",
"UV_EXTRA_INDEX_URL",
"UV_DEFAULT_INDEX",
"UV_INDEX",
"UV_TRUSTED_HOST",
"HTTP_PROXY",
"HTTPS_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"no_proxy",
)
def _lock_file_exclusive(lock_file) -> None:
@@ -142,9 +160,9 @@ class AioSandboxProvider(SandboxProvider):
Local provider manages container lifecycle directly (start/stop).
"""
provisioner_url = self._config.get("provisioner_url")
if provisioner_url:
logger.info(f"Using remote sandbox backend with provisioner at {provisioner_url}")
return RemoteSandboxBackend(provisioner_url=provisioner_url)
if provisioner_url:
logger.info(f"Using remote sandbox backend with provisioner at {provisioner_url}")
return RemoteSandboxBackend(provisioner_url=provisioner_url, environment=self._config["environment"])
logger.info("Using local container sandbox backend")
return LocalContainerBackend(
@@ -172,20 +190,24 @@ class AioSandboxProvider(SandboxProvider):
"idle_timeout": idle_timeout if idle_timeout is not None else DEFAULT_IDLE_TIMEOUT,
"replicas": replicas if replicas is not None else DEFAULT_REPLICAS,
"mounts": sandbox_config.mounts or [],
"environment": self._resolve_env_vars(sandbox_config.environment or {}),
"environment": self._resolve_env_vars(sandbox_config.environment or {}),
# provisioner URL for dynamic pod management (e.g. http://provisioner:8002)
"provisioner_url": getattr(sandbox_config, "provisioner_url", None) or "",
}
@staticmethod
def _resolve_env_vars(env_config: dict[str, str]) -> dict[str, str]:
"""Resolve environment variable references (values starting with $)."""
resolved = {}
for key, value in env_config.items():
if isinstance(value, str) and value.startswith("$"):
env_name = value[1:]
resolved[key] = os.environ.get(env_name, "")
else:
def _resolve_env_vars(env_config: dict[str, str]) -> dict[str, str]:
"""Resolve environment variable references (values starting with $)."""
resolved = {}
for key in PACKAGE_MANAGER_ENV_KEYS:
value = os.environ.get(key)
if value:
resolved[key] = value
for key, value in env_config.items():
if isinstance(value, str) and value.startswith("$"):
env_name = value[1:]
resolved[key] = os.environ.get(env_name, "")
else:
resolved[key] = str(value)
return resolved
@@ -40,14 +40,17 @@ class RemoteSandboxBackend(SandboxBackend):
provisioner_url: http://provisioner:8002
"""
def __init__(self, provisioner_url: str):
"""Initialize with the provisioner service URL.
Args:
provisioner_url: URL of the provisioner service
(e.g., ``http://provisioner:8002``).
"""
self._provisioner_url = provisioner_url.rstrip("/")
def __init__(self, provisioner_url: str, environment: dict[str, str] | None = None):
"""Initialize with the provisioner service URL.
Args:
provisioner_url: URL of the provisioner service
(e.g., ``http://provisioner:8002``).
environment: Environment variables for sandbox pods. The remote
provisioner may ignore this field if it does not support it.
"""
self._provisioner_url = provisioner_url.rstrip("/")
self._environment = environment or {}
@property
def provisioner_url(self) -> str:
@@ -91,10 +94,11 @@ class RemoteSandboxBackend(SandboxBackend):
try:
resp = requests.post(
f"{self._provisioner_url}/api/sandboxes",
json={
"sandbox_id": sandbox_id,
"thread_id": thread_id,
},
json={
"sandbox_id": sandbox_id,
"thread_id": thread_id,
"environment": self._environment,
},
timeout=30,
)
resp.raise_for_status()
@@ -11,8 +11,13 @@ blocking, making a unified file safe for both workloads. Writers
that contend for the lock wait via the default 5-second sqlite3
busy timeout rather than failing immediately.
Postgres mode: both use the same database URL but maintain independent
connection pools with different lifecycles.
Postgres mode: both use the same database URL but maintain independent
connection pools with different lifecycles.
MySQL mode: application ORM data uses MySQL. LangGraph's checkpointer does
not have a built-in MySQL backend in this project, so deployments that choose
MySQL for business tables should configure the legacy ``checkpointer:``
section separately (usually sqlite) to preserve conversation checkpoints.
Memory mode: checkpointer uses MemorySaver, app uses in-memory stores.
No database is initialized.
@@ -21,12 +26,12 @@ Sensitive values (postgres_url) should use $VAR syntax in config.yaml
to reference environment variables from .env:
database:
backend: postgres
postgres_url: $DATABASE_URL
The $VAR resolution is handled by AppConfig.resolve_env_variables()
before this config is instantiated -- DatabaseConfig itself does not
need to do any environment variable processing.
backend: postgres
postgres_url: $DATABASE_URL
The $VAR resolution is handled by AppConfig.resolve_env_variables()
before this config is instantiated -- DatabaseConfig itself does not
need to do any environment variable processing.
"""
from __future__ import annotations
@@ -37,31 +42,40 @@ from typing import Literal
from pydantic import BaseModel, Field
class DatabaseConfig(BaseModel):
backend: Literal["memory", "sqlite", "postgres"] = Field(
default="memory",
description=("Storage backend for both checkpointer and application data. 'memory' for development (no persistence across restarts), 'sqlite' for single-node deployment, 'postgres' for production multi-node deployment."),
)
class DatabaseConfig(BaseModel):
backend: Literal["memory", "sqlite", "postgres", "mysql"] = Field(
default="memory",
description=("Storage backend for application data. 'memory' for development (no persistence across restarts), 'sqlite' for single-node deployment, 'postgres' for production multi-node deployment, 'mysql' for MySQL-backed business tables."),
)
sqlite_dir: str = Field(
default=".deer-flow/data",
description=("Directory for the SQLite database file. Both checkpointer and application data share {sqlite_dir}/deerflow.db."),
)
postgres_url: str = Field(
default="",
description=(
"PostgreSQL connection URL, shared by checkpointer and app. "
postgres_url: str = Field(
default="",
description=(
"PostgreSQL connection URL, shared by checkpointer and app. "
"Use $DATABASE_URL in config.yaml to reference .env. "
"Example: postgresql://user:pass@host:5432/deerflow "
"(the +asyncpg driver suffix is added automatically where needed)."
),
)
),
)
mysql_url: str = Field(
default="",
description=(
"MySQL connection URL for the application ORM. "
"Use $MYSQL_DATABASE_URL in config.yaml to reference .env. "
"Example: mysql+asyncmy://user:pass@host:3306/deerflow?charset=utf8mb4. "
"If mysql:// is provided, the +asyncmy driver suffix is added automatically."
),
)
echo_sql: bool = Field(
default=False,
description="Echo all SQL statements to log (debug only).",
)
pool_size: int = Field(
default=5,
description="Connection pool size for the app ORM engine (postgres only).",
description="Connection pool size for the app ORM engine (postgres/mysql only).",
)
# -- Derived helpers (not user-configured) --
@@ -94,9 +108,16 @@ class DatabaseConfig(BaseModel):
"""SQLAlchemy async URL for the application ORM engine."""
if self.backend == "sqlite":
return f"sqlite+aiosqlite:///{self.sqlite_path}"
if self.backend == "postgres":
url = self.postgres_url
if url.startswith("postgresql://"):
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
return url
raise ValueError(f"No SQLAlchemy URL for backend={self.backend!r}")
if self.backend == "postgres":
url = self.postgres_url
if url.startswith("postgresql://"):
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
return url
if self.backend == "mysql":
url = self.mysql_url
if not url:
raise ValueError("database.mysql_url is required for backend='mysql'")
if url.startswith("mysql://"):
url = url.replace("mysql://", "mysql+asyncmy://", 1)
return url
raise ValueError(f"No SQLAlchemy URL for backend={self.backend!r}")
@@ -4,10 +4,11 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, DateTime, String
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class AgentRow(Base):
@@ -16,9 +17,9 @@ class AgentRow(Base):
# Stored as a plain string so the same id can be used as the filesystem
# directory name under {base_dir}/agents/{id}/.
id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(256), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(191), nullable=False, index=True)
description: Mapped[str] = mapped_column(String(1024), nullable=False, default="")
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
published: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -10,10 +10,13 @@ None and fall back to in-memory implementations.
from __future__ import annotations
import json
import logging
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
import json
import logging
import sys
from importlib.util import find_spec
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
def _json_serializer(obj: object) -> str:
@@ -23,11 +26,37 @@ def _json_serializer(obj: object) -> str:
logger = logging.getLogger(__name__)
_engine: AsyncEngine | None = None
_session_factory: async_sessionmaker[AsyncSession] | None = None
async def _auto_create_postgres_db(url: str) -> None:
_engine: AsyncEngine | None = None
_session_factory: async_sessionmaker[AsyncSession] | None = None
def _add_runtime_vendor_path() -> None:
"""Allow offline deployments to place optional DB drivers in DEER_FLOW_HOME.
The offline Docker image keeps the Python environment inside the image, while
``/app/backend/.deer-flow`` is a persistent bind mount. The MySQL switch
script installs ``asyncmy`` into ``{DEER_FLOW_HOME}/python-vendor`` so the
unchanged startup script can still import it after container recreation.
"""
try:
from deerflow.config.runtime_paths import runtime_home
vendor_dir = runtime_home() / "python-vendor"
except Exception:
vendor_dir = Path(".deer-flow/python-vendor").resolve()
vendor = str(vendor_dir)
if vendor_dir.exists() and vendor not in sys.path:
sys.path.insert(0, vendor)
def _mysql_driver_name(url: str) -> str:
if url.startswith("mysql+aiomysql://"):
return "aiomysql"
return "asyncmy"
async def _auto_create_postgres_db(url: str) -> None:
"""Connect to the ``postgres`` maintenance DB and CREATE DATABASE.
The target database name is extracted from *url*. The connection is
@@ -65,8 +94,8 @@ async def init_engine(
"""Create the async engine and session factory, then auto-create tables.
Args:
backend: "memory", "sqlite", or "postgres".
url: SQLAlchemy async URL (for sqlite/postgres).
backend: "memory", "sqlite", "postgres", or "mysql".
url: SQLAlchemy async URL (for sqlite/postgres/mysql).
echo: Echo SQL to log.
pool_size: Postgres connection pool size.
sqlite_dir: Directory to create for SQLite (ensured to exist).
@@ -77,11 +106,20 @@ async def init_engine(
logger.info("Persistence backend=memory -- ORM engine not initialized")
return
if backend == "postgres":
try:
import asyncpg # noqa: F401
except ImportError:
raise ImportError("database.backend is set to 'postgres' but asyncpg is not installed.\nInstall it with:\n uv sync --extra postgres\nOr switch to backend: sqlite in config.yaml for single-node deployment.") from None
if backend == "postgres":
try:
import asyncpg # noqa: F401
except ImportError:
raise ImportError("database.backend is set to 'postgres' but asyncpg is not installed.\nInstall it with:\n uv sync --extra postgres\nOr switch to backend: sqlite in config.yaml for single-node deployment.") from None
if backend == "mysql":
_add_runtime_vendor_path()
driver = _mysql_driver_name(url)
if find_spec(driver) is None:
raise ImportError(
"database.backend is set to 'mysql' but the MySQL async driver is not installed.\n"
"Install asyncmy, or run switch_database_to_mysql.sh so it can install asyncmy into "
f"{Path('.deer-flow/python-vendor')} from the offline wheelhouse."
)
if backend == "sqlite":
import os
@@ -112,16 +150,25 @@ async def init_engine(
cursor.execute("PRAGMA foreign_keys=ON;")
finally:
cursor.close()
elif backend == "postgres":
_engine = create_async_engine(
url,
echo=echo,
pool_size=pool_size,
pool_pre_ping=True,
json_serializer=_json_serializer,
)
else:
raise ValueError(f"Unknown persistence backend: {backend!r}")
elif backend == "postgres":
_engine = create_async_engine(
url,
echo=echo,
pool_size=pool_size,
pool_pre_ping=True,
json_serializer=_json_serializer,
)
elif backend == "mysql":
_engine = create_async_engine(
url,
echo=echo,
pool_size=pool_size,
pool_pre_ping=True,
pool_recycle=1800,
json_serializer=_json_serializer,
)
else:
raise ValueError(f"Unknown persistence backend: {backend!r}")
_session_factory = async_sessionmaker(_engine, expire_on_commit=False)
@@ -162,10 +209,10 @@ async def init_engine_from_config(config) -> None:
await init_engine("memory")
return
await init_engine(
backend=config.backend,
url=config.app_sqlalchemy_url,
echo=config.echo_sql,
pool_size=config.pool_size,
backend=config.backend,
url=config.app_sqlalchemy_url,
echo=config.echo_sql,
pool_size=config.pool_size,
sqlite_dir=config.sqlite_dir if config.backend == "sqlite" else "",
)
@@ -4,10 +4,11 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import DateTime, String, Text, UniqueConstraint
from sqlalchemy import String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class FeedbackRow(Base):
@@ -29,4 +30,4 @@ class FeedbackRow(Base):
comment: Mapped[str | None] = mapped_column(Text)
# Optional text feedback from the user
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
@@ -4,10 +4,11 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import DateTime, Float, Integer, String, Text
from sqlalchemy import Float, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class LlmCallMetricRow(Base):
@@ -18,7 +19,7 @@ class LlmCallMetricRow(Base):
id: Mapped[str] = mapped_column(String(64), primary_key=True)
# Wall-clock time the LLM call ended. Indexed because admin views always
# sort/filter by recency.
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), index=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), index=True)
user_id: Mapped[str | None] = mapped_column(String(64), index=True)
thread_id: Mapped[str | None] = mapped_column(String(64), index=True)
@@ -44,12 +44,13 @@ def run_migrations_offline() -> None:
context.run_migrations()
def do_run_migrations(connection):
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=True, # Required for SQLite ALTER TABLE support
)
def do_run_migrations(connection):
render_as_batch = connection.dialect.name == "sqlite"
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=render_as_batch, # Required for SQLite ALTER TABLE support
)
with context.begin_transaction():
context.run_migrations()
@@ -7,9 +7,11 @@ Create Date: 2026-05-12
from __future__ import annotations
from alembic import op
from sqlalchemy import inspect
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
import sqlalchemy as sa
from deerflow.persistence.types import PortableJSON
revision = "20260512_01"
@@ -23,9 +25,9 @@ def _has_column(table_name: str, column_name: str) -> bool:
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
def upgrade() -> None:
if not _has_column("scheduled_task_runs", "result_json"):
op.add_column("scheduled_task_runs", sa.Column("result_json", sa.JSON(), nullable=True))
def upgrade() -> None:
if not _has_column("scheduled_task_runs", "result_json"):
op.add_column("scheduled_task_runs", sa.Column("result_json", PortableJSON(), nullable=True))
def downgrade() -> None:
@@ -30,7 +30,7 @@ def upgrade() -> None:
op.create_table(
"agents",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("name", sa.String(256), nullable=False),
sa.Column("name", sa.String(191), nullable=False),
sa.Column("description", sa.String(1024), nullable=False, server_default=""),
sa.Column("user_id", sa.String(64), nullable=True),
sa.Column("published", sa.Boolean(), nullable=False, server_default=sa.false()),
@@ -28,7 +28,7 @@ def upgrade() -> None:
op.create_table(
"tags",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("name", sa.String(256), nullable=False),
sa.Column("name", sa.String(159), nullable=False),
sa.Column("tag_type", sa.String(32), nullable=False),
sa.Column("color", sa.String(16), nullable=False, server_default="gray"),
sa.Column("description", sa.String(1024), nullable=False, server_default=""),
@@ -76,29 +76,39 @@ def upgrade() -> None:
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
# 预置默认文章类型(幂等,跳过已存在的 key)
# 预置默认文章类型(幂等,跳过已存在的 key)。
# 用 SQLAlchemy Core 构造而非裸 SQL:``key`` 是 MySQL 保留字,裸
# ``SELECT key`` 会触发 1064 语法错误。Core 编译器会按方言自动给保留字
# 加引号(MySQL 反引号 / Postgres 双引号),保持跨后端可移植。
article_types = sa.table(
"ai_writing_article_types",
sa.column("id", sa.String),
sa.column("key", sa.String),
sa.column("label", sa.String),
sa.column("description", sa.Text),
sa.column("prompt_hint", sa.Text),
sa.column("default_word_count", sa.Integer),
sa.column("sort_order", sa.Integer),
sa.column("created_at", sa.DateTime),
sa.column("updated_at", sa.DateTime),
)
conn = op.get_bind()
existing_keys = {row[0] for row in conn.execute(sa.text("SELECT key FROM ai_writing_article_types")).fetchall()}
existing_keys = {row[0] for row in conn.execute(sa.select(article_types.c.key)).fetchall()}
now = datetime.now(UTC)
for t in _DEFAULT_TYPES:
if t["key"] not in existing_keys:
conn.execute(
sa.text(
"INSERT INTO ai_writing_article_types "
"(id, key, label, description, prompt_hint, default_word_count, sort_order, created_at, updated_at) "
"VALUES (:id, :key, :label, :description, :prompt_hint, :default_word_count, :sort_order, :created_at, :updated_at)"
),
{
"id": str(uuid.uuid4()),
"key": t["key"],
"label": t["label"],
"description": t["description"],
"prompt_hint": t["prompt_hint"],
"default_word_count": t["default_word_count"],
"sort_order": t["sort_order"],
"created_at": now,
"updated_at": now,
},
article_types.insert().values(
id=str(uuid.uuid4()),
key=t["key"],
label=t["label"],
description=t["description"],
prompt_hint=t["prompt_hint"],
default_word_count=t["default_word_count"],
sort_order=t["sort_order"],
created_at=now,
updated_at=now,
)
)
@@ -0,0 +1,53 @@
"""Create thread_shares table (per-conversation share links).
Revision ID: 20260518_02
Revises: 20260518_01
Create Date: 2026-05-18
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260518_02"
down_revision = "20260518_01"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def upgrade() -> None:
if not _has_table("thread_shares"):
op.create_table(
"thread_shares",
sa.Column("share_code", sa.String(32), primary_key=True),
sa.Column("owner_user_id", sa.String(64), nullable=False),
sa.Column("thread_id", sa.String(64), nullable=False),
sa.Column("revoked", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_thread_shares_owner_user_id", "thread_shares", ["owner_user_id"])
op.create_index("ix_thread_shares_thread_id", "thread_shares", ["thread_id"])
elif not _has_column("thread_shares", "thread_id"):
# An earlier all-conversations variant of this table — add the column.
op.add_column("thread_shares", sa.Column("thread_id", sa.String(64), nullable=False, server_default=""))
op.create_index("ix_thread_shares_thread_id", "thread_shares", ["thread_id"])
def downgrade() -> None:
if _has_table("thread_shares"):
op.drop_table("thread_shares")
@@ -0,0 +1,57 @@
"""Ensure thread_shares.thread_id exists (per-conversation share links).
Standalone migration so databases that already applied an earlier
all-conversations variant of ``20260518_02`` (which had no ``thread_id``
column) get the column added. Idempotent — a no-op when the column is
already present.
Revision ID: 20260518_03
Revises: 20260518_02
Create Date: 2026-05-18
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260518_03"
down_revision = "20260518_02"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def _has_column(table_name: str, column_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(col["name"] == column_name for col in inspector.get_columns(table_name))
def _has_index(table_name: str, index_name: str) -> bool:
if not _has_table(table_name):
return False
inspector = inspect(op.get_bind())
return any(ix["name"] == index_name for ix in inspector.get_indexes(table_name))
def upgrade() -> None:
if not _has_table("thread_shares"):
# Fresh database — 20260518_02 already created the table with thread_id.
return
if not _has_column("thread_shares", "thread_id"):
op.add_column("thread_shares", sa.Column("thread_id", sa.String(64), nullable=False, server_default=""))
if not _has_index("thread_shares", "ix_thread_shares_thread_id"):
op.create_index("ix_thread_shares_thread_id", "thread_shares", ["thread_id"])
def downgrade() -> None:
# Leave the column in place — dropping it would break the share feature.
pass
@@ -0,0 +1,39 @@
"""Add retry_count to scheduled_task_runs.
Tracks how many recovery attempts a run has consumed so the scheduler can
cap retries on stuck/timed-out runs.
Revision ID: 20260518_04
Revises: 20260518_03
Create Date: 2026-05-18
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260518_04"
down_revision = "20260518_03"
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_task_runs", "retry_count"):
op.add_column(
"scheduled_task_runs",
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"),
)
def downgrade() -> None:
if _has_column("scheduled_task_runs", "retry_count"):
op.drop_column("scheduled_task_runs", "retry_count")
@@ -0,0 +1,44 @@
"""Add mode to thread_shares.
Distinguishes "import" shares (recipient copies the conversation into their
account) from "view" shares (a public, read-only static page).
Revision ID: 20260518_05
Revises: 20260518_04
Create Date: 2026-05-18
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260518_05"
down_revision = "20260518_04"
branch_labels = None
depends_on = None
def _has_column(table_name: str, column_name: str) -> bool:
inspector = inspect(op.get_bind())
if table_name not in inspector.get_table_names():
return True # table missing entirely — skip (created with the column)
return any(column["name"] == column_name for column in inspector.get_columns(table_name))
def upgrade() -> None:
if not _has_column("thread_shares", "mode"):
op.add_column(
"thread_shares",
sa.Column("mode", sa.String(16), nullable=False, server_default="import"),
)
def downgrade() -> None:
inspector = inspect(op.get_bind())
if "thread_shares" in inspector.get_table_names() and any(
column["name"] == "mode" for column in inspector.get_columns("thread_shares")
):
op.drop_column("thread_shares", "mode")
@@ -0,0 +1,28 @@
"""Merge the ai-writing and thread-shares migration heads.
Two independent migration branches diverged from ``20260515_01``: the AI
writing feature (``20260517_01..03``) and the thread-shares / scheduled-task
work (``20260518_01..05``). Merging the two branches into one repo left Alembic
with multiple heads, so ``upgrade head`` failed. This is a no-op merge revision
that joins both lineages — it changes no schema.
Revision ID: 20260518_06
Revises: 20260517_03, 20260518_05
Create Date: 2026-05-18
"""
from __future__ import annotations
revision = "20260518_06"
down_revision = ("20260517_03", "20260518_05")
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
@@ -32,6 +32,7 @@ from deerflow.persistence.scheduled_tasks.model import (
from deerflow.persistence.skills.model import SkillRow
from deerflow.persistence.tags.model import TagAssignmentRow, TagRow
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.thread_shares.model import ThreadShareRow
from deerflow.persistence.user.model import UserRow
__all__ = [
@@ -51,5 +52,6 @@ __all__ = [
"TagAssignmentRow",
"TagRow",
"ThreadMetaRow",
"ThreadShareRow",
"UserRow",
]
@@ -4,10 +4,11 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import JSON, DateTime, Index, String, Text, UniqueConstraint
from sqlalchemy import Index, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableJSON
class RunEventRow(Base):
@@ -24,9 +25,9 @@ class RunEventRow(Base):
category: Mapped[str] = mapped_column(String(16), nullable=False)
# "message" | "trace" | "lifecycle"
content: Mapped[str] = mapped_column(Text, default="")
event_metadata: Mapped[dict] = mapped_column(JSON, default=dict)
event_metadata: Mapped[dict] = mapped_column(PortableJSON(), default=dict)
seq: Mapped[int] = mapped_column(nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
__table_args__ = (
UniqueConstraint("thread_id", "seq", name="uq_events_thread_seq"),
@@ -5,10 +5,11 @@ from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import JSON, Boolean, DateTime, String, Text
from sqlalchemy import Boolean, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableJSON
class NotificationRow(Base):
@@ -19,6 +20,6 @@ class NotificationRow(Base):
type: Mapped[str] = mapped_column(String(64), nullable=False)
title: Mapped[str] = mapped_column(String(256), nullable=False, default="")
body: Mapped[str] = mapped_column(Text, nullable=False, default="")
payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False, default=dict)
payload: Mapped[dict[str, Any]] = mapped_column(PortableJSON(), nullable=False, default=dict)
read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), index=True)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), index=True)
@@ -4,10 +4,11 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy import Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class RecommendedQuestionRow(Base):
@@ -17,5 +18,5 @@ class RecommendedQuestionRow(Base):
title: Mapped[str] = mapped_column(String(256), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -4,10 +4,11 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import JSON, DateTime, Index, String, Text
from sqlalchemy import Index, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableJSON
class RunRow(Base):
@@ -22,8 +23,8 @@ class RunRow(Base):
model_name: Mapped[str | None] = mapped_column(String(128))
multitask_strategy: Mapped[str] = mapped_column(String(20), default="reject")
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
kwargs_json: Mapped[dict] = mapped_column(JSON, default=dict)
metadata_json: Mapped[dict] = mapped_column(PortableJSON(), default=dict)
kwargs_json: Mapped[dict] = mapped_column(PortableJSON(), default=dict)
error: Mapped[str | None] = mapped_column(Text)
# Convenience fields (for listing pages without querying RunEventStore)
@@ -43,7 +44,7 @@ class RunRow(Base):
# Follow-up association
follow_up_to_run_id: Mapped[str | None] = mapped_column(String(64))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
__table_args__ = (Index("ix_runs_thread_status", "thread_id", "status"),)
@@ -36,6 +36,35 @@ class ScheduledTaskStore(ABC):
async def due_tasks(self, now: datetime, *, limit: int = 20) -> list[dict[str, Any]]:
raise NotImplementedError
# --- Admin (cross-user) ---
@abstractmethod
async def list_all_tasks(self) -> list[dict[str, Any]]:
"""Return every task across all users. Admin-only callers."""
raise NotImplementedError
@abstractmethod
async def update_task_any(self, task_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
"""Update a task by id without restricting by owner. Admin-only callers."""
raise NotImplementedError
@abstractmethod
async def delete_task_any(self, task_id: str) -> bool:
"""Delete a task by id without restricting by owner. Admin-only callers."""
raise NotImplementedError
@abstractmethod
async def list_runs_any(self, task_id: str, *, limit: int = 50) -> list[dict[str, Any]]:
"""Return runs for a task without restricting by owner. Admin-only callers."""
raise NotImplementedError
@abstractmethod
async def list_stuck_runs(self, cutoff: datetime, *, limit: int = 50) -> list[dict[str, Any]]:
"""Return runs still in a non-terminal state whose ``started_at`` is older
than ``cutoff``. Used by the scheduler watchdog to recover runs that hung
or were orphaned by a service restart."""
raise NotImplementedError
@abstractmethod
async def create_run_once(self, data: dict[str, Any]) -> dict[str, Any] | None:
raise NotImplementedError
@@ -56,6 +85,11 @@ class ScheduledTaskStore(ABC):
async def delete_run(self, run_id: str, user_id: str) -> bool:
raise NotImplementedError
@abstractmethod
async def delete_run_any(self, run_id: str) -> bool:
"""Delete a run by id without restricting by owner. Admin-only callers."""
raise NotImplementedError
@abstractmethod
async def get_delivery_profile(self, user_id: str) -> dict[str, Any]:
raise NotImplementedError
@@ -59,6 +59,37 @@ class MemoryScheduledTaskStore(ScheduledTaskStore):
due.sort(key=lambda task: task["next_run_at"])
return due[:limit]
# --- Admin (cross-user) ---
async def list_all_tasks(self) -> list[dict[str, Any]]:
return [dict(task) for task in self._tasks.values()]
async def update_task_any(self, task_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
task = self._tasks.get(task_id)
if task is None:
return None
task.update(data)
return dict(task)
async def delete_task_any(self, task_id: str) -> bool:
return self._tasks.pop(task_id, None) is not None
async def list_runs_any(self, task_id: str, *, limit: int = 50) -> list[dict[str, Any]]:
runs = [dict(run) for run in self._runs.values() if run["task_id"] == task_id]
runs.sort(key=lambda run: run.get("scheduled_for"), reverse=True)
return runs[:limit]
async def list_stuck_runs(self, cutoff: datetime, *, limit: int = 50) -> list[dict[str, Any]]:
stuck = [
dict(run)
for run in self._runs.values()
if run.get("status") in ("running", "pending")
and run.get("started_at") is not None
and run["started_at"] <= cutoff
]
stuck.sort(key=lambda run: run["started_at"])
return stuck[:limit]
async def create_run_once(self, data: dict[str, Any]) -> dict[str, Any] | None:
key = (data["task_id"], data["scheduled_for"].isoformat())
if key in self._runs_by_key:
@@ -90,11 +121,21 @@ class MemoryScheduledTaskStore(ScheduledTaskStore):
run = self._runs.get(run_id)
if run is None or run["user_id"] != user_id:
return False
self._runs.pop(run_id, None)
self._drop_run(run)
return True
async def delete_run_any(self, run_id: str) -> bool:
run = self._runs.get(run_id)
if run is None:
return False
self._drop_run(run)
return True
def _drop_run(self, run: dict[str, Any]) -> None:
self._runs.pop(run["id"], None)
scheduled_for = run.get("scheduled_for")
if hasattr(scheduled_for, "isoformat"):
self._runs_by_key.discard((run["task_id"], scheduled_for.isoformat()))
return True
async def get_delivery_profile(self, user_id: str) -> dict[str, Any]:
return dict(self._profiles.get(user_id) or {"user_id": user_id, "element_user_id": None})
@@ -4,10 +4,11 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import JSON, Boolean, DateTime, String, Text, UniqueConstraint
from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableJSON
class SchedulerThreadRow(Base):
@@ -15,8 +16,8 @@ class SchedulerThreadRow(Base):
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
thread_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
class ScheduledTaskRow(Base):
@@ -29,20 +30,20 @@ class ScheduledTaskRow(Base):
source_thread_id: Mapped[str | None] = mapped_column(String(64), 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)
name: Mapped[str] = mapped_column(String(256))
name: Mapped[str] = mapped_column(String(127))
prompt: Mapped[str] = mapped_column(Text)
schedule_text: Mapped[str] = mapped_column(String(256))
cron_expr: Mapped[str] = mapped_column(String(128))
timezone: Mapped[str] = mapped_column(String(64), default="UTC")
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
next_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
next_run_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), index=True)
last_run_at: Mapped[datetime | None] = mapped_column(BeijingDateTime())
last_status: Mapped[str | None] = mapped_column(String(32))
execution_context_json: Mapped[dict] = mapped_column(JSON, default=dict)
execution_context_json: Mapped[dict] = mapped_column(PortableJSON(), default=dict)
published: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
published_at: Mapped[datetime | None] = mapped_column(BeijingDateTime())
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
class ScheduledTaskSubscriptionRow(Base):
@@ -53,8 +54,8 @@ class ScheduledTaskSubscriptionRow(Base):
task_id: Mapped[str] = mapped_column(String(64), index=True)
user_id: Mapped[str] = mapped_column(String(64), index=True)
notify_element: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
class ScheduledTaskRunRow(Base):
@@ -66,21 +67,25 @@ class ScheduledTaskRunRow(Base):
user_id: Mapped[str] = mapped_column(String(64), index=True)
scheduler_thread_id: Mapped[str] = mapped_column(String(64), index=True)
agent_run_id: Mapped[str | None] = mapped_column(String(64), index=True)
scheduled_for: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
scheduled_for: Mapped[datetime] = mapped_column(BeijingDateTime(), index=True)
started_at: Mapped[datetime | None] = mapped_column(BeijingDateTime())
finished_at: Mapped[datetime | None] = mapped_column(BeijingDateTime())
status: Mapped[str] = mapped_column(String(32), default="pending")
error: Mapped[str | None] = mapped_column(Text)
result_json: Mapped[dict | None] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
result_json: Mapped[dict | None] = mapped_column(PortableJSON(), default=dict)
# Number of recovery attempts made after the first try. A run is retried
# when it overruns the per-attempt timeout or is found orphaned in the
# "running" state (e.g. after a service restart). See ScheduledTaskService.
retry_count: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
class ScheduledTaskDeliveryProfileRow(Base):
__tablename__ = "scheduled_task_delivery_profiles"
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
element_user_id: Mapped[str | None] = mapped_column(String(256), index=True)
element_user_id: Mapped[str | None] = mapped_column(String(191), index=True)
element_room_id: Mapped[str | None] = mapped_column(String(256), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -124,6 +124,60 @@ class ScheduledTaskRepository(ScheduledTaskStore):
)
return [_to_dict(row) for row in result.scalars()]
# --- Admin (cross-user) ---
async def list_all_tasks(self) -> list[dict[str, Any]]:
async with self._sf() as session:
result = await session.execute(select(ScheduledTaskRow).order_by(ScheduledTaskRow.created_at.desc()))
return [_to_dict(row) for row in result.scalars()]
async def update_task_any(self, task_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ScheduledTaskRow, task_id)
if row is None:
return None
for key, value in data.items():
attr = "execution_context_json" if key == "execution_context" else key
if hasattr(row, attr):
setattr(row, attr, value)
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return _to_dict(row)
async def delete_task_any(self, task_id: str) -> bool:
async with self._sf() as session:
row = await session.get(ScheduledTaskRow, task_id)
if row is None:
return False
await session.delete(row)
await session.commit()
return True
async def list_runs_any(self, task_id: str, *, limit: int = 50) -> list[dict[str, Any]]:
async with self._sf() as session:
result = await session.execute(
select(ScheduledTaskRunRow)
.where(ScheduledTaskRunRow.task_id == task_id)
.order_by(ScheduledTaskRunRow.scheduled_for.desc())
.limit(limit)
)
return [_to_dict(row) for row in result.scalars()]
async def list_stuck_runs(self, cutoff: datetime, *, limit: int = 50) -> list[dict[str, Any]]:
async with self._sf() as session:
result = await session.execute(
select(ScheduledTaskRunRow)
.where(
ScheduledTaskRunRow.status.in_(("running", "pending")),
ScheduledTaskRunRow.started_at.is_not(None),
ScheduledTaskRunRow.started_at <= cutoff,
)
.order_by(ScheduledTaskRunRow.started_at.asc())
.limit(limit)
)
return [_to_dict(row) for row in result.scalars()]
async def create_run_once(self, data: dict[str, Any]) -> dict[str, Any] | None:
row = ScheduledTaskRunRow(
id=data["id"],
@@ -180,6 +234,15 @@ class ScheduledTaskRepository(ScheduledTaskStore):
await session.commit()
return True
async def delete_run_any(self, run_id: str) -> bool:
async with self._sf() as session:
row = await session.get(ScheduledTaskRunRow, run_id)
if row is None:
return False
await session.delete(row)
await session.commit()
return True
async def get_delivery_profile(self, user_id: str) -> dict[str, Any]:
async with self._sf() as session:
row = await session.get(ScheduledTaskDeliveryProfileRow, user_id)
@@ -232,7 +295,14 @@ class ScheduledTaskRepository(ScheduledTaskStore):
result = await session.execute(
select(ScheduledTaskRow)
.where(ScheduledTaskRow.published.is_(True))
.order_by(ScheduledTaskRow.published_at.desc().nullslast(), ScheduledTaskRow.created_at.desc())
# ``NULLS LAST`` is unsupported by MySQL — sort by an
# ``IS NULL`` flag instead (False<True ⇒ non-null rows first),
# which is portable across mysql/sqlite/postgres.
.order_by(
ScheduledTaskRow.published_at.is_(None),
ScheduledTaskRow.published_at.desc(),
ScheduledTaskRow.created_at.desc(),
)
.offset(max(0, offset))
.limit(max(1, limit))
)
@@ -14,10 +14,11 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import DateTime, String, UniqueConstraint
from sqlalchemy import String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
# Allowed tag/target types. Kept here so persistence + API share one source.
TAG_TYPES: tuple[str, ...] = ("agent", "skill", "scheduled_task")
@@ -28,12 +29,12 @@ class TagRow(Base):
__table_args__ = (UniqueConstraint("tag_type", "name", name="uq_tags_type_name"),)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
name: Mapped[str] = mapped_column(String(256), nullable=False)
name: Mapped[str] = mapped_column(String(159), nullable=False)
tag_type: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
color: Mapped[str] = mapped_column(String(16), nullable=False, default="gray")
description: Mapped[str] = mapped_column(String(1024), nullable=False, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
class TagAssignmentRow(Base):
@@ -44,4 +45,4 @@ class TagAssignmentRow(Base):
tag_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
target_type: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
target_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
@@ -4,10 +4,11 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import JSON, DateTime, String
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime, PortableJSON
class ThreadMetaRow(Base):
@@ -18,6 +19,6 @@ class ThreadMetaRow(Base):
user_id: Mapped[str | None] = mapped_column(String(64), index=True)
display_name: Mapped[str | None] = mapped_column(String(256))
status: Mapped[str] = mapped_column(String(20), default="idle")
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
metadata_json: Mapped[dict] = mapped_column(PortableJSON(), default=dict)
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -0,0 +1,19 @@
"""Persistence layer for conversation-history share links."""
from deerflow.persistence.thread_shares.base import ThreadShareStore, generate_share_code
from deerflow.persistence.thread_shares.memory import MemoryThreadShareStore
from deerflow.persistence.thread_shares.sql import ThreadShareRepository
__all__ = [
"ThreadShareStore",
"MemoryThreadShareStore",
"ThreadShareRepository",
"generate_share_code",
"make_thread_share_store",
]
def make_thread_share_store(session_factory):
if session_factory is None:
return MemoryThreadShareStore()
return ThreadShareRepository(session_factory)
@@ -0,0 +1,50 @@
"""Abstract persistence API for per-conversation share links."""
from __future__ import annotations
import secrets
from abc import ABC, abstractmethod
from typing import Any
# Unambiguous alphabet — no 0/O/1/I/L so codes are easy to read and type.
_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
def generate_share_code(length: int = 8) -> str:
"""Return a random, unguessable, human-readable share code."""
return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(length))
class ThreadShareStore(ABC):
@abstractmethod
async def create(self, owner_user_id: str, thread_id: str, *, mode: str = "import") -> dict[str, Any]:
"""Create a new share link for one conversation and return its record.
``mode`` is ``"import"`` (recipient copies the conversation) or
``"view"`` (a public read-only static page).
"""
raise NotImplementedError
@abstractmethod
async def get(self, share_code: str) -> dict[str, Any] | None:
"""Look up a share by code regardless of owner (None if missing)."""
raise NotImplementedError
@abstractmethod
async def get_active_for_thread(self, owner_user_id: str, thread_id: str, *, mode: str = "import") -> dict[str, Any] | None:
"""Return the owner's non-revoked share of ``mode`` for ``thread_id``, if any.
Used so re-sharing the same conversation in the same mode reuses its
existing code instead of minting a new one each time.
"""
raise NotImplementedError
@abstractmethod
async def list_for_owner(self, owner_user_id: str) -> list[dict[str, Any]]:
"""List all share links created by ``owner_user_id`` (newest first)."""
raise NotImplementedError
@abstractmethod
async def revoke(self, share_code: str, owner_user_id: str) -> bool:
"""Revoke a share link. Returns False if missing or not owned."""
raise NotImplementedError
@@ -0,0 +1,59 @@
"""In-memory ``ThreadShareStore`` implementation for tests / memory backend."""
from __future__ import annotations
import copy
from datetime import UTC, datetime
from typing import Any
from deerflow.persistence.thread_shares.base import ThreadShareStore, generate_share_code
class MemoryThreadShareStore(ThreadShareStore):
def __init__(self) -> None:
self._shares: dict[str, dict[str, Any]] = {}
async def create(self, owner_user_id: str, thread_id: str, *, mode: str = "import") -> dict[str, Any]:
code = generate_share_code()
while code in self._shares:
code = generate_share_code()
record = {
"share_code": code,
"owner_user_id": owner_user_id,
"thread_id": thread_id,
"revoked": False,
"mode": mode,
"created_at": datetime.now(UTC).isoformat(),
}
self._shares[code] = record
return copy.deepcopy(record)
async def get(self, share_code: str) -> dict[str, Any] | None:
record = self._shares.get(share_code)
return copy.deepcopy(record) if record is not None else None
async def get_active_for_thread(self, owner_user_id: str, thread_id: str, *, mode: str = "import") -> dict[str, Any] | None:
matches = [
r
for r in self._shares.values()
if r["owner_user_id"] == owner_user_id
and r["thread_id"] == thread_id
and r.get("mode", "import") == mode
and not r["revoked"]
]
if not matches:
return None
matches.sort(key=lambda r: r["created_at"], reverse=True)
return copy.deepcopy(matches[0])
async def list_for_owner(self, owner_user_id: str) -> list[dict[str, Any]]:
rows = [r for r in self._shares.values() if r["owner_user_id"] == owner_user_id]
rows.sort(key=lambda r: r["created_at"], reverse=True)
return [copy.deepcopy(r) for r in rows]
async def revoke(self, share_code: str, owner_user_id: str) -> bool:
record = self._shares.get(share_code)
if record is None or record["owner_user_id"] != owner_user_id:
return False
record["revoked"] = True
return True
@@ -0,0 +1,37 @@
"""ORM model for conversation share links.
A share link is a short, revocable code created by a user for *one* of their
conversations. Redeeming it copies that single conversation into the redeemer's
account. The table only stores the link itself — the conversation is copied
lazily at import time.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class ThreadShareRow(Base):
__tablename__ = "thread_shares"
# Short, URL-safe, unguessable code embedded in the share link.
share_code: Mapped[str] = mapped_column(String(32), primary_key=True)
# The user who created the share.
owner_user_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
# The single conversation (thread) this link shares.
thread_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
# Soft-disable: a revoked link can no longer be previewed or imported.
revoked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Share mode:
# "import" — recipient (a logged-in user) copies the conversation into
# their own account and can keep chatting in the copy.
# "view" — a public, read-only static page anyone can open without
# logging in; viewers cannot send new messages.
mode: Mapped[str] = mapped_column(String(16), nullable=False, default="import", server_default="import")
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC))
@@ -0,0 +1,80 @@
"""SQLAlchemy-backed per-conversation share-link repository."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.thread_shares.base import ThreadShareStore, generate_share_code
from deerflow.persistence.thread_shares.model import ThreadShareRow
def _row_to_dict(row: ThreadShareRow) -> dict[str, Any]:
data = row.to_dict()
created = data.get("created_at")
if isinstance(created, datetime):
data["created_at"] = created.isoformat()
return data
class ThreadShareRepository(ThreadShareStore):
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def create(self, owner_user_id: str, thread_id: str, *, mode: str = "import") -> dict[str, Any]:
# Retry on the (astronomically unlikely) code collision.
async with self._sf() as session:
for _ in range(8):
code = generate_share_code()
if await session.get(ThreadShareRow, code) is not None:
continue
row = ThreadShareRow(
share_code=code,
owner_user_id=owner_user_id,
thread_id=thread_id,
revoked=False,
mode=mode,
)
session.add(row)
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
raise RuntimeError("Failed to allocate a unique share code")
async def get(self, share_code: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(ThreadShareRow, share_code)
return _row_to_dict(row) if row is not None else None
async def get_active_for_thread(self, owner_user_id: str, thread_id: str, *, mode: str = "import") -> dict[str, Any] | None:
stmt = (
select(ThreadShareRow)
.where(
ThreadShareRow.owner_user_id == owner_user_id,
ThreadShareRow.thread_id == thread_id,
ThreadShareRow.mode == mode,
ThreadShareRow.revoked.is_(False),
)
.order_by(ThreadShareRow.created_at.desc())
)
async with self._sf() as session:
row = (await session.execute(stmt)).scalars().first()
return _row_to_dict(row) if row is not None else None
async def list_for_owner(self, owner_user_id: str) -> list[dict[str, Any]]:
stmt = select(ThreadShareRow).where(ThreadShareRow.owner_user_id == owner_user_id).order_by(ThreadShareRow.created_at.desc())
async with self._sf() as session:
result = await session.execute(stmt)
return [_row_to_dict(row) for row in result.scalars()]
async def revoke(self, share_code: str, owner_user_id: str) -> bool:
async with self._sf() as session:
row = await session.get(ThreadShareRow, share_code)
if row is None or row.owner_user_id != owner_user_id:
return False
row.revoked = True
await session.commit()
return True
@@ -0,0 +1,85 @@
"""Portable SQLAlchemy column types."""
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import JSON, DateTime, Text
from sqlalchemy.types import TypeDecorator
# China Standard Time. China has observed UTC+8 with no DST since 1991, so a
# fixed offset is exact and avoids depending on the `tzdata` package (which the
# stdlib `zoneinfo` needs on Windows / minimal containers).
BEIJING_TZ = timezone(timedelta(hours=8))
class PortableJSON(TypeDecorator):
"""Use native JSON where available, and TEXT on older MySQL servers."""
impl = JSON
cache_ok = True
def load_dialect_impl(self, dialect):
if dialect.name == "mysql":
return dialect.type_descriptor(Text())
return dialect.type_descriptor(JSON())
def process_bind_param(self, value: Any, dialect) -> Any:
if dialect.name != "mysql" or value is None:
return value
return json.dumps(value, ensure_ascii=False)
def process_result_value(self, value: Any, dialect) -> Any:
if dialect.name != "mysql" or value is None or not isinstance(value, str):
return value
if not value:
return None
return json.loads(value)
class BeijingDateTime(TypeDecorator):
"""Store timestamps as Beijing (UTC+8) wall-clock time.
The application works in UTC internally (``datetime.now(UTC)`` everywhere).
MySQL ``DATETIME`` and SQLite have no timezone, so the driver serializes the
wall-clock fields of whatever datetime it is given and drops ``tzinfo``.
Without conversion a row stores the UTC wall clock and an operator browsing
the table sees a time 8 hours behind Beijing.
This decorator converts UTC-aware values to Beijing on the way in (stored as
naive Beijing wall-clock) and re-attaches the Beijing zone on the way out, so
the database holds Beijing time while Python keeps receiving correct
timezone-aware datetimes — instant comparisons stay correct either way.
PostgreSQL has a real ``TIMESTAMP WITH TIME ZONE`` type and stores the
instant unambiguously, so values pass through unchanged there.
"""
impl = DateTime
cache_ok = True
def load_dialect_impl(self, dialect):
# timezone=True keeps PostgreSQL on timestamptz; MySQL/SQLite ignore it.
return dialect.type_descriptor(DateTime(timezone=True))
def process_bind_param(self, value: Any, dialect) -> Any:
if not isinstance(value, datetime):
return value
if dialect.name == "postgresql":
return value
if value.tzinfo is None:
# Naive values follow the codebase convention of being UTC.
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(BEIJING_TZ).replace(tzinfo=None)
def process_result_value(self, value: Any, dialect) -> Any:
if not isinstance(value, datetime):
return value
if dialect.name == "postgresql":
return value
if value.tzinfo is None:
# Stored as naive Beijing wall-clock; re-attach the zone.
return value.replace(tzinfo=BEIJING_TZ)
return value.astimezone(BEIJING_TZ)
@@ -13,10 +13,11 @@ from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import Boolean, DateTime, Index, String, text
from sqlalchemy import Boolean, Index, String, text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
from deerflow.persistence.types import BeijingDateTime
class UserRow(Base):
@@ -25,7 +26,7 @@ class UserRow(Base):
# UUIDs are stored as 36-char strings for cross-backend portability.
id: Mapped[str] = mapped_column(String(36), primary_key=True)
email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True)
email: Mapped[str] = mapped_column(String(191), unique=True, nullable=False, index=True)
password_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
# "admin" | "user" — kept as plain string to avoid ALTER TABLE pain
@@ -33,7 +34,7 @@ class UserRow(Base):
system_role: Mapped[str] = mapped_column(String(16), nullable=False, default="user")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
BeijingDateTime(),
nullable=False,
default=lambda: datetime.now(UTC),
)
@@ -87,6 +87,7 @@ class RunEventStore(abc.ABC):
limit: int = 50,
before_seq: int | None = None,
after_seq: int | None = None,
user_id: str | None = None,
) -> list[dict]:
"""Return displayable messages (category=message) for a specific run, ordered by seq ascending.
@@ -152,7 +152,9 @@ class JsonlRunEventStore(RunEventStore):
events = [e for e in events if e.get("event_type") in event_types]
return events[:limit]
async def list_messages_by_run(self, thread_id, run_id, *, limit=50, before_seq=None, after_seq=None):
async def list_messages_by_run(self, thread_id, run_id, *, limit=50, before_seq=None, after_seq=None, user_id=None):
# JSONL events carry no per-event owner; user_id is accepted for
# interface parity with DbRunEventStore but ignored.
events = self._read_run_events(thread_id, run_id)
filtered = [e for e in events if e.get("category") == "message"]
if before_seq is not None:
@@ -97,7 +97,9 @@ class MemoryRunEventStore(RunEventStore):
filtered = [e for e in filtered if e["event_type"] in event_types]
return filtered[:limit]
async def list_messages_by_run(self, thread_id, run_id, *, limit=50, before_seq=None, after_seq=None):
async def list_messages_by_run(self, thread_id, run_id, *, limit=50, before_seq=None, after_seq=None, user_id=None):
# The in-memory store keeps no per-event owner, so user_id is accepted
# for interface parity with DbRunEventStore but ignored.
all_events = self._events.get(thread_id, [])
filtered = [e for e in all_events if e["run_id"] == run_id and e["category"] == "message"]
if before_seq is not None:
@@ -1,4 +1,4 @@
from .registry import get_scheduled_task_service, set_scheduled_task_service
from .service import ScheduledTaskService
from .service import DuplicateTaskNameError, ScheduledTaskService
__all__ = ["ScheduledTaskService", "get_scheduled_task_service", "set_scheduled_task_service"]
__all__ = ["DuplicateTaskNameError", "ScheduledTaskService", "get_scheduled_task_service", "set_scheduled_task_service"]
@@ -9,7 +9,7 @@ import re
import shutil
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
from typing import Any
@@ -27,6 +27,27 @@ from deerflow.utils.time import now_iso
logger = logging.getLogger(__name__)
# A single execution attempt may not exceed this wall-clock budget. When it
# does, the run is treated as stuck (the agent loop hung, a tool blocked, etc.)
# and retried. The watchdog uses the same window to recover runs orphaned by a
# crash or restart — those stay "running" in the DB with no in-process owner.
_RUN_ATTEMPT_TIMEOUT_SECONDS = 30 * 60 # 30 minutes
# Retries allowed after the first attempt before the run is marked "failed".
_MAX_RUN_RETRIES = 3
class DuplicateTaskNameError(Exception):
"""A user already owns a scheduled task with the requested name.
Task names are unique per user (``uq_scheduled_tasks_user_name``). The
service raises this instead of letting the raw DB ``IntegrityError`` bubble
up so the API layer can return a friendly 409 rather than a 500.
"""
def __init__(self, name: str) -> None:
self.name = name
super().__init__(f"A scheduled task named {name!r} already exists")
@dataclass
class _RuntimeUser:
@@ -337,15 +358,20 @@ class ScheduledTaskService:
"kind": item.get("kind"),
}
@staticmethod
def _remove_run_archive(run_id: str) -> None:
"""Best-effort cleanup of a run's on-disk result archive."""
archive_dir = get_paths().base_dir / "scheduled-task-runs" / run_id
if archive_dir.exists():
try:
shutil.rmtree(archive_dir)
except Exception:
logger.warning("Failed to delete scheduled task run archive %s", archive_dir, exc_info=True)
async def delete_run(self, user_id: str, run_id: str) -> bool:
deleted = await self.store.delete_run(run_id, user_id)
if deleted:
archive_dir = get_paths().base_dir / "scheduled-task-runs" / run_id
if archive_dir.exists():
try:
shutil.rmtree(archive_dir)
except Exception:
logger.warning("Failed to delete scheduled task run archive %s", archive_dir, exc_info=True)
self._remove_run_archive(run_id)
return True
candidate = await self.store.get_run_any(run_id)
if candidate is not None and await self.store.get_subscription(candidate["task_id"], user_id) is not None:
@@ -358,6 +384,20 @@ class ScheduledTaskService:
async def update_delivery_profile(self, user_id: str, data: dict[str, Any]) -> dict[str, Any]:
return await self.store.update_delivery_profile(user_id, data)
async def _assert_name_available(self, user_id: str, name: str, *, exclude_task_id: str | None = None) -> None:
"""Raise :class:`DuplicateTaskNameError` if ``user_id`` already owns ``name``.
Enforces ``uq_scheduled_tasks_user_name`` proactively so callers get a
clean conflict error instead of a raw DB ``IntegrityError``. The check
compares the trimmed name; ``exclude_task_id`` skips the task being
renamed so an unchanged name on update is not flagged as a collision.
"""
for existing in await self.store.list_tasks(user_id):
if exclude_task_id is not None and existing.get("task_id") == exclude_task_id:
continue
if (existing.get("name") or "").strip() == name:
raise DuplicateTaskNameError(name)
async def create_task(
self,
*,
@@ -374,6 +414,8 @@ class ScheduledTaskService:
tz = _normalize_timezone(timezone)
cron_expr = _schedule_to_cron(schedule)
next_run_at = _next_run_at(cron_expr, tz)
final_name = name.strip() or "Scheduled task"
await self._assert_name_available(user_id, final_name)
scheduler_thread = await self.ensure_scheduler_thread(user_id)
context = dict(execution_context or {})
if execution_agent_name:
@@ -386,7 +428,7 @@ class ScheduledTaskService:
"source_thread_id": source_thread_id,
"source_agent_name": source_agent_name,
"execution_agent_name": execution_agent_name,
"name": name.strip() or "Scheduled task",
"name": final_name,
"prompt": prompt.strip(),
"schedule_text": schedule.strip(),
"cron_expr": cron_expr,
@@ -398,31 +440,106 @@ class ScheduledTaskService:
)
return task
async def update_task(self, user_id: str, task_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
@staticmethod
def _build_task_update(data: dict[str, Any], existing: dict[str, Any] | None) -> dict[str, Any]:
"""Translate an API update payload into a storage update dict.
Shared by owner-scoped :meth:`update_task` and admin :meth:`admin_update_task`
so both apply identical schedule/timezone normalization.
"""
update_data: dict[str, Any] = {}
for key in ("name", "prompt", "enabled", "execution_agent_name"):
if key in data and data[key] is not None:
update_data[key] = data[key]
# Names are matched against the per-user uniqueness constraint, so keep
# the stored value trimmed to match create_task's normalization.
if isinstance(update_data.get("name"), str):
update_data["name"] = update_data["name"].strip() or "Scheduled task"
timezone = data.get("timezone")
if timezone is not None:
update_data["timezone"] = _normalize_timezone(timezone)
if data.get("schedule") is not None:
schedule = str(data["schedule"])
cron_expr = _schedule_to_cron(schedule)
tz = update_data.get("timezone")
if tz is None:
existing = await self.store.get_task(task_id, user_id)
tz = (existing or {}).get("timezone", "UTC")
tz = update_data.get("timezone") or (existing or {}).get("timezone") or "UTC"
update_data["schedule_text"] = schedule
update_data["cron_expr"] = cron_expr
update_data["next_run_at"] = _next_run_at(cron_expr, tz)
if data.get("execution_context") is not None:
update_data["execution_context"] = data["execution_context"]
# Re-enabling a task must also schedule it forward from now, otherwise a
# paused task with a stale/empty next_run_at would never fire again.
if update_data.get("enabled") is True and "next_run_at" not in update_data:
cron = (existing or {}).get("cron_expr")
if cron:
tz = update_data.get("timezone") or (existing or {}).get("timezone") or "UTC"
update_data["next_run_at"] = _next_run_at(cron, tz)
return update_data
async def update_task(self, user_id: str, task_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
existing = await self.store.get_task(task_id, user_id)
if existing is None:
return None
update_data = self._build_task_update(data, existing)
if "name" in update_data:
await self._assert_name_available(user_id, update_data["name"], exclude_task_id=task_id)
return await self.store.update_task(task_id, user_id, update_data)
async def delete_task(self, user_id: str, task_id: str) -> bool:
return await self.store.delete_task(task_id, user_id)
# --- Admin (cross-user management) ---
async def admin_list_tasks(self) -> list[dict[str, Any]]:
"""List every scheduled task across all users. Admin-only callers."""
tasks = await self.store.list_all_tasks()
for task in tasks:
task["role"] = "owner"
return tasks
async def admin_update_task(self, task_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
"""Update any task regardless of owner. Admin-only callers."""
existing = await self.store.get_task_any(task_id)
if existing is None:
return None
update_data = self._build_task_update(data, existing)
if "name" in update_data:
await self._assert_name_available(existing["user_id"], update_data["name"], exclude_task_id=task_id)
return await self.store.update_task_any(task_id, update_data)
async def admin_delete_task(self, task_id: str) -> bool:
"""Delete any task regardless of owner. Admin-only callers."""
return await self.store.delete_task_any(task_id)
async def admin_delete_run(self, run_id: str) -> bool:
"""Delete a single run regardless of owner. Admin-only callers."""
deleted = await self.store.delete_run_any(run_id)
if deleted:
self._remove_run_archive(run_id)
return deleted
async def admin_list_runs(self, task_id: str, *, limit: int = 50) -> list[dict[str, Any]]:
"""List runs for any task regardless of owner. Admin-only callers."""
runs = await self.store.list_runs_any(task_id, limit=limit)
for run in runs:
run["can_edit"] = True
return runs
async def admin_get_run(self, run_id: str) -> dict[str, Any] | None:
"""Fetch a single run regardless of owner. Admin-only callers."""
run = await self.store.get_run_any(run_id)
if run is not None:
run["can_edit"] = True
return run
async def admin_trigger_task(self, task_id: str) -> dict[str, Any] | None:
"""Trigger an immediate run of any task regardless of owner."""
task = await self.store.get_task_any(task_id)
if task is None:
return None
asyncio.create_task(self._execute_tracked(task, scheduled_for=datetime.now(UTC), manual=True))
return task
async def pause_task(self, user_id: str, task_id: str) -> dict[str, Any] | None:
return await self.store.update_task(task_id, user_id, {"enabled": False})
@@ -437,11 +554,19 @@ class ScheduledTaskService:
task = await self.store.get_task(task_id, user_id)
if task is None:
return None
asyncio.create_task(self._execute_task(task, scheduled_for=datetime.now(UTC), manual=True))
asyncio.create_task(self._execute_tracked(task, scheduled_for=datetime.now(UTC), manual=True))
return task
async def _run_loop(self) -> None:
while not self._stopping.is_set():
# Recover hung / orphaned runs first so a single stuck task cannot
# silently wedge the scheduler indefinitely.
try:
await self._reconcile_stuck_runs()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Scheduled task watchdog scan failed")
try:
due = await self.store.due_tasks(datetime.now(UTC), limit=20)
for task in due:
@@ -466,64 +591,195 @@ class ScheduledTaskService:
finally:
self._running_task_ids.discard(task["task_id"])
async def _execute_task(self, task: dict[str, Any], *, scheduled_for: datetime, manual: bool) -> None:
async def _execute_tracked(self, task: dict[str, Any], *, scheduled_for: datetime, manual: bool) -> None:
"""Run a task while marking it busy so the watchdog won't double-drive it."""
self._running_task_ids.add(task["task_id"])
try:
await self._execute_task(task, scheduled_for=scheduled_for, manual=manual)
finally:
self._running_task_ids.discard(task["task_id"])
async def _reconcile_stuck_runs(self) -> None:
"""Recover runs left in the ``running`` state with no in-process owner.
A run becomes stuck when the agent loop hangs past the per-attempt
timeout, or when the process crashes / restarts mid-run (the original
asyncio task is gone but the DB row still says ``running``). For each
such run we retry up to ``_MAX_RUN_RETRIES`` times, then mark it
``failed`` so it no longer shows as perpetually executing.
"""
cutoff = datetime.now(UTC) - timedelta(seconds=_RUN_ATTEMPT_TIMEOUT_SECONDS)
stuck = await self.store.list_stuck_runs(cutoff, limit=20)
for run in stuck:
task_id = run["task_id"]
# A run still owned by a live in-process execution is handled by
# that execution's own timeout/retry loop — don't touch it here.
if task_id in self._running_task_ids:
continue
self._running_task_ids.add(task_id)
asyncio.create_task(self._recover_and_untrack(run))
async def _recover_and_untrack(self, run: dict[str, Any]) -> None:
try:
await self._recover_stuck_run(run)
except Exception:
logger.exception("Failed to recover stuck scheduled task run %s", run.get("id"))
finally:
self._running_task_ids.discard(run["task_id"])
async def _recover_stuck_run(self, run: dict[str, Any]) -> None:
run_id = run["id"]
retry_count = int(run.get("retry_count") or 0)
task = await self.store.get_task_any(run["task_id"])
scheduled_for = _coerce_datetime(run.get("scheduled_for")) or datetime.now(UTC)
if task is None:
# Parent task was deleted — just close out the orphaned run.
await self.store.update_run(
run_id,
{"status": "failed", "finished_at": datetime.now(UTC), "error": "执行超时:所属任务已被删除"},
)
return
if retry_count >= _MAX_RUN_RETRIES:
await self._fail_run(
task,
run_id,
scheduled_for,
error=f"执行超时:超过 {_RUN_ATTEMPT_TIMEOUT_SECONDS // 60} 分钟未完成,重试 {_MAX_RUN_RETRIES} 次后仍失败",
retry_count=retry_count,
)
return
logger.warning("Recovering stuck scheduled task run %s (retry budget %s/%s)", run_id, retry_count, _MAX_RUN_RETRIES)
await self._execute_task(task, scheduled_for=scheduled_for, manual=False, run_row=run)
async def _fail_run(
self,
task: dict[str, Any],
run_id: str,
scheduled_for: datetime,
*,
error: str,
retry_count: int = 0,
) -> None:
"""Mark a run failed and reflect the outcome on the parent task."""
result = _build_result(task, scheduled_for=scheduled_for, content=f"Scheduled task failed: {error}")
await self.store.update_run(
run_id,
{"status": "failed", "finished_at": datetime.now(UTC), "error": error, "result": result, "retry_count": retry_count},
)
await self.store.update_task(task["task_id"], task["user_id"], {"last_run_at": datetime.now(UTC), "last_status": "failed"})
async def _execute_task(
self,
task: dict[str, Any],
*,
scheduled_for: datetime,
manual: bool,
run_row: dict[str, Any] | None = None,
) -> None:
# Each run uses its own throwaway thread so message history cannot accumulate
# across runs and blow past the model's input window. The per-user
# task["scheduler_thread_id"] is no longer used to drive the LLM call —
# we keep it on the task row only for backwards compatibility.
runtime_thread_id = str(uuid.uuid4())
run_row = await self.store.create_run_once(
{
"id": str(uuid.uuid4()),
"task_id": task["task_id"],
"user_id": task["user_id"],
"scheduler_thread_id": runtime_thread_id,
"scheduled_for": scheduled_for,
"status": "running",
"started_at": datetime.now(UTC),
}
)
is_recovery = run_row is not None
if run_row is None:
return
run_row = await self.store.create_run_once(
{
"id": str(uuid.uuid4()),
"task_id": task["task_id"],
"user_id": task["user_id"],
"scheduler_thread_id": str(uuid.uuid4()),
"scheduled_for": scheduled_for,
"status": "running",
"started_at": datetime.now(UTC),
}
)
if run_row is None:
return
else:
# Recovery path: re-claim an existing (orphaned) run row.
await self.store.update_run(run_row["id"], {"status": "running", "started_at": datetime.now(UTC)})
if not manual:
# Advancing the schedule only happens once, when the run is first
# created — recovery retries reuse the original scheduled_for.
if not manual and not is_recovery:
next_run_at = _next_run_at(task["cron_expr"], task.get("timezone") or "UTC", base=scheduled_for)
await self.store.update_task(task["task_id"], task["user_id"], {"next_run_at": next_run_at, "last_status": "running"})
try:
await self._ensure_thread_meta(task["user_id"], runtime_thread_id)
async with self._agent_run_lock:
output_snapshot = self._snapshot_result_files(runtime_thread_id, task["user_id"])
record = await self._start_agent_run(task, scheduled_for=scheduled_for, runtime_thread_id=runtime_thread_id)
await self.store.update_run(run_row["id"], {"agent_run_id": record.run_id})
if record.task is not None:
await record.task
result_text = await self._extract_latest_ai_text(runtime_thread_id)
result_files = await self._extract_result_files(
runtime_thread_id,
task["user_id"],
run_id=run_row["id"],
output_snapshot=output_snapshot,
run_id = run_row["id"]
retry_count = int(run_row.get("retry_count") or 0)
while True:
runtime_thread_id = str(uuid.uuid4())
try:
result = await asyncio.wait_for(
self._run_agent_attempt(task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id),
timeout=_RUN_ATTEMPT_TIMEOUT_SECONDS,
)
result = _build_result(task, scheduled_for=scheduled_for, content=result_text, files=result_files)
except TimeoutError:
# The attempt overran the 30-minute cap. Retry a bounded number
# of times, then give up and mark the run failed.
if retry_count >= _MAX_RUN_RETRIES:
await self._fail_run(
task,
run_id,
scheduled_for,
error=f"执行超时:单次执行超过 {_RUN_ATTEMPT_TIMEOUT_SECONDS // 60} 分钟,重试 {_MAX_RUN_RETRIES} 次后仍失败",
retry_count=retry_count,
)
return
retry_count += 1
logger.warning("Scheduled task %s timed out; retry %s/%s", task["task_id"], retry_count, _MAX_RUN_RETRIES)
await self.store.update_run(
run_id,
{
"retry_count": retry_count,
"status": "running",
"started_at": datetime.now(UTC),
"error": f"执行超时,正在重试(第 {retry_count}/{_MAX_RUN_RETRIES} 次)",
},
)
continue
except Exception as exc:
logger.exception("Scheduled task %s failed", task["task_id"])
await self._fail_run(task, run_id, scheduled_for, error=str(exc), retry_count=retry_count)
return
await self.store.update_run(
run_row["id"],
{"status": "succeeded", "finished_at": datetime.now(UTC), "result": result},
run_id,
{"status": "succeeded", "finished_at": datetime.now(UTC), "result": result, "retry_count": retry_count, "error": None},
)
await self.store.update_task(task["task_id"], task["user_id"], {"last_run_at": datetime.now(UTC), "last_status": "succeeded"})
await self._deliver_element_result(task, result["content"])
except Exception as exc:
logger.exception("Scheduled task %s failed", task["task_id"])
result = _build_result(
task,
scheduled_for=scheduled_for,
content=f"Scheduled task failed: {exc}",
return
async def _run_agent_attempt(
self,
task: dict[str, Any],
*,
runtime_thread_id: str,
scheduled_for: datetime,
run_id: str,
) -> dict[str, Any]:
"""Run the agent once and build the result payload.
Wrapped in :func:`asyncio.wait_for` by the caller so a hung agent loop
cannot hold the shared run lock — and the whole scheduler — forever.
"""
await self._ensure_thread_meta(task["user_id"], runtime_thread_id)
async with self._agent_run_lock:
output_snapshot = self._snapshot_result_files(runtime_thread_id, task["user_id"])
record = await self._start_agent_run(task, scheduled_for=scheduled_for, runtime_thread_id=runtime_thread_id)
await self.store.update_run(run_id, {"agent_run_id": record.run_id})
if record.task is not None:
await record.task
result_text = await self._extract_latest_ai_text(runtime_thread_id)
result_files = await self._extract_result_files(
runtime_thread_id,
task["user_id"],
run_id=run_id,
output_snapshot=output_snapshot,
)
await self.store.update_run(
run_row["id"],
{"status": "failed", "finished_at": datetime.now(UTC), "error": str(exc), "result": result},
)
await self.store.update_task(task["task_id"], task["user_id"], {"last_run_at": datetime.now(UTC), "last_status": "failed"})
return _build_result(task, scheduled_for=scheduled_for, content=result_text, files=result_files)
async def _ensure_thread_meta(self, user_id: str, thread_id: str) -> None:
token = set_current_user(_RuntimeUser(id=user_id))
@@ -40,13 +40,14 @@ dependencies = [
[project.optional-dependencies]
ollama = ["langchain-ollama>=0.3.0"]
postgres = [
"asyncpg>=0.29",
"langgraph-checkpoint-postgres>=3.0.5",
"psycopg[binary]>=3.3.3",
"psycopg-pool>=3.3.0",
]
pymupdf = ["pymupdf4llm>=0.0.17"]
postgres = [
"asyncpg>=0.29",
"langgraph-checkpoint-postgres>=3.0.5",
"psycopg[binary]>=3.3.3",
"psycopg-pool>=3.3.0",
]
mysql = ["asyncmy>=0.2.10"]
pymupdf = ["pymupdf4llm>=0.0.17"]
[build-system]
requires = ["hatchling"]
@@ -24,8 +24,9 @@ dependencies = [
"croniter>=6.2.2",
]
[project.optional-dependencies]
postgres = ["deerflow-harness[postgres]"]
[project.optional-dependencies]
postgres = ["deerflow-harness[postgres]"]
mysql = ["deerflow-harness[mysql]"]
[dependency-groups]
dev = [
@@ -0,0 +1,433 @@
"""Migrate DeerFlow business tables from SQLite to MySQL.
This intentionally excludes LangGraph checkpoint tables (``checkpoints`` and
``writes``). Keep those on SQLite unless/until a MySQL checkpointer is added.
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sqlite3
import sys
from collections.abc import Iterable
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import sqlalchemy as sa
from sqlalchemy import MetaData, text
from sqlalchemy.engine import make_url
from sqlalchemy.ext.asyncio import create_async_engine
BUSINESS_TABLES = [
"users",
"threads_meta",
"runs",
"run_events",
"feedback",
"agents",
"skills",
"scheduled_tasks",
"scheduled_task_subscriptions",
"scheduled_task_runs",
"scheduled_task_delivery_profiles",
"scheduler_threads",
"llm_call_metrics",
"notifications",
"recommended_questions",
"tags",
"tag_assignments",
"thread_shares",
]
JSON_COLUMNS = {
("threads_meta", "metadata_json"),
("runs", "metadata_json"),
("runs", "kwargs_json"),
("run_events", "event_metadata"),
("scheduled_tasks", "execution_context_json"),
("scheduled_task_runs", "result_json"),
("notifications", "payload"),
}
def _json_serializer(obj: object) -> str:
return json.dumps(obj, ensure_ascii=False)
def _add_vendor_path(vendor_dir: str | None) -> None:
candidates: list[Path] = []
if vendor_dir:
candidates.append(Path(vendor_dir))
if env_home := os.getenv("DEER_FLOW_HOME"):
candidates.append(Path(env_home) / "python-vendor")
candidates.append(Path(".deer-flow/python-vendor"))
for candidate in candidates:
resolved = candidate.resolve()
if resolved.exists():
value = str(resolved)
if value not in sys.path:
sys.path.insert(0, value)
def _normalize_mysql_url(raw: str) -> str:
if raw.startswith("mysql://"):
return raw.replace("mysql://", "mysql+asyncmy://", 1)
return raw
def _quote_sqlite_identifier(name: str) -> str:
return '"' + name.replace('"', '""') + '"'
def _sqlite_tables(conn: sqlite3.Connection) -> set[str]:
rows = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
return {str(row[0]) for row in rows}
def _sqlite_columns(conn: sqlite3.Connection, table_name: str) -> list[str]:
rows = conn.execute(f"PRAGMA table_info({_quote_sqlite_identifier(table_name)})").fetchall()
return [str(row[1]) for row in rows]
def _sqlite_count(conn: sqlite3.Connection, table_name: str) -> int:
return int(conn.execute(f"SELECT COUNT(*) FROM {_quote_sqlite_identifier(table_name)}").fetchone()[0])
def _parse_datetime(value: Any) -> Any:
if value is None or isinstance(value, datetime):
return value
if isinstance(value, (int, float)):
return datetime.fromtimestamp(value, tz=timezone.utc).replace(tzinfo=None)
if not isinstance(value, str):
return value
raw = value.strip()
if not raw:
return None
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
parsed = datetime.fromisoformat(raw)
if parsed.tzinfo is not None:
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed
def _parse_json(value: Any) -> Any:
if value is None or isinstance(value, (dict, list, int, float, bool)):
return value
if isinstance(value, bytes):
value = value.decode("utf-8")
if isinstance(value, str):
raw = value.strip()
if not raw:
return None
return json.loads(raw)
return value
def _convert_value(table_name: str, column: sa.Column, value: Any) -> Any:
if value is None:
return None
if (table_name, column.name) in JSON_COLUMNS or isinstance(column.type, sa.JSON):
parsed = _parse_json(value)
if not isinstance(column.type, sa.JSON):
return json.dumps(parsed, ensure_ascii=False)
return parsed
if isinstance(column.type, sa.DateTime):
return _parse_datetime(value)
if isinstance(column.type, sa.Boolean):
return bool(value)
return value
def _build_rows(
*,
table_name: str,
table: sa.Table,
rows: Iterable[sqlite3.Row],
columns: list[str],
) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for row in rows:
item: dict[str, Any] = {}
for column_name in columns:
column = table.c[column_name]
item[column_name] = _convert_value(table_name, column, row[column_name])
result.append(item)
return result
async def _create_database_if_needed(mysql_url: str) -> None:
parsed = make_url(mysql_url)
db_name = parsed.database
if not db_name:
raise ValueError("MySQL URL must include a database name")
maintenance = parsed.set(database="mysql")
engine = create_async_engine(
maintenance.render_as_string(hide_password=False),
isolation_level="AUTOCOMMIT",
pool_pre_ping=True,
)
quoted = "`" + db_name.replace("`", "``") + "`"
try:
async with engine.connect() as conn:
await conn.execute(text(f"CREATE DATABASE IF NOT EXISTS {quoted} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"))
finally:
await engine.dispose()
async def _create_schema(mysql_url: str) -> None:
from deerflow.persistence.base import Base
import deerflow.persistence.models # noqa: F401
engine = create_async_engine(mysql_url, pool_pre_ping=True, json_serializer=_json_serializer)
try:
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
finally:
await engine.dispose()
def _quote_mysql_identifier(name: str) -> str:
return "`" + name.replace("`", "``") + "`"
async def _drop_empty_target_schema(mysql_url: str, table_names: list[str]) -> None:
"""Drop known target tables only when all existing known tables are empty.
This is intentionally conservative. It lets a failed first attempt clean up
half-created MySQL tables before re-running with fixed schema definitions,
while refusing to touch a database that already contains migrated rows.
"""
engine = create_async_engine(mysql_url, pool_pre_ping=True)
try:
async with engine.begin() as conn:
result = await conn.execute(
text(
"SELECT TABLE_NAME FROM information_schema.TABLES "
"WHERE TABLE_SCHEMA = DATABASE()"
)
)
existing = {str(row[0]) for row in result}
candidates = [name for name in table_names if name in existing]
if not candidates:
return
non_empty: list[str] = []
for table_name in candidates:
count = int(
(
await conn.execute(
text(f"SELECT COUNT(*) FROM {_quote_mysql_identifier(table_name)}")
)
).scalar_one()
)
if count > 0:
non_empty.append(table_name)
if non_empty:
joined = ", ".join(non_empty)
print(f"Target MySQL has non-empty tables; skip empty-schema cleanup: {joined}")
return
await conn.execute(text("SET FOREIGN_KEY_CHECKS=0"))
try:
for table_name in reversed(candidates):
await conn.execute(text(f"DROP TABLE {_quote_mysql_identifier(table_name)}"))
print(f"dropped empty target table: {table_name}")
finally:
await conn.execute(text("SET FOREIGN_KEY_CHECKS=1"))
finally:
await engine.dispose()
def _run_alembic(mysql_url: str) -> None:
from alembic import command
from alembic.config import Config
import deerflow.persistence as persistence_pkg
migrations_dir = str(Path(persistence_pkg.__file__).parent / "migrations")
cfg = Config()
cfg.set_main_option("script_location", migrations_dir)
cfg.set_main_option("sqlalchemy.url", mysql_url.replace("%", "%%"))
command.upgrade(cfg, "head")
async def _reflect_target(mysql_url: str) -> tuple[sa.ext.asyncio.AsyncEngine, MetaData]:
engine = create_async_engine(
mysql_url,
pool_pre_ping=True,
pool_recycle=1800,
json_serializer=_json_serializer,
)
metadata = MetaData()
async with engine.begin() as conn:
await conn.run_sync(metadata.reflect)
return engine, metadata
async def _target_count(conn, table: sa.Table) -> int:
return int((await conn.execute(sa.select(sa.func.count()).select_from(table))).scalar_one())
async def _ensure_target_empty(engine, metadata: MetaData, table_names: list[str]) -> None:
non_empty: list[str] = []
async with engine.connect() as conn:
for table_name in table_names:
table = metadata.tables.get(table_name)
if table is not None and await _target_count(conn, table) > 0:
non_empty.append(table_name)
if non_empty:
joined = ", ".join(non_empty)
raise RuntimeError(f"Target MySQL tables are not empty: {joined}. Re-run with --truncate-target if this is intentional.")
async def _truncate_target(engine, metadata: MetaData, table_names: list[str]) -> None:
async with engine.begin() as conn:
await conn.execute(text("SET FOREIGN_KEY_CHECKS=0"))
try:
for table_name in reversed(table_names):
table = metadata.tables.get(table_name)
if table is not None:
await conn.execute(table.delete())
finally:
await conn.execute(text("SET FOREIGN_KEY_CHECKS=1"))
async def _copy_table(
*,
source: sqlite3.Connection,
engine,
metadata: MetaData,
table_name: str,
batch_size: int,
) -> tuple[int, int]:
table = metadata.tables.get(table_name)
if table is None:
print(f"skip {table_name}: not present in MySQL schema")
return 0, 0
source_columns = _sqlite_columns(source, table_name)
columns = [name for name in source_columns if name in table.c]
if not columns:
print(f"skip {table_name}: no shared columns")
return 0, 0
selected = ", ".join(_quote_sqlite_identifier(name) for name in columns)
query = f"SELECT {selected} FROM {_quote_sqlite_identifier(table_name)}"
cursor = source.execute(query)
copied = 0
async with engine.begin() as conn:
while True:
batch = cursor.fetchmany(batch_size)
if not batch:
break
payload = _build_rows(table_name=table_name, table=table, rows=batch, columns=columns)
if payload:
await conn.execute(table.insert(), payload)
copied += len(payload)
expected = _sqlite_count(source, table_name)
return expected, copied
async def _verify_counts(source: sqlite3.Connection, engine, metadata: MetaData, table_names: list[str]) -> None:
failures: list[str] = []
async with engine.connect() as conn:
for table_name in table_names:
table = metadata.tables.get(table_name)
if table is None:
continue
source_count = _sqlite_count(source, table_name)
target_count = await _target_count(conn, table)
if source_count != target_count:
failures.append(f"{table_name}: sqlite={source_count}, mysql={target_count}")
if failures:
raise RuntimeError("Row count verification failed:\n " + "\n ".join(failures))
async def _migrate_data(args: argparse.Namespace, mysql_url: str) -> None:
source_path = Path(args.sqlite).resolve()
if not source_path.is_file():
raise FileNotFoundError(f"SQLite database not found: {source_path}")
source = sqlite3.connect(str(source_path))
source.row_factory = sqlite3.Row
try:
source_tables = _sqlite_tables(source)
table_names = [name for name in BUSINESS_TABLES if name in source_tables]
missing = [name for name in BUSINESS_TABLES if name not in source_tables]
if missing:
print("SQLite tables not present, skipped: " + ", ".join(missing))
engine, metadata = await _reflect_target(mysql_url)
try:
if args.truncate_target:
await _truncate_target(engine, metadata, table_names)
else:
await _ensure_target_empty(engine, metadata, table_names)
for table_name in table_names:
expected, copied = await _copy_table(
source=source,
engine=engine,
metadata=metadata,
table_name=table_name,
batch_size=args.batch_size,
)
print(f"{table_name}: copied {copied}/{expected}")
await _verify_counts(source, engine, metadata, table_names)
finally:
await engine.dispose()
finally:
source.close()
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Migrate DeerFlow business tables from SQLite to MySQL")
parser.add_argument("--sqlite", required=True, help="Source SQLite deerflow.db path")
parser.add_argument("--mysql-url", default=os.getenv("MYSQL_DATABASE_URL", ""), help="Target MySQL SQLAlchemy URL")
parser.add_argument("--vendor-dir", default="", help="Optional Python vendor directory containing asyncmy")
parser.add_argument("--batch-size", type=int, default=1000)
parser.add_argument("--create-database", action="store_true", help="Create the target MySQL database if it does not exist")
parser.add_argument("--drop-empty-target-schema", action="store_true", help="Drop known target tables first, but only when all are empty")
parser.add_argument("--skip-schema", action="store_true", help="Do not create/update target schema before copying")
parser.add_argument("--truncate-target", action="store_true", help="Delete existing target rows before copying")
return parser.parse_args()
def main() -> None:
args = _parse_args()
_add_vendor_path(args.vendor_dir)
mysql_url = _normalize_mysql_url(args.mysql_url.strip())
if not mysql_url:
raise SystemExit("Missing --mysql-url or MYSQL_DATABASE_URL")
if args.create_database:
asyncio.run(_create_database_if_needed(mysql_url))
if not args.skip_schema:
if args.drop_empty_target_schema:
asyncio.run(_drop_empty_target_schema(mysql_url, BUSINESS_TABLES + ["alembic_version"]))
asyncio.run(_create_schema(mysql_url))
_run_alembic(mysql_url)
asyncio.run(_migrate_data(args, mysql_url))
print("Business table migration completed successfully.")
if __name__ == "__main__":
main()
@@ -0,0 +1,47 @@
"""Patch config.yaml for the MySQL business DB + SQLite checkpointer split."""
from __future__ import annotations
import argparse
from pathlib import Path
import yaml
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Switch DeerFlow config.yaml to MySQL business storage")
parser.add_argument("--config", required=True, help="Path to config.yaml")
parser.add_argument("--mysql-url-env", default="MYSQL_DATABASE_URL", help="Environment variable referenced by database.mysql_url")
parser.add_argument("--checkpointer-sqlite", required=True, help="SQLite deerflow.db path used by LangGraph checkpointer")
parser.add_argument("--pool-size", type=int, default=10)
return parser.parse_args()
def main() -> None:
args = _parse_args()
config_path = Path(args.config)
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
if not isinstance(data, dict):
raise SystemExit(f"Expected a mapping in {config_path}")
database = dict(data.get("database") or {})
database["backend"] = "mysql"
database["mysql_url"] = f"${args.mysql_url_env}"
database.setdefault("pool_size", args.pool_size)
database.setdefault("echo_sql", False)
data["database"] = database
data["checkpointer"] = {
"type": "sqlite",
"connection_string": args.checkpointer_sqlite,
}
config_path.write_text(
yaml.safe_dump(data, allow_unicode=True, sort_keys=False),
encoding="utf-8",
)
print(f"Updated {config_path}: database.backend=mysql, checkpointer.type=sqlite")
if __name__ == "__main__":
main()
@@ -0,0 +1,77 @@
"""Tests for admin (cross-user) deletion of scheduled-task runs.
Regular users may only delete runs they own; admins may delete any run. This
covers the service/store layer that the admin-aware ``DELETE /runs/{id}``
route relies on.
"""
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from deerflow.persistence.scheduled_tasks import MemoryScheduledTaskStore
from deerflow.runtime.scheduler.service import ScheduledTaskService
@pytest.fixture
def store() -> MemoryScheduledTaskStore:
return MemoryScheduledTaskStore()
def _build_service(store: MemoryScheduledTaskStore) -> ScheduledTaskService:
service = ScheduledTaskService(app=None, store=store, poll_interval_seconds=60.0)
service._element_client = None # noqa: SLF001
return service
async def _seed_run(store: MemoryScheduledTaskStore, *, run_id: str, task_id: str, user_id: str) -> None:
await store.create_run_once(
{
"id": run_id,
"task_id": task_id,
"user_id": user_id,
"scheduler_thread_id": f"thread-{user_id}",
"scheduled_for": datetime.now(UTC),
"status": "succeeded",
"started_at": datetime.now(UTC),
}
)
@pytest.mark.asyncio
async def test_admin_delete_run_removes_any_owner_run(store):
await _seed_run(store, run_id="r1", task_id="t1", user_id="alice")
service = _build_service(store)
assert await service.admin_delete_run("r1") is True
assert await store.get_run_any("r1") is None
@pytest.mark.asyncio
async def test_admin_delete_run_missing_returns_false(store):
service = _build_service(store)
assert await service.admin_delete_run("nope") is False
@pytest.mark.asyncio
async def test_owner_delete_run_still_scoped_to_owner(store):
"""A non-owner, non-subscriber deleting another user's run gets False."""
await _seed_run(store, run_id="r1", task_id="t1", user_id="alice")
service = _build_service(store)
assert await service.delete_run("bob", "r1") is False
assert await store.get_run_any("r1") is not None # untouched
# The owner can still delete their own run.
assert await service.delete_run("alice", "r1") is True
assert await store.get_run_any("r1") is None
@pytest.mark.asyncio
async def test_store_delete_run_any_independent_of_owner(store):
await _seed_run(store, run_id="r1", task_id="t1", user_id="alice")
assert await store.delete_run_any("r1") is True
assert await store.delete_run_any("r1") is False
@@ -0,0 +1,116 @@
"""Tests for per-user scheduled-task name uniqueness.
Task names are unique per user (``uq_scheduled_tasks_user_name``). The service
proactively rejects collisions with :class:`DuplicateTaskNameError` so the API
returns a 409 instead of letting a raw DB ``IntegrityError`` surface as a 500.
"""
from __future__ import annotations
from datetime import UTC, datetime
import pytest
from deerflow.persistence.scheduled_tasks import MemoryScheduledTaskStore
from deerflow.runtime.scheduler import DuplicateTaskNameError
from deerflow.runtime.scheduler.service import ScheduledTaskService
@pytest.fixture
def store() -> MemoryScheduledTaskStore:
return MemoryScheduledTaskStore()
def _build_service(store: MemoryScheduledTaskStore) -> ScheduledTaskService:
service = ScheduledTaskService(app=None, store=store, poll_interval_seconds=60.0)
service._element_client = None # noqa: SLF001
return service
def _seed_task(store: MemoryScheduledTaskStore, *, task_id: str, user_id: str, name: str) -> dict:
task = {
"task_id": task_id,
"user_id": user_id,
"scheduler_thread_id": f"thread-{user_id}",
"name": name,
"prompt": "hello",
"schedule_text": "0 9 * * *",
"cron_expr": "0 9 * * *",
"timezone": "UTC",
"enabled": True,
"next_run_at": datetime.now(UTC),
"execution_context": {},
}
store._tasks[task_id] = task # noqa: SLF001
return task
@pytest.mark.asyncio
async def test_create_task_rejects_duplicate_name(store):
"""A second task with the same (user_id, name) is refused before any insert."""
_seed_task(store, task_id="t1", user_id="alice", name="日报")
service = _build_service(store)
with pytest.raises(DuplicateTaskNameError):
await service.create_task(user_id="alice", name="日报", prompt="p", schedule="0 9 * * *")
@pytest.mark.asyncio
async def test_create_task_treats_whitespace_padded_name_as_duplicate(store):
"""The name is trimmed before the uniqueness check, matching what is stored."""
_seed_task(store, task_id="t1", user_id="alice", name="日报")
service = _build_service(store)
with pytest.raises(DuplicateTaskNameError):
await service.create_task(user_id="alice", name=" 日报 ", prompt="p", schedule="0 9 * * *")
@pytest.mark.asyncio
async def test_create_task_allows_same_name_for_different_user(store):
"""The constraint is per user — another user may reuse the name.
The create proceeds past the name check and only fails later because the
test service has no app wired for scheduler-thread setup; the point here is
that no DuplicateTaskNameError is raised.
"""
_seed_task(store, task_id="t1", user_id="alice", name="日报")
service = _build_service(store)
with pytest.raises(Exception) as exc_info:
await service.create_task(user_id="bob", name="日报", prompt="p", schedule="0 9 * * *")
assert not isinstance(exc_info.value, DuplicateTaskNameError)
@pytest.mark.asyncio
async def test_update_task_rejects_rename_to_existing_name(store):
"""Renaming a task onto a sibling's name is refused."""
_seed_task(store, task_id="t1", user_id="alice", name="日报")
_seed_task(store, task_id="t2", user_id="alice", name="周报")
service = _build_service(store)
with pytest.raises(DuplicateTaskNameError):
await service.update_task("alice", "t2", {"name": "日报"})
@pytest.mark.asyncio
async def test_update_task_allows_keeping_own_name(store):
"""Re-saving a task without changing its name is not a self-collision."""
_seed_task(store, task_id="t1", user_id="alice", name="日报")
service = _build_service(store)
updated = await service.update_task("alice", "t1", {"name": "日报", "prompt": "new"})
assert updated is not None
assert updated["name"] == "日报"
assert updated["prompt"] == "new"
@pytest.mark.asyncio
async def test_admin_update_task_rejects_rename_to_existing_name(store):
"""Admin cross-user rename still honours the owner's name uniqueness."""
_seed_task(store, task_id="t1", user_id="alice", name="日报")
_seed_task(store, task_id="t2", user_id="alice", name="周报")
service = _build_service(store)
with pytest.raises(DuplicateTaskNameError):
await service.admin_update_task("t2", {"name": "日报"})
@@ -0,0 +1,80 @@
"""Regression tests for the scheduled-task run lookup helper.
Guards against a recursion bug where ``_load_run`` called itself instead of
delegating to ``service.get_run`` — every ``GET /api/scheduled-tasks/runs/{id}``
blew the stack with ``RecursionError`` and returned 500.
"""
from __future__ import annotations
import pytest
from app.gateway.routers import scheduled_tasks as st
class _FakeService:
"""Records delegation so the test can assert no self-recursion happened."""
def __init__(self, *, owner_run=None, admin_run=None) -> None:
self._owner_run = owner_run
self._admin_run = admin_run
self.get_run_calls: list[tuple[str, str]] = []
self.admin_get_run_calls: list[str] = []
async def get_run(self, user_id: str, run_id: str):
self.get_run_calls.append((user_id, run_id))
return self._owner_run
async def admin_get_run(self, run_id: str):
self.admin_get_run_calls.append(run_id)
return self._admin_run
class _User:
def __init__(self, system_role: str | None) -> None:
self.system_role = system_role
def _patch_user(monkeypatch, user) -> None:
async def _fake(_request):
return user
monkeypatch.setattr(st, "get_optional_user_from_request", _fake)
@pytest.mark.asyncio
async def test_load_run_returns_owner_run_without_recursing(monkeypatch):
"""Owner lookup hits service.get_run exactly once — no self-recursion."""
_patch_user(monkeypatch, _User("user"))
service = _FakeService(owner_run={"id": "r1"})
run = await st._load_run(service, object(), "r1", "alice")
assert run == {"id": "r1"}
assert service.get_run_calls == [("alice", "r1")]
assert service.admin_get_run_calls == [] # admin fallback skipped when found
@pytest.mark.asyncio
async def test_load_run_admin_fallback_when_owner_miss(monkeypatch):
"""An admin who is not the owner falls back to admin_get_run."""
_patch_user(monkeypatch, _User("admin"))
service = _FakeService(owner_run=None, admin_run={"id": "r2"})
run = await st._load_run(service, object(), "r2", "bob")
assert run == {"id": "r2"}
assert service.get_run_calls == [("bob", "r2")]
assert service.admin_get_run_calls == ["r2"]
@pytest.mark.asyncio
async def test_load_run_non_admin_miss_returns_none(monkeypatch):
"""A non-admin who is not the owner gets None (router turns it into 404)."""
_patch_user(monkeypatch, _User("user"))
service = _FakeService(owner_run=None, admin_run={"id": "r3"})
run = await st._load_run(service, object(), "r3", "carol")
assert run is None
assert service.admin_get_run_calls == [] # no admin fallback for non-admins
@@ -17,6 +17,7 @@ import pytest
from app.gateway.routers.skills import (
SkillDetailRequest,
SkillResponse,
_can_view_skill_source,
_skill_to_response,
)
from deerflow.config import paths as paths_module
@@ -160,3 +161,29 @@ def test_skill_to_response_detail_none_without_ownership(isolated_paths) -> None
resp = _skill_to_response(skill, None)
assert resp.detail is None
assert resp.builtin is True
# ---------------------------------------------------------------------------
# SKILL.md source visibility — admin / publisher only
# ---------------------------------------------------------------------------
def test_admin_can_view_any_skill_source() -> None:
# Admin sees a built-in skill's source (no owner).
assert _can_view_skill_source(SkillCategory.PUBLIC, None, "user-a", is_admin=True) is True
# ...and another user's custom skill.
assert _can_view_skill_source(SkillCategory.CUSTOM, "user-b", "user-a", is_admin=True) is True
def test_publisher_can_view_own_custom_skill_source() -> None:
assert _can_view_skill_source(SkillCategory.CUSTOM, "user-a", "user-a", is_admin=False) is True
def test_non_owner_cannot_view_custom_skill_source() -> None:
# A different user — even if the skill is published to them — cannot read source.
assert _can_view_skill_source(SkillCategory.CUSTOM, "user-b", "user-a", is_admin=False) is False
def test_non_admin_cannot_view_builtin_skill_source() -> None:
# Built-in / public skills have no publisher, so non-admins never see source.
assert _can_view_skill_source(SkillCategory.PUBLIC, None, "user-a", is_admin=False) is False
+87
View File
@@ -0,0 +1,87 @@
config_version: 8
log_level: info
models:
- name: deepseek-chat
display_name: DeepSeek Chat
use: langchain_openai:ChatOpenAI
model: deepseek-chat
api_key: sk-e1c22b7931dc446f8b67e57878edd105
base_url: https://api.deepseek.com
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: deepseek-v4-flash
display_name: DeepSeek V4 Flash
use: langchain_openai:ChatOpenAI
model: deepseek-v4-flash
api_key: sk-e1c22b7931dc446f8b67e57878edd105
base_url: https://api.deepseek.com
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
- name: deepseek-v4-pro
display_name: DeepSeek V4 Pro
use: langchain_openai:ChatOpenAI
model: deepseek-v4-pro
api_key: sk-e1c22b7931dc446f8b67e57878edd105
base_url: https://api.deepseek.com
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: true
supports_thinking: true
tool_groups:
- name: web
- name: file:read
- name: file:write
- name: bash
tools:
- name: web_search
group: web
use: deerflow.community.ddg_search.tools:web_search_tool
max_results: 5
- name: ls
group: file:read
use: deerflow.sandbox.tools:ls_tool
- name: read_file
group: file:read
use: deerflow.sandbox.tools:read_file_tool
- name: glob
group: file:read
use: deerflow.sandbox.tools:glob_tool
max_results: 200
- name: grep
group: file:read
use: deerflow.sandbox.tools:grep_tool
max_results: 100
- name: write_file
group: file:write
use: deerflow.sandbox.tools:write_file_tool
- name: str_replace
group: file:write
use: deerflow.sandbox.tools:str_replace_tool
- name: bash
group: bash
use: deerflow.sandbox.tools:bash_tool
sandbox:
use: deerflow.sandbox.local:LocalSandboxProvider
allow_host_bash: true
skills:
path: /app/skills
agents_api:
enabled: true
database:
backend: mysql
mysql_url: $MYSQL_DATABASE_URL
pool_size: 10
echo_sql: false
checkpointer:
type: sqlite
connection_string: D:\cursor-work\offline-backend-20260512\backend\.deer-flow\data\deerflow.db
@@ -0,0 +1,215 @@
param(
[string]$MysqlDatabaseUrl = $env:MYSQL_DATABASE_URL,
[string]$MysqlPipPackages = $(if ($env:MYSQL_PIP_PACKAGES) { $env:MYSQL_PIP_PACKAGES } else { "asyncmy==0.2.10" }),
[string]$CreateDatabase = $(if ($env:CREATE_DATABASE) { $env:CREATE_DATABASE } else { "1" }),
[string]$DropEmptyTargetSchema = $(if ($env:DROP_EMPTY_TARGET_SCHEMA) { $env:DROP_EMPTY_TARGET_SCHEMA } else { "1" }),
[string]$TruncateTarget = $(if ($env:TRUNCATE_TARGET) { $env:TRUNCATE_TARGET } else { "0" }),
[string]$SqliteDb = $(if ($env:SQLITE_DB) { $env:SQLITE_DB } else { "" }),
[string]$ConfigFile = $(if ($env:DEER_FLOW_CONFIG_PATH) { $env:DEER_FLOW_CONFIG_PATH } else { "" }),
[string]$EnvFile = $(if ($env:ENV_FILE) { $env:ENV_FILE } else { "" }),
[string]$PythonExe = $(if ($env:PYTHON_EXE) { $env:PYTHON_EXE } else { "" })
)
$ErrorActionPreference = "Stop"
function Die([string]$Message) {
Write-Error $Message
exit 1
}
function Require-Command([string]$Name) {
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
Die "command not found: $Name"
}
}
function Require-File([string]$Path) {
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
Die "required file not found: $Path"
}
}
function Require-Dir([string]$Path) {
if (-not (Test-Path -LiteralPath $Path -PathType Container)) {
Die "required directory not found: $Path"
}
}
function Resolve-ExistingFile([string]$Path) {
return (Resolve-Path -LiteralPath $Path).Path
}
function Get-EnvFileValue([string]$Path, [string]$Key) {
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
return ""
}
$line = Get-Content -LiteralPath $Path |
Where-Object { $_ -match "^\s*$([regex]::Escape($Key))=" } |
Select-Object -Last 1
if (-not $line) {
return ""
}
return ($line -replace "^\s*$([regex]::Escape($Key))=", "")
}
function Upsert-EnvFileValue([string]$Path, [string]$Key, [string]$Value) {
$existing = @()
if (Test-Path -LiteralPath $Path -PathType Leaf) {
$existing = Get-Content -LiteralPath $Path | Where-Object { $_ -notmatch "^\s*$([regex]::Escape($Key))=" }
}
$existing + "$Key=$Value" | Set-Content -LiteralPath $Path -Encoding UTF8
}
function Is-Enabled([string]$Value) {
return @("1", "true", "yes", "on") -contains $Value.ToLowerInvariant()
}
function Run-Native([string]$Command, [string[]]$Arguments) {
& $Command @Arguments
if ($LASTEXITCODE -ne 0) {
Die "$Command failed with exit code $LASTEXITCODE"
}
}
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$BackendDir = Join-Path $ScriptDir "backend"
$DefaultDeerFlowHome = Join-Path $BackendDir ".deer-flow"
if (-not $ConfigFile) {
$ConfigFile = Join-Path $ScriptDir "config.yaml"
}
if (-not $EnvFile) {
$EnvFile = Join-Path $ScriptDir ".env"
}
if (-not $SqliteDb) {
$SqliteDb = Join-Path $DefaultDeerFlowHome "data\deerflow.db"
}
if ($PythonExe) {
Require-File $PythonExe
$PythonExe = Resolve-ExistingFile $PythonExe
} else {
Require-Command uv
}
Require-File $EnvFile
Require-File $ConfigFile
Require-File $SqliteDb
Require-File (Join-Path $BackendDir "pyproject.toml")
Require-File (Join-Path $BackendDir "scripts\migrate_business_sqlite_to_mysql.py")
Require-File (Join-Path $BackendDir "scripts\update_database_config.py")
Require-File (Join-Path $ScriptDir "extensions_config.json")
Require-File (Join-Path $ScriptDir "skill_evolution.json")
Require-Dir (Join-Path $ScriptDir "skills")
if (-not $MysqlDatabaseUrl) {
$MysqlDatabaseUrl = Get-EnvFileValue $EnvFile "MYSQL_DATABASE_URL"
}
if (-not $MysqlDatabaseUrl) {
Die "MYSQL_DATABASE_URL is required. Example: .\switch_database_to_mysql.ps1 -MysqlDatabaseUrl 'mysql+asyncmy://user:pass@127.0.0.1:3306/deerflow?charset=utf8mb4'"
}
$ConfigFile = Resolve-ExistingFile $ConfigFile
$EnvFile = Resolve-ExistingFile $EnvFile
$SqliteDb = Resolve-ExistingFile $SqliteDb
$DeerFlowHome = if ($env:DEER_FLOW_HOME) { $env:DEER_FLOW_HOME } else { $DefaultDeerFlowHome }
New-Item -ItemType Directory -Force -Path $DeerFlowHome | Out-Null
$DeerFlowHome = (Resolve-Path -LiteralPath $DeerFlowHome).Path
$BackupRoot = Join-Path $DeerFlowHome "backups"
New-Item -ItemType Directory -Force -Path $BackupRoot | Out-Null
$Timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$BackupDir = Join-Path $BackupRoot "mysql-switch-$Timestamp"
New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null
Copy-Item -LiteralPath $ConfigFile -Destination (Join-Path $BackupDir "config.yaml.bak") -Force
Copy-Item -LiteralPath $EnvFile -Destination (Join-Path $BackupDir "env.bak") -Force
Copy-Item -LiteralPath $SqliteDb -Destination (Join-Path $BackupDir "deerflow.db.bak") -Force
$env:MYSQL_DATABASE_URL = $MysqlDatabaseUrl
$env:DEER_FLOW_PROJECT_ROOT = $ScriptDir
$env:DEER_FLOW_HOME = $DeerFlowHome
$env:DEER_FLOW_CONFIG_PATH = $ConfigFile
$env:DEER_FLOW_EXTENSIONS_CONFIG_PATH = Join-Path $ScriptDir "extensions_config.json"
$env:DEER_FLOW_SKILL_EVOLUTION_CONFIG_PATH = Join-Path $ScriptDir "skill_evolution.json"
$env:DEER_FLOW_SKILLS_PATH = Join-Path $ScriptDir "skills"
$PythonPathParts = @(
$BackendDir,
(Join-Path $BackendDir "packages\harness")
)
if ($env:PYTHONPATH) {
$PythonPathParts += $env:PYTHONPATH
}
$env:PYTHONPATH = ($PythonPathParts -join [IO.Path]::PathSeparator)
Push-Location $BackendDir
try {
$packages = @($MysqlPipPackages -split "\s+" | Where-Object { $_ })
if ($PythonExe) {
if ($packages.Count -gt 0) {
Write-Host "Installing MySQL driver into selected Python environment: $MysqlPipPackages"
Run-Native $PythonExe (@("-m", "pip", "install") + $packages)
}
Run-Native $PythonExe @("-c", "import asyncmy")
} else {
if (-not (Test-Path -LiteralPath (Join-Path $BackendDir ".venv") -PathType Container)) {
Write-Host "Creating local uv virtual environment..."
Run-Native "uv" @("venv")
}
if ($packages.Count -gt 0) {
Write-Host "Installing MySQL driver into local backend environment: $MysqlPipPackages"
Run-Native "uv" (@("pip", "install") + $packages)
}
Run-Native "uv" @("run", "--no-sync", "python", "-c", "import asyncmy")
}
$migrationArgs = @(
"scripts\migrate_business_sqlite_to_mysql.py",
"--sqlite", $SqliteDb,
"--mysql-url", $MysqlDatabaseUrl
)
if (Is-Enabled $CreateDatabase) {
$migrationArgs += "--create-database"
}
if (Is-Enabled $DropEmptyTargetSchema) {
$migrationArgs += "--drop-empty-target-schema"
}
if (Is-Enabled $TruncateTarget) {
$migrationArgs += "--truncate-target"
}
Write-Host "Backups written to: $BackupDir"
Write-Host "Migrating business tables to MySQL..."
if ($PythonExe) {
Run-Native $PythonExe $migrationArgs
} else {
Run-Native "uv" (@("run", "--no-sync", "python") + $migrationArgs)
}
$configArgs = @(
"scripts\update_database_config.py",
"--config", $ConfigFile,
"--mysql-url-env", "MYSQL_DATABASE_URL",
"--checkpointer-sqlite", $SqliteDb
)
if ($PythonExe) {
Run-Native $PythonExe $configArgs
} else {
Run-Native "uv" (@("run", "--no-sync", "python") + $configArgs)
}
Upsert-EnvFileValue $EnvFile "MYSQL_DATABASE_URL" $MysqlDatabaseUrl
}
finally {
Pop-Location
}
Write-Host ""
Write-Host "MySQL switch completed."
Write-Host " Config updated: $ConfigFile"
Write-Host " Env updated: $EnvFile"
Write-Host " Backup dir: $BackupDir"
Write-Host ""
Write-Host "Start the local backend from backend directory, for example:"
Write-Host " cd backend"
Write-Host " `$env:PYTHONPATH='.'"
Write-Host " uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"
@@ -0,0 +1,166 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
IMAGE_TAG="${IMAGE_TAG:-deerflow-backend-offline:20260505}"
CONTAINER_NAME="${CONTAINER_NAME:-deerflow-backend}"
IMAGE_TAR="${IMAGE_TAR:-${SCRIPT_DIR}/deerflow-backend-offline-image-20260505.tar}"
MYSQL_DRIVER_IMPORT="${MYSQL_DRIVER_IMPORT:-asyncmy}"
MYSQL_PIP_PACKAGES="${MYSQL_PIP_PACKAGES:-asyncmy==0.2.10}"
CREATE_DATABASE="${CREATE_DATABASE:-1}"
DROP_EMPTY_TARGET_SCHEMA="${DROP_EMPTY_TARGET_SCHEMA:-1}"
TRUNCATE_TARGET="${TRUNCATE_TARGET:-0}"
SQLITE_DB_HOST="${SQLITE_DB_HOST:-${SCRIPT_DIR}/data/data/deerflow.db}"
SQLITE_DB_CONTAINER="${SQLITE_DB_CONTAINER:-/app/backend/.deer-flow/data/deerflow.db}"
VENDOR_DIR_CONTAINER="${VENDOR_DIR_CONTAINER:-/app/backend/.deer-flow/python-vendor}"
die() {
echo "ERROR: $*" >&2
exit 1
}
require_cmd() {
command -v "$1" >/dev/null 2>&1 || die "command not found: $1"
}
require_file() {
[ -f "$1" ] || die "required file not found: $1"
}
read_env_value() {
local key="$1"
[ -f "${SCRIPT_DIR}/.env" ] || return 0
grep -E "^[[:space:]]*${key}=" "${SCRIPT_DIR}/.env" 2>/dev/null | tail -n 1 | sed 's/^[^=]*=//'
}
upsert_env_key() {
local key="$1"
local value="$2"
local tmp
tmp="$(mktemp "${SCRIPT_DIR}/.env.mysql-switch.XXXXXX")"
grep -Ev "^[[:space:]]*${key}=" "${SCRIPT_DIR}/.env" >"${tmp}" || true
printf '%s=%s\n' "${key}" "${value}" >>"${tmp}"
mv "${tmp}" "${SCRIPT_DIR}/.env"
}
require_cmd docker
require_file "${SCRIPT_DIR}/.env"
require_file "${SCRIPT_DIR}/config.yaml"
require_file "${SCRIPT_DIR}/backend/pyproject.toml"
require_file "${SCRIPT_DIR}/backend/app/gateway/app.py"
require_file "${SCRIPT_DIR}/backend/scripts/migrate_business_sqlite_to_mysql.py"
require_file "${SCRIPT_DIR}/backend/scripts/update_database_config.py"
require_file "${SCRIPT_DIR}/extensions_config.json"
require_file "${SCRIPT_DIR}/skill_evolution.json"
[ -d "${SCRIPT_DIR}/skills" ] || die "required directory not found: ${SCRIPT_DIR}/skills"
[ -d "${SCRIPT_DIR}/wheelhouse" ] || die "required directory not found: ${SCRIPT_DIR}/wheelhouse"
MYSQL_DATABASE_URL="${MYSQL_DATABASE_URL:-$(read_env_value MYSQL_DATABASE_URL)}"
[ -n "${MYSQL_DATABASE_URL}" ] || die "MYSQL_DATABASE_URL is required. Example: MYSQL_DATABASE_URL='mysql+asyncmy://user:pass@host:3306/deerflow?charset=utf8mb4' bash switch_database_to_mysql.sh"
if docker ps --format '{{.Names}}' | awk -v name="${CONTAINER_NAME}" '$0 == name { found=1 } END { exit(found ? 0 : 1) }'; then
die "container ${CONTAINER_NAME} is running. Stop the backend first: bash stop_backend.sh"
fi
require_file "${SQLITE_DB_HOST}"
mkdir -p "${SCRIPT_DIR}/data/backups"
timestamp="$(date +%Y%m%d_%H%M%S)"
backup_dir="${SCRIPT_DIR}/data/backups/mysql-switch-${timestamp}"
mkdir -p "${backup_dir}"
cp "${SCRIPT_DIR}/config.yaml" "${backup_dir}/config.yaml.bak"
cp "${SCRIPT_DIR}/.env" "${backup_dir}/env.bak"
cp "${SQLITE_DB_HOST}" "${backup_dir}/deerflow.db.bak"
upsert_env_key "MYSQL_DATABASE_URL" "${MYSQL_DATABASE_URL}"
if ! docker image inspect "${IMAGE_TAG}" >/dev/null 2>&1; then
require_file "${IMAGE_TAR}"
echo "Loading Docker image from ${IMAGE_TAR}"
docker load -i "${IMAGE_TAR}"
fi
if docker ps -a --format '{{.Names}}' | awk -v name="${CONTAINER_NAME}-mysql-switch" '$0 == name { found=1 } END { exit(found ? 0 : 1) }'; then
docker rm -f "${CONTAINER_NAME}-mysql-switch" >/dev/null
fi
echo "Backups written to: ${backup_dir}"
echo "Migrating business tables to MySQL..."
docker run --rm \
--name "${CONTAINER_NAME}-mysql-switch" \
--env-file "${SCRIPT_DIR}/.env" \
-e MYSQL_DATABASE_URL="${MYSQL_DATABASE_URL}" \
-e MYSQL_DRIVER_IMPORT="${MYSQL_DRIVER_IMPORT}" \
-e MYSQL_PIP_PACKAGES="${MYSQL_PIP_PACKAGES}" \
-e CREATE_DATABASE="${CREATE_DATABASE}" \
-e DROP_EMPTY_TARGET_SCHEMA="${DROP_EMPTY_TARGET_SCHEMA}" \
-e TRUNCATE_TARGET="${TRUNCATE_TARGET}" \
-e VENDOR_DIR_CONTAINER="${VENDOR_DIR_CONTAINER}" \
-e DEER_FLOW_PROJECT_ROOT=/app \
-e DEER_FLOW_HOME=/app/backend/.deer-flow \
-e DEER_FLOW_CONFIG_PATH=/app/config.yaml \
-e DEER_FLOW_EXTENSIONS_CONFIG_PATH=/app/extensions_config.json \
-e DEER_FLOW_SKILL_EVOLUTION_CONFIG_PATH=/app/skill_evolution.json \
-e DEER_FLOW_SKILLS_PATH=/app/skills \
-e UV_LINK_MODE=copy \
-v "${SCRIPT_DIR}/backend/app:/app/backend/app" \
-v "${SCRIPT_DIR}/backend/packages:/app/backend/packages" \
-v "${SCRIPT_DIR}/backend/scripts:/app/backend/scripts" \
-v "${SCRIPT_DIR}/backend/pyproject.toml:/app/backend/pyproject.toml:ro" \
-v "${SCRIPT_DIR}/backend/uv.lock:/app/backend/uv.lock:ro" \
-v "${SCRIPT_DIR}/backend/langgraph.json:/app/backend/langgraph.json:ro" \
-v "${SCRIPT_DIR}/config.yaml:/app/config.yaml" \
-v "${SCRIPT_DIR}/extensions_config.json:/app/extensions_config.json:ro" \
-v "${SCRIPT_DIR}/skill_evolution.json:/app/skill_evolution.json:ro" \
-v "${SCRIPT_DIR}/skills:/app/skills:ro" \
-v "${SCRIPT_DIR}/wheelhouse:/app/wheelhouse:ro" \
-v "${SCRIPT_DIR}/data:/app/backend/.deer-flow" \
"${IMAGE_TAG}" \
sh -c '
set -eu
cd /app/backend
mkdir -p "${VENDOR_DIR_CONTAINER:-/app/backend/.deer-flow/python-vendor}"
export PYTHONPATH=".:${VENDOR_DIR_CONTAINER:-/app/backend/.deer-flow/python-vendor}"
if ! uv run --no-sync python -c "import ${MYSQL_DRIVER_IMPORT:-asyncmy}" >/dev/null 2>&1; then
echo "Installing ${MYSQL_PIP_PACKAGES:-asyncmy==0.2.10} into ${VENDOR_DIR_CONTAINER:-/app/backend/.deer-flow/python-vendor}"
uv pip install \
--target "${VENDOR_DIR_CONTAINER:-/app/backend/.deer-flow/python-vendor}" \
--no-index \
--find-links /app/wheelhouse \
${MYSQL_PIP_PACKAGES:-asyncmy==0.2.10}
fi
MIGRATION_FLAGS=""
if [ "${CREATE_DATABASE:-1}" = "1" ]; then
MIGRATION_FLAGS="${MIGRATION_FLAGS} --create-database"
fi
if [ "${DROP_EMPTY_TARGET_SCHEMA:-1}" = "1" ]; then
MIGRATION_FLAGS="${MIGRATION_FLAGS} --drop-empty-target-schema"
fi
if [ "${TRUNCATE_TARGET:-0}" = "1" ]; then
MIGRATION_FLAGS="${MIGRATION_FLAGS} --truncate-target"
fi
uv run --no-sync python scripts/migrate_business_sqlite_to_mysql.py \
--sqlite "'"${SQLITE_DB_CONTAINER}"'" \
--mysql-url "${MYSQL_DATABASE_URL}" \
--vendor-dir "${VENDOR_DIR_CONTAINER:-/app/backend/.deer-flow/python-vendor}" \
${MIGRATION_FLAGS}
uv run --no-sync python scripts/update_database_config.py \
--config /app/config.yaml \
--mysql-url-env MYSQL_DATABASE_URL \
--checkpointer-sqlite "'"${SQLITE_DB_CONTAINER}"'"
'
echo
echo "MySQL switch completed."
echo " Config updated: ${SCRIPT_DIR}/config.yaml"
echo " Env updated: ${SCRIPT_DIR}/.env"
echo " Backup dir: ${backup_dir}"
echo
echo "Start backend with the unchanged script:"
echo " bash start_backend.sh"