Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
1e5e2581c8
86 files changed
+5703
-487
No files matched your search
@@ -47,11 +47,9 @@ offline-backend-20260505/bundle/start_backend.sh
|
|||||||
offline-backend-20260505/bundle/stop_backend.sh
|
offline-backend-20260505/bundle/stop_backend.sh
|
||||||
|
|
||||||
# offline-backend-20260512 runtime artifacts (large files, local config, data)
|
# offline-backend-20260512 runtime artifacts (large files, local config, data)
|
||||||
offline-backend-20260512/.env
|
|
||||||
offline-backend-20260512/.env.example
|
offline-backend-20260512/.env.example
|
||||||
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
|
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
|
||||||
offline-backend-20260512/SHA256SUMS
|
offline-backend-20260512/SHA256SUMS
|
||||||
offline-backend-20260512/config.yaml
|
|
||||||
offline-backend-20260512/config.yaml.example
|
offline-backend-20260512/config.yaml.example
|
||||||
offline-backend-20260512/data/
|
offline-backend-20260512/data/
|
||||||
offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
|
offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { HashRouter, useLocation } from "react-router-dom";
|
|||||||
import LandingPage from "./pages/LandingPage";
|
import LandingPage from "./pages/LandingPage";
|
||||||
import LoginPage from "./pages/LoginPage";
|
import LoginPage from "./pages/LoginPage";
|
||||||
import PageRoutes from "./pages/PageRoutes";
|
import PageRoutes from "./pages/PageRoutes";
|
||||||
|
import PublicSharePage from "./pages/PublicSharePage";
|
||||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||||
|
|
||||||
function AppProviders({ children }: { children: React.ReactNode }) {
|
function AppProviders({ children }: { children: React.ReactNode }) {
|
||||||
@@ -47,6 +48,8 @@ export function App() {
|
|||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/login/:username" element={<LoginPage />} />
|
<Route path="/login/:username" element={<LoginPage />} />
|
||||||
<Route path="/landing" element={<LandingPage />} />
|
<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="/page/*" element={<PageRoutes />} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import type { AgentThreadState } from "@/core/threads";
|
|||||||
export interface ThreadContextType {
|
export interface ThreadContextType {
|
||||||
thread: BaseStream<AgentThreadState>;
|
thread: BaseStream<AgentThreadState>;
|
||||||
isMock?: boolean;
|
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>(
|
export const ThreadContext = createContext<ThreadContextType | undefined>(
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { Message } from "@langchain/langgraph-sdk";
|
|||||||
import { FileIcon, Loader2Icon } from "lucide-react";
|
import { FileIcon, Loader2Icon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
useMemo,
|
useMemo,
|
||||||
type AnchorHTMLAttributes,
|
type AnchorHTMLAttributes,
|
||||||
type ImgHTMLAttributes,
|
type ImgHTMLAttributes,
|
||||||
@@ -23,6 +25,7 @@ import {
|
|||||||
import { Task, TaskTrigger } from "@/components/ai-elements/task";
|
import { Task, TaskTrigger } from "@/components/ai-elements/task";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
||||||
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
extractContentFromMessage,
|
extractContentFromMessage,
|
||||||
@@ -38,9 +41,33 @@ import { cn } from "@/lib/utils";
|
|||||||
|
|
||||||
import { CopyButton } from "../copy-button";
|
import { CopyButton } from "../copy-button";
|
||||||
|
|
||||||
|
import { ThreadContext } from "./context";
|
||||||
import { MarkdownContent } from "./markdown-content";
|
import { MarkdownContent } from "./markdown-content";
|
||||||
import { MessageTokenUsage } from "./message-token-usage";
|
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({
|
export function MessageListItem({
|
||||||
className,
|
className,
|
||||||
message,
|
message,
|
||||||
@@ -102,6 +129,7 @@ function MessageImage({
|
|||||||
threadId: string;
|
threadId: string;
|
||||||
maxWidth?: string;
|
maxWidth?: string;
|
||||||
}) {
|
}) {
|
||||||
|
const resolveArtifact = useArtifactResolver(threadId);
|
||||||
if (!src) return null;
|
if (!src) return null;
|
||||||
|
|
||||||
const imgClassName = cn("overflow-hidden rounded-lg", `max-w-[${maxWidth}]`);
|
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} />;
|
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 (
|
return (
|
||||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||||
@@ -134,6 +162,7 @@ function MessageContent_({
|
|||||||
}) {
|
}) {
|
||||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||||
const isHuman = message.type === "human";
|
const isHuman = message.type === "human";
|
||||||
|
const resolveArtifact = useArtifactResolver(threadId);
|
||||||
const components = useMemo(
|
const components = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
|
img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
|
||||||
@@ -141,7 +170,7 @@ function MessageContent_({
|
|||||||
),
|
),
|
||||||
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||||
if (href?.startsWith("/mnt/")) {
|
if (href?.startsWith("/mnt/")) {
|
||||||
const url = resolveArtifactURL(href, threadId);
|
const url = resolveArtifact(href);
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
{...props}
|
{...props}
|
||||||
@@ -154,7 +183,7 @@ function MessageContent_({
|
|||||||
return <a {...props} href={href} />;
|
return <a {...props} href={href} />;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[threadId],
|
[threadId, resolveArtifact],
|
||||||
);
|
);
|
||||||
|
|
||||||
const rawContent = extractContentFromMessage(message);
|
const rawContent = extractContentFromMessage(message);
|
||||||
@@ -347,6 +376,7 @@ function RichFileCard({
|
|||||||
threadId: string;
|
threadId: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const resolveArtifact = useArtifactResolver(threadId);
|
||||||
const isUploading = file.status === "uploading";
|
const isUploading = file.status === "uploading";
|
||||||
const isImage = isImageFile(file.filename);
|
const isImage = isImageFile(file.filename);
|
||||||
|
|
||||||
@@ -379,7 +409,7 @@ function RichFileCard({
|
|||||||
|
|
||||||
if (!file.path) return null;
|
if (!file.path) return null;
|
||||||
|
|
||||||
const fileUrl = resolveArtifactURL(file.path, threadId);
|
const fileUrl = resolveArtifact(file.path);
|
||||||
|
|
||||||
if (isImage) {
|
if (isImage) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
|
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||||
import { getAPIClient } from "@/core/api";
|
import { getAPIClient } from "@/core/api";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import {
|
import {
|
||||||
@@ -76,6 +77,9 @@ export function RecentChatList() {
|
|||||||
const [renameThreadId, setRenameThreadId] = useState<string | null>(null);
|
const [renameThreadId, setRenameThreadId] = useState<string | null>(null);
|
||||||
const [renameValue, setRenameValue] = useState("");
|
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(
|
const handleDelete = useCallback(
|
||||||
(threadId: string) => {
|
(threadId: string) => {
|
||||||
deleteThread({ threadId });
|
deleteThread({ threadId });
|
||||||
@@ -115,18 +119,10 @@ export function RecentChatList() {
|
|||||||
}
|
}
|
||||||
}, [renameThread, renameThreadId, renameValue]);
|
}, [renameThread, renameThreadId, renameValue]);
|
||||||
|
|
||||||
const handleShare = useCallback(
|
const handleShare = useCallback((thread: AgentThread) => {
|
||||||
async (thread: AgentThread) => {
|
// Opens the share-code dialog; the recipient imports a copy via that code.
|
||||||
const shareUrl = `${window.location.origin}${pathOfThread(thread)}`;
|
setShareThreadId(thread.thread_id);
|
||||||
try {
|
}, []);
|
||||||
await navigator.clipboard.writeText(shareUrl);
|
|
||||||
toast.success(t.clipboard.linkCopied);
|
|
||||||
} catch {
|
|
||||||
toast.error(t.clipboard.failedToCopyToClipboard);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[t],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleExport = useCallback(
|
const handleExport = useCallback(
|
||||||
async (thread: AgentThread, format: "markdown" | "json") => {
|
async (thread: AgentThread, format: "markdown" | "json") => {
|
||||||
@@ -291,6 +287,15 @@ export function RecentChatList() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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";
|
"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 { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Collapsible,
|
||||||
|
CollapsibleContent,
|
||||||
|
CollapsibleTrigger,
|
||||||
|
} from "@/components/ui/collapsible";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -23,15 +37,18 @@ import {
|
|||||||
} from "@/core/skills/hooks";
|
} from "@/core/skills/hooks";
|
||||||
import type { Skill } from "@/core/skills/type";
|
import type { Skill } from "@/core/skills/type";
|
||||||
import { reasoningPlugins } from "@/core/streamdown";
|
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.
|
* 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;
|
if (!skill) return false;
|
||||||
const auth = getStoredAuth();
|
const auth = getStoredAuth();
|
||||||
if (!auth) return false;
|
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
|
* Reusable skill detail dialog. Given a skill name it fetches the skill
|
||||||
* metadata (including the admin/owner-authored `detail`) and the raw SKILL.md
|
* metadata (including the admin/owner-authored `detail`) and lets authorized
|
||||||
* source, and lets authorized users edit the detail inline.
|
* 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({
|
export function SkillDetailDialog({
|
||||||
skillName,
|
skillName,
|
||||||
@@ -62,17 +80,22 @@ export function SkillDetailDialog({
|
|||||||
// Only fetch while the dialog is open to avoid background requests.
|
// Only fetch while the dialog is open to avoid background requests.
|
||||||
const activeName = open ? skillName : null;
|
const activeName = open ? skillName : null;
|
||||||
const { data: skill, isLoading: isSkillLoading } = useSkill(activeName);
|
const { data: skill, isLoading: isSkillLoading } = useSkill(activeName);
|
||||||
const {
|
|
||||||
data: contentData,
|
|
||||||
isLoading: isContentLoading,
|
|
||||||
error: contentError,
|
|
||||||
} = useSkillContent(activeName);
|
|
||||||
const setDetail = useSetSkillDetail();
|
const setDetail = useSetSkillDetail();
|
||||||
|
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [showSource, setShowSource] = useState(false);
|
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.
|
// Reset transient UI whenever the dialog closes or switches to another skill.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -81,8 +104,6 @@ export function SkillDetailDialog({
|
|||||||
}
|
}
|
||||||
}, [open, skillName]);
|
}, [open, skillName]);
|
||||||
|
|
||||||
const canEdit = useMemo(() => canEditSkillDetail(skill), [skill]);
|
|
||||||
|
|
||||||
function startEditing() {
|
function startEditing() {
|
||||||
setDraft(skill?.detail ?? "");
|
setDraft(skill?.detail ?? "");
|
||||||
setEditing(true);
|
setEditing(true);
|
||||||
@@ -101,22 +122,54 @@ export function SkillDetailDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="flex max-h-[80vh] flex-col gap-3 sm:max-w-2xl">
|
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl">
|
||||||
<DialogHeader>
|
{/* Header */}
|
||||||
<DialogTitle>{skill?.name ?? skillName ?? ""}</DialogTitle>
|
<DialogHeader className="space-y-0 border-b px-6 py-4 text-left">
|
||||||
<DialogDescription>{skill?.description}</DialogDescription>
|
<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>
|
</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 */}
|
{/* Detail — human-authored long-form introduction */}
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-medium">{tx.detailSectionTitle}</h3>
|
<h3 className="text-foreground/90 flex items-center gap-1.5 text-sm font-semibold">
|
||||||
{canEdit && !editing && (
|
<FileTextIcon className="text-muted-foreground size-4" />
|
||||||
|
{tx.detailSectionTitle}
|
||||||
|
</h3>
|
||||||
|
{canManage && !editing && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-7"
|
className="text-muted-foreground hover:text-foreground h-7 px-2"
|
||||||
onClick={startEditing}
|
onClick={startEditing}
|
||||||
>
|
>
|
||||||
<PencilIcon className="size-3.5" />
|
<PencilIcon className="size-3.5" />
|
||||||
@@ -124,15 +177,17 @@ export function SkillDetailDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Textarea
|
<Textarea
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||||
placeholder={tx.detailPlaceholder}
|
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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -154,12 +209,12 @@ export function SkillDetailDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : isSkillLoading ? (
|
) : 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" />
|
<Loader2 className="size-4 animate-spin" />
|
||||||
{t.common.loading}
|
{t.common.loading}
|
||||||
</div>
|
</div>
|
||||||
) : skill?.detail ? (
|
) : 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
|
<MarkdownContent
|
||||||
content={skill.detail}
|
content={skill.detail}
|
||||||
isLoading={false}
|
isLoading={false}
|
||||||
@@ -168,44 +223,55 @@ export function SkillDetailDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground text-sm">
|
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center">
|
||||||
{tx.detailEmpty}
|
<FileTextIcon className="mx-auto mb-2 size-6 opacity-40" />
|
||||||
{!canEdit ? ` ${tx.detailAdminOnlyHint}` : ""}
|
<p className="text-sm">{tx.detailEmpty}</p>
|
||||||
</p>
|
{!canManage && (
|
||||||
)}
|
<p className="mt-1 text-xs">{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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";
|
} from "@/components/ui/sidebar";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
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() {
|
export function WorkspaceNavChatList() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -77,7 +77,7 @@ export function WorkspaceNavChatList() {
|
|||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
isActive={pathname.startsWith("/page/workspace/admin")}
|
isActive={pathname === "/page/workspace/admin/users"}
|
||||||
asChild
|
asChild
|
||||||
>
|
>
|
||||||
<Link className="text-sidebar-foreground" href="/page/workspace/admin/users">
|
<Link className="text-sidebar-foreground" href="/page/workspace/admin/users">
|
||||||
@@ -87,6 +87,22 @@ export function WorkspaceNavChatList() {
|
|||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</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>
|
</SidebarMenu>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -250,6 +250,35 @@ export const enUS: Translations = {
|
|||||||
searchChats: "Search chats",
|
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)
|
// Page titles (document title)
|
||||||
pages: {
|
pages: {
|
||||||
appName: "CM Assistant",
|
appName: "CM Assistant",
|
||||||
|
|||||||
@@ -194,6 +194,32 @@ export interface Translations {
|
|||||||
searchChats: string;
|
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)
|
// Page titles (document title)
|
||||||
pages: {
|
pages: {
|
||||||
appName: string;
|
appName: string;
|
||||||
|
|||||||
@@ -238,6 +238,33 @@ export const zhCN: Translations = {
|
|||||||
searchChats: "搜索对话",
|
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)
|
// Page titles (document title)
|
||||||
pages: {
|
pages: {
|
||||||
appName: "CM 助手",
|
appName: "CM 助手",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
ScheduledTaskResultFileContent,
|
ScheduledTaskResultFileContent,
|
||||||
ScheduledTaskRun,
|
ScheduledTaskRun,
|
||||||
ScheduledTaskRunFileUpdateResponse,
|
ScheduledTaskRunFileUpdateResponse,
|
||||||
|
ScheduledTaskRunMessagesResponse,
|
||||||
ScheduledTaskSubscription,
|
ScheduledTaskSubscription,
|
||||||
SchedulerThread,
|
SchedulerThread,
|
||||||
} from "./types";
|
} 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) {
|
export function scheduledTaskRunDownloadUrl(runId: string) {
|
||||||
return `${baseUrl()}/runs/${encodeURIComponent(runId)}/download`;
|
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";
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
adminDeleteScheduledTask,
|
||||||
|
adminListScheduledTaskRuns,
|
||||||
|
adminTriggerScheduledTask,
|
||||||
|
adminUpdateScheduledTask,
|
||||||
createScheduledTask,
|
createScheduledTask,
|
||||||
deleteScheduledTask,
|
deleteScheduledTask,
|
||||||
deleteScheduledTaskRun,
|
deleteScheduledTaskRun,
|
||||||
getSchedulerThread,
|
getSchedulerThread,
|
||||||
getScheduledTaskDeliveryProfile,
|
getScheduledTaskDeliveryProfile,
|
||||||
getScheduledTaskRun,
|
getScheduledTaskRun,
|
||||||
|
getScheduledTaskRunMessages,
|
||||||
|
listAllScheduledTasks,
|
||||||
listPublishedScheduledTasks,
|
listPublishedScheduledTasks,
|
||||||
listScheduledTaskRuns,
|
listScheduledTaskRuns,
|
||||||
listScheduledTasks,
|
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) {
|
export function useScheduledTaskRun(runId?: string | null) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["scheduled-tasks", "runs", runId],
|
queryKey: ["scheduled-tasks", "runs", runId],
|
||||||
queryFn: () => getScheduledTaskRun(runId!),
|
queryFn: () => getScheduledTaskRun(runId!),
|
||||||
enabled: !!runId,
|
enabled: !!runId,
|
||||||
refetchOnWindowFocus: false,
|
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) {
|
export function usePublishedScheduledTasks(enabled: boolean = true) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["scheduled-tasks", "published"],
|
queryKey: ["scheduled-tasks", "published"],
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export type ScheduledTaskRun = {
|
|||||||
status: "pending" | "running" | "succeeded" | "failed" | string;
|
status: "pending" | "running" | "succeeded" | "failed" | string;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
result?: ScheduledTaskRunResult | null;
|
result?: ScheduledTaskRunResult | null;
|
||||||
|
retry_count?: number | null;
|
||||||
created_at?: string | null;
|
created_at?: string | null;
|
||||||
updated_at?: string | null;
|
updated_at?: string | null;
|
||||||
can_edit?: boolean;
|
can_edit?: boolean;
|
||||||
@@ -106,3 +107,19 @@ export type ScheduledTaskRunFileUpdateResponse = {
|
|||||||
mime_type?: string | null;
|
mime_type?: string | null;
|
||||||
kind?: 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;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { DownloadIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs";
|
||||||
import {
|
import {
|
||||||
WorkspaceBody,
|
WorkspaceBody,
|
||||||
WorkspaceContainer,
|
WorkspaceContainer,
|
||||||
@@ -20,6 +23,7 @@ export default function ChatsPage() {
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { data: threads } = useThreads();
|
const { data: threads } = useThreads();
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = `${t.pages.chats} - ${t.pages.appName}`;
|
document.title = `${t.pages.chats} - ${t.pages.appName}`;
|
||||||
@@ -38,7 +42,17 @@ export default function ChatsPage() {
|
|||||||
<WorkspaceHeader></WorkspaceHeader>
|
<WorkspaceHeader></WorkspaceHeader>
|
||||||
<WorkspaceBody>
|
<WorkspaceBody>
|
||||||
<div className="flex size-full flex-col">
|
<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
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
className="h-12 w-full max-w-(--container-width-md) text-xl"
|
className="h-12 w-full max-w-(--container-width-md) text-xl"
|
||||||
@@ -70,6 +84,10 @@ export default function ChatsPage() {
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</WorkspaceBody>
|
</WorkspaceBody>
|
||||||
|
<ImportConversationsDialog
|
||||||
|
open={importOpen}
|
||||||
|
onOpenChange={setImportOpen}
|
||||||
|
/>
|
||||||
</WorkspaceContainer>
|
</WorkspaceContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
|
||||||
|
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||||
|
import { MessageList } from "@/components/workspace/messages";
|
||||||
|
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||||
|
import { SubtasksProvider } from "@/core/tasks/context";
|
||||||
|
import { getPublicShareSnapshot } from "@/core/thread-shares/api";
|
||||||
|
import type { AgentThreadState } from "@/core/threads";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public, read-only conversation viewer.
|
||||||
|
*
|
||||||
|
* Reachable at `#/share/<code>` with NO authentication — anyone with the link
|
||||||
|
* can open it. It renders a frozen snapshot of a `view`-mode shared
|
||||||
|
* conversation; there is no input box, so viewers cannot send new messages.
|
||||||
|
*/
|
||||||
|
export default function PublicSharePage() {
|
||||||
|
const { shareCode } = useParams<{ shareCode: string }>();
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ["public-share", shareCode],
|
||||||
|
queryFn: () => getPublicShareSnapshot(shareCode!),
|
||||||
|
enabled: !!shareCode,
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A minimal static stand-in for the live LangGraph stream. MessageList only
|
||||||
|
// reads `messages`, `values.artifacts`, `isLoading` and `isThreadLoading`.
|
||||||
|
const thread = useMemo(() => {
|
||||||
|
const messages = data?.messages ?? [];
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
values: {
|
||||||
|
title: data?.title ?? "",
|
||||||
|
messages,
|
||||||
|
artifacts: data?.artifacts ?? [],
|
||||||
|
todos: [],
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
isThreadLoading: false,
|
||||||
|
error: undefined,
|
||||||
|
interrupt: undefined,
|
||||||
|
} as unknown as BaseStream<AgentThreadState>;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background text-muted-foreground flex h-screen items-center justify-center gap-2 text-sm">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
正在加载分享内容…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError || !data) {
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex h-screen flex-col items-center justify-center gap-2 px-6 text-center">
|
||||||
|
<p className="text-lg font-medium">分享不可用</p>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
该分享链接不存在、已被撤销,或不是只读分享。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex h-screen flex-col">
|
||||||
|
<header className="flex shrink-0 flex-col gap-1 border-b px-6 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="truncate text-base font-semibold">
|
||||||
|
{data.title || "未命名对话"}
|
||||||
|
</h1>
|
||||||
|
<span className="bg-muted text-muted-foreground shrink-0 rounded-full px-2 py-0.5 text-xs">
|
||||||
|
只读分享
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
这是一个只读对话快照,你可以查看全部内容,但无法继续提问。
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<main className="min-h-0 flex-1">
|
||||||
|
<SubtasksProvider>
|
||||||
|
<ArtifactsProvider>
|
||||||
|
<ThreadContext.Provider
|
||||||
|
value={{ thread, isMock: true, publicShareCode: shareCode }}
|
||||||
|
>
|
||||||
|
<MessageList
|
||||||
|
threadId={shareCode ?? "public-share"}
|
||||||
|
thread={thread}
|
||||||
|
paddingBottom={48}
|
||||||
|
/>
|
||||||
|
</ThreadContext.Provider>
|
||||||
|
</ArtifactsProvider>
|
||||||
|
</SubtasksProvider>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { ChevronDown, Download, Edit3, FileText, ListChecks, Loader2, Save, Trash2, X } from "lucide-react";
|
import { Activity, ChevronDown, ChevronLeft, Download, Edit3, FileText, Loader2, Save, Trash2, X } from "lucide-react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams, useSearchParams } from "react-router-dom";
|
||||||
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -24,9 +24,11 @@ import {
|
|||||||
getScheduledTaskRunFileContent,
|
getScheduledTaskRunFileContent,
|
||||||
type ScheduledTaskResultFileContent,
|
type ScheduledTaskResultFileContent,
|
||||||
type ScheduledTaskRun,
|
type ScheduledTaskRun,
|
||||||
|
type ScheduledTaskRunMessage,
|
||||||
updateScheduledTaskRunFile,
|
updateScheduledTaskRunFile,
|
||||||
useScheduledTaskActions,
|
useScheduledTaskActions,
|
||||||
useScheduledTaskRun,
|
useScheduledTaskRun,
|
||||||
|
useScheduledTaskRunMessages,
|
||||||
} from "@/core/scheduled-tasks";
|
} from "@/core/scheduled-tasks";
|
||||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -97,12 +99,117 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
|
|||||||
|
|
||||||
type EditState = { draft: string; saving: boolean };
|
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() {
|
export default function ScheduledTaskRunDetailPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { run_id } = useParams();
|
const { run_id } = useParams();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const actions = useScheduledTaskActions();
|
const actions = useScheduledTaskActions();
|
||||||
const { data: run, isLoading } = useScheduledTaskRun(run_id);
|
const { data: run, isLoading } = useScheduledTaskRun(run_id);
|
||||||
const result = run?.result ?? null;
|
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 [fileContents, setFileContents] = useState<ScheduledTaskResultFileContent[]>([]);
|
||||||
const [filesLoading, setFilesLoading] = useState(false);
|
const [filesLoading, setFilesLoading] = useState(false);
|
||||||
const [showMeta, setShowMeta] = useState(false);
|
const [showMeta, setShowMeta] = useState(false);
|
||||||
@@ -257,7 +364,7 @@ export default function ScheduledTaskRunDetailPage() {
|
|||||||
try {
|
try {
|
||||||
await actions.removeRun.mutateAsync(run.id);
|
await actions.removeRun.mutateAsync(run.id);
|
||||||
toast.success("执行记录已删除");
|
toast.success("执行记录已删除");
|
||||||
navigate("/page/workspace/scheduled/tasks");
|
handleBack();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : "删除失败");
|
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")} />
|
<ChevronDown className={cn("size-3 transition-transform", showMeta && "rotate-180")} />
|
||||||
执行信息
|
执行信息
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => navigate("/page/workspace/scheduled/tasks")}>
|
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleBack}>
|
||||||
<ListChecks className="size-3" />
|
<ChevronLeft className="size-3" />
|
||||||
返回记录
|
返回
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleDownloadResult} disabled={!result}>
|
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleDownloadResult} disabled={!result}>
|
||||||
<Download className="size-3" />
|
<Download className="size-3" />
|
||||||
@@ -330,28 +437,45 @@ export default function ScheduledTaskRunDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* 内容区 */}
|
{/* 内容区 */}
|
||||||
{!result && run && (
|
{run && !result && !hasExecution && (
|
||||||
<div className="text-muted-foreground p-4 text-sm">
|
<div className="text-muted-foreground p-4 text-sm">
|
||||||
这条记录暂时还没有结果,任务可能仍在执行中。
|
这条记录暂时还没有结果,任务可能仍在执行中。
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{result && (
|
{run && (result || hasExecution) && (
|
||||||
<Tabs defaultValue={fileCount > 0 ? "files" : "body"} className="flex min-h-0 flex-1 flex-col">
|
<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">
|
<div className="border-b px-3 pt-1">
|
||||||
<TabsList className="h-8">
|
<TabsList className="h-8">
|
||||||
<TabsTrigger value="body" className="text-xs">
|
{result && (
|
||||||
<FileText className="mr-1 size-3" />
|
<TabsTrigger value="body" className="text-xs">
|
||||||
正文
|
<FileText className="mr-1 size-3" />
|
||||||
</TabsTrigger>
|
正文
|
||||||
<TabsTrigger value="files" className="text-xs">
|
</TabsTrigger>
|
||||||
结果文件{fileCount > 0 && `(${fileCount})`}
|
)}
|
||||||
</TabsTrigger>
|
{result && (
|
||||||
<TabsTrigger value="meta" className="text-xs">
|
<TabsTrigger value="files" className="text-xs">
|
||||||
元数据
|
结果文件{fileCount > 0 && `(${fileCount})`}
|
||||||
</TabsTrigger>
|
</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>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="min-h-0 flex-1">
|
<ScrollArea className="min-h-0 flex-1">
|
||||||
|
{result && (
|
||||||
|
<>
|
||||||
<TabsContent value="body" className="m-0 p-4">
|
<TabsContent value="body" className="m-0 p-4">
|
||||||
{result.format === "markdown" || result.format === "mixed" ? (
|
{result.format === "markdown" || result.format === "mixed" ? (
|
||||||
<MarkdownContent
|
<MarkdownContent
|
||||||
@@ -471,6 +595,31 @@ export default function ScheduledTaskRunDetailPage() {
|
|||||||
{resultMetadata}
|
{resultMetadata}
|
||||||
</pre>
|
</pre>
|
||||||
</TabsContent>
|
</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>
|
</ScrollArea>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
BellOff,
|
BellOff,
|
||||||
BellRing,
|
BellRing,
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
TagsIcon,
|
TagsIcon,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
@@ -51,199 +52,17 @@ import {
|
|||||||
useScheduledTasks,
|
useScheduledTasks,
|
||||||
usePublishedScheduledTasks,
|
usePublishedScheduledTasks,
|
||||||
} from "@/core/scheduled-tasks";
|
} 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";
|
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) {
|
function formatDate(value?: string | null) {
|
||||||
return value ? new Date(value).toLocaleString() : "暂无";
|
return value ? new Date(value).toLocaleString() : "暂无";
|
||||||
}
|
}
|
||||||
@@ -256,28 +75,6 @@ function displayUsername(value?: string | null): string {
|
|||||||
return at === -1 ? raw : raw.slice(0, at);
|
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 = {
|
const emptyForm = {
|
||||||
name: "",
|
name: "",
|
||||||
schedule: "",
|
schedule: "",
|
||||||
@@ -288,6 +85,10 @@ const emptyForm = {
|
|||||||
|
|
||||||
export default function ScheduledTasksPage() {
|
export default function ScheduledTasksPage() {
|
||||||
const navigate = useNavigate();
|
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 [search, setSearch] = useState("");
|
||||||
const [tagId, setTagId] = useState<string | null>(null);
|
const [tagId, setTagId] = useState<string | null>(null);
|
||||||
// Name + tag filtering is resolved server-side via the /api/scheduled-tasks query.
|
// 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 [activeTab, setActiveTab] = useState<"mine" | "plaza">("mine");
|
||||||
const { data: published = [], isLoading: publishedLoading } =
|
const { data: published = [], isLoading: publishedLoading } =
|
||||||
usePublishedScheduledTasks(activeTab === "plaza");
|
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 [elementUserId, setElementUserId] = useState("");
|
||||||
const [editing, setEditing] = useState<ScheduledTask | null>(null);
|
const [editing, setEditing] = useState<ScheduledTask | null>(null);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
@@ -504,6 +307,12 @@ export default function ScheduledTasksPage() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={task.task_id}
|
key={task.task_id}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el && pendingScrollRef.current === task.task_id) {
|
||||||
|
pendingScrollRef.current = null;
|
||||||
|
el.scrollIntoView({ block: "center" });
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-card text-card-foreground rounded-md border p-4 text-left transition-colors hover:bg-accent/60",
|
"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",
|
selectedTaskId === task.task_id && "border-primary bg-accent",
|
||||||
@@ -802,7 +611,11 @@ export default function ScheduledTasksPage() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
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" />
|
<Eye className="size-4" />
|
||||||
查看结果
|
查看结果
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import ChatPage from "./ChatPage";
|
|||||||
import AgentsPage from "./AgentsPage";
|
import AgentsPage from "./AgentsPage";
|
||||||
import AgentChatPage from "./AgentChatPage";
|
import AgentChatPage from "./AgentChatPage";
|
||||||
import NewAgentPage from "./NewAgentPage";
|
import NewAgentPage from "./NewAgentPage";
|
||||||
|
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
|
||||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||||
import LlmMetricsPage from "./LlmMetricsPage";
|
import LlmMetricsPage from "./LlmMetricsPage";
|
||||||
@@ -184,6 +185,18 @@ export default function WorkspaceRoutes({
|
|||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="admin/scheduled-tasks"
|
||||||
|
element={
|
||||||
|
getStoredAuth()?.system_role === "admin" ? (
|
||||||
|
<WorkspaceLayout {...layoutProps}>
|
||||||
|
<AdminScheduledTasksPage />
|
||||||
|
</WorkspaceLayout>
|
||||||
|
) : (
|
||||||
|
<Navigate to="/page/workspace/chats/new" replace />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="admin/tags"
|
path="admin/tags"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ import path from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import sourceIdentifierPlugin from "vite-plugin-source-identifier";
|
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
const rootDir = fileURLToPath(new URL(".", import.meta.url));
|
const rootDir = fileURLToPath(new URL(".", import.meta.url));
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), sourceIdentifierPlugin()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: [
|
alias: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 天内会话使用。归档功能正式实现时,需要把“归档文件缺失”作为可预期异常处理。
|
||||||
@@ -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 |
|
| **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) |
|
| **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 |
|
| **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) |
|
| **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. |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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.
|
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 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.
|
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/`)
|
### Sandbox System (`packages/harness/deerflow/sandbox/`)
|
||||||
|
|
||||||
**Interface**: Abstract `Sandbox` with `execute_command`, `read_file`, `write_file`, `list_dir`
|
**Interface**: Abstract `Sandbox` with `execute_command`, `read_file`, `write_file`, `list_dir`
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ from app.gateway.routers import (
|
|||||||
system_settings,
|
system_settings,
|
||||||
tags,
|
tags,
|
||||||
thread_runs,
|
thread_runs,
|
||||||
|
thread_shares,
|
||||||
threads,
|
threads,
|
||||||
uploads,
|
uploads,
|
||||||
writing,
|
writing,
|
||||||
@@ -255,7 +256,7 @@ async def _run_db_migrations() -> None:
|
|||||||
logger.info("Persistence backend=memory — skipping Alembic migrations")
|
logger.info("Persistence backend=memory — skipping Alembic migrations")
|
||||||
return
|
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")
|
migrations_dir = str(Path(_persistence_pkg.__file__).parent / "migrations")
|
||||||
|
|
||||||
def _run_sync() -> None:
|
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)
|
# Thread Runs API (LangGraph Platform-compatible runs lifecycle)
|
||||||
app.include_router(thread_runs.router)
|
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)
|
# Stateless Runs API (stream/wait without a pre-existing thread)
|
||||||
app.include_router(runs.router)
|
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
|
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||||
|
|
||||||
# Paths that never require authentication.
|
# 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, ...] = (
|
_PUBLIC_PATH_PREFIXES: tuple[str, ...] = (
|
||||||
"/health",
|
"/health",
|
||||||
"/docs",
|
"/docs",
|
||||||
"/redoc",
|
"/redoc",
|
||||||
"/openapi.json",
|
"/openapi.json",
|
||||||
|
"/api/public/",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Exact auth paths that are public (login/register/status check).
|
# 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)
|
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
|
from deerflow.persistence.ai_writing_sessions import make_ai_writing_session_store
|
||||||
|
|
||||||
app.state.ai_writing_session_store = make_ai_writing_session_store(sf)
|
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
|
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):
|
def get_ai_writing_session_store(request: Request):
|
||||||
"""Return the AI writing session store, or None when not configured."""
|
"""Return the AI writing session store, or None when not configured."""
|
||||||
return getattr(request.app.state, "ai_writing_session_store", None)
|
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 fastapi.responses import FileResponse, Response, StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from app.gateway.deps import get_current_user, get_local_provider, get_tag_store
|
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 get_scheduled_task_service
|
from deerflow.runtime.scheduler import DuplicateTaskNameError, get_scheduled_task_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
|
router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"])
|
||||||
|
|
||||||
@@ -61,6 +61,26 @@ async def _user_id(request: Request) -> str:
|
|||||||
return user_id
|
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:
|
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.
|
"""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:
|
async def get_scheduled_task_run(run_id: str, request: Request) -> dict:
|
||||||
service = get_scheduled_task_service()
|
service = get_scheduled_task_service()
|
||||||
user_id = await _user_id(request)
|
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:
|
if run is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||||
return run
|
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}")
|
@router.delete("/runs/{run_id}")
|
||||||
async def delete_scheduled_task_run(run_id: str, request: Request) -> dict:
|
async def delete_scheduled_task_run(run_id: str, request: Request) -> dict:
|
||||||
service = get_scheduled_task_service()
|
service = get_scheduled_task_service()
|
||||||
@@ -221,7 +277,15 @@ async def delete_scheduled_task_run(run_id: str, request: Request) -> dict:
|
|||||||
try:
|
try:
|
||||||
deleted = await service.delete_run(user_id, run_id)
|
deleted = await service.delete_run(user_id, run_id)
|
||||||
except PermissionError as exc:
|
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:
|
if not deleted:
|
||||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||||
return {"success": True}
|
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:
|
async def get_scheduled_task_run_file(run_id: str, file_index: int, request: Request) -> dict:
|
||||||
service = get_scheduled_task_service()
|
service = get_scheduled_task_service()
|
||||||
user_id = await _user_id(request)
|
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:
|
if run is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||||
item, path = _run_file_path(run, file_index)
|
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):
|
async def download_scheduled_task_run(run_id: str, request: Request):
|
||||||
service = get_scheduled_task_service()
|
service = get_scheduled_task_service()
|
||||||
user_id = await _user_id(request)
|
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:
|
if run is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
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):
|
async def download_scheduled_task_run_file(run_id: str, file_index: int, request: Request):
|
||||||
service = get_scheduled_task_service()
|
service = get_scheduled_task_service()
|
||||||
user_id = await _user_id(request)
|
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:
|
if run is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
||||||
item, path = _run_file_path(run, file_index)
|
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:
|
async def create_scheduled_task(body: ScheduledTaskCreateRequest, request: Request) -> dict:
|
||||||
service = get_scheduled_task_service()
|
service = get_scheduled_task_service()
|
||||||
user_id = await _user_id(request)
|
user_id = await _user_id(request)
|
||||||
task = await service.create_task(
|
try:
|
||||||
user_id=user_id,
|
task = await service.create_task(
|
||||||
name=body.name,
|
user_id=user_id,
|
||||||
prompt=body.prompt,
|
name=body.name,
|
||||||
schedule=body.schedule,
|
prompt=body.prompt,
|
||||||
timezone=body.timezone,
|
schedule=body.schedule,
|
||||||
execution_agent_name=body.execution_agent_name,
|
timezone=body.timezone,
|
||||||
execution_context=body.execution_context,
|
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)
|
await _enrich_with_creator(task)
|
||||||
return 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:
|
async def update_scheduled_task(task_id: str, body: ScheduledTaskUpdateRequest, request: Request) -> dict:
|
||||||
service = get_scheduled_task_service()
|
service = get_scheduled_task_service()
|
||||||
user_id = await _user_id(request)
|
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:
|
if task is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
raise HTTPException(status_code=404, detail=f"Task {task_id} not found")
|
||||||
await _enrich_with_creator(task)
|
await _enrich_with_creator(task)
|
||||||
@@ -497,3 +567,85 @@ async def delete_scheduled_task_subscription(task_id: str, request: Request) ->
|
|||||||
if not deleted:
|
if not deleted:
|
||||||
raise HTTPException(status_code=404, detail="Subscription not found")
|
raise HTTPException(status_code=404, detail="Subscription not found")
|
||||||
return {"success": True}
|
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"
|
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]:
|
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)
|
owned = await store.get_owned(name, user_id)
|
||||||
if owned is not None:
|
if owned is not None:
|
||||||
@@ -870,15 +888,27 @@ class SkillContentResponse(BaseModel):
|
|||||||
"/skills/{skill_name}/content",
|
"/skills/{skill_name}/content",
|
||||||
response_model=SkillContentResponse,
|
response_model=SkillContentResponse,
|
||||||
summary="Get Skill Markdown Content",
|
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:
|
try:
|
||||||
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
skill_name = skill_name.replace("\r\n", "").replace("\n", "")
|
||||||
skills = get_or_new_skill_storage(app_config=config).load_skills(enabled_only=False)
|
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)
|
skill = next((s for s in skills if s.name == skill_name), None)
|
||||||
if skill is None:
|
if skill is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Skill '{skill_name}' not found")
|
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")
|
content = skill.skill_file.read_text(encoding="utf-8")
|
||||||
return SkillContentResponse(name=skill.name, category=skill.category, content=content)
|
return SkillContentResponse(name=skill.name, category=skill.category, content=content)
|
||||||
except HTTPException:
|
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
|
||||||
+23
-1
@@ -46,6 +46,24 @@ DEFAULT_CONTAINER_PREFIX = "deer-flow-sandbox"
|
|||||||
DEFAULT_IDLE_TIMEOUT = 600 # 10 minutes in seconds
|
DEFAULT_IDLE_TIMEOUT = 600 # 10 minutes in seconds
|
||||||
DEFAULT_REPLICAS = 3 # Maximum concurrent sandbox containers
|
DEFAULT_REPLICAS = 3 # Maximum concurrent sandbox containers
|
||||||
IDLE_CHECK_INTERVAL = 60 # Check every 60 seconds
|
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:
|
def _lock_file_exclusive(lock_file) -> None:
|
||||||
@@ -144,7 +162,7 @@ class AioSandboxProvider(SandboxProvider):
|
|||||||
provisioner_url = self._config.get("provisioner_url")
|
provisioner_url = self._config.get("provisioner_url")
|
||||||
if provisioner_url:
|
if provisioner_url:
|
||||||
logger.info(f"Using remote sandbox backend with provisioner at {provisioner_url}")
|
logger.info(f"Using remote sandbox backend with provisioner at {provisioner_url}")
|
||||||
return RemoteSandboxBackend(provisioner_url=provisioner_url)
|
return RemoteSandboxBackend(provisioner_url=provisioner_url, environment=self._config["environment"])
|
||||||
|
|
||||||
logger.info("Using local container sandbox backend")
|
logger.info("Using local container sandbox backend")
|
||||||
return LocalContainerBackend(
|
return LocalContainerBackend(
|
||||||
@@ -181,6 +199,10 @@ class AioSandboxProvider(SandboxProvider):
|
|||||||
def _resolve_env_vars(env_config: dict[str, str]) -> dict[str, str]:
|
def _resolve_env_vars(env_config: dict[str, str]) -> dict[str, str]:
|
||||||
"""Resolve environment variable references (values starting with $)."""
|
"""Resolve environment variable references (values starting with $)."""
|
||||||
resolved = {}
|
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():
|
for key, value in env_config.items():
|
||||||
if isinstance(value, str) and value.startswith("$"):
|
if isinstance(value, str) and value.startswith("$"):
|
||||||
env_name = value[1:]
|
env_name = value[1:]
|
||||||
|
|||||||
+5
-1
@@ -40,14 +40,17 @@ class RemoteSandboxBackend(SandboxBackend):
|
|||||||
provisioner_url: http://provisioner:8002
|
provisioner_url: http://provisioner:8002
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, provisioner_url: str):
|
def __init__(self, provisioner_url: str, environment: dict[str, str] | None = None):
|
||||||
"""Initialize with the provisioner service URL.
|
"""Initialize with the provisioner service URL.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
provisioner_url: URL of the provisioner service
|
provisioner_url: URL of the provisioner service
|
||||||
(e.g., ``http://provisioner:8002``).
|
(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._provisioner_url = provisioner_url.rstrip("/")
|
||||||
|
self._environment = environment or {}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def provisioner_url(self) -> str:
|
def provisioner_url(self) -> str:
|
||||||
@@ -94,6 +97,7 @@ class RemoteSandboxBackend(SandboxBackend):
|
|||||||
json={
|
json={
|
||||||
"sandbox_id": sandbox_id,
|
"sandbox_id": sandbox_id,
|
||||||
"thread_id": thread_id,
|
"thread_id": thread_id,
|
||||||
|
"environment": self._environment,
|
||||||
},
|
},
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
|
|||||||
+24
-3
@@ -14,6 +14,11 @@ busy timeout rather than failing immediately.
|
|||||||
Postgres mode: both use the same database URL but maintain independent
|
Postgres mode: both use the same database URL but maintain independent
|
||||||
connection pools with different lifecycles.
|
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.
|
Memory mode: checkpointer uses MemorySaver, app uses in-memory stores.
|
||||||
No database is initialized.
|
No database is initialized.
|
||||||
|
|
||||||
@@ -38,9 +43,9 @@ from pydantic import BaseModel, Field
|
|||||||
|
|
||||||
|
|
||||||
class DatabaseConfig(BaseModel):
|
class DatabaseConfig(BaseModel):
|
||||||
backend: Literal["memory", "sqlite", "postgres"] = Field(
|
backend: Literal["memory", "sqlite", "postgres", "mysql"] = Field(
|
||||||
default="memory",
|
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."),
|
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(
|
sqlite_dir: str = Field(
|
||||||
default=".deer-flow/data",
|
default=".deer-flow/data",
|
||||||
@@ -55,13 +60,22 @@ class DatabaseConfig(BaseModel):
|
|||||||
"(the +asyncpg driver suffix is added automatically where needed)."
|
"(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(
|
echo_sql: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
description="Echo all SQL statements to log (debug only).",
|
description="Echo all SQL statements to log (debug only).",
|
||||||
)
|
)
|
||||||
pool_size: int = Field(
|
pool_size: int = Field(
|
||||||
default=5,
|
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) --
|
# -- Derived helpers (not user-configured) --
|
||||||
@@ -99,4 +113,11 @@ class DatabaseConfig(BaseModel):
|
|||||||
if url.startswith("postgresql://"):
|
if url.startswith("postgresql://"):
|
||||||
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
|
||||||
return url
|
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}")
|
raise ValueError(f"No SQLAlchemy URL for backend={self.backend!r}")
|
||||||
+5
-4
@@ -4,10 +4,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, String
|
from sqlalchemy import Boolean, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from deerflow.persistence.base import Base
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.types import BeijingDateTime
|
||||||
|
|
||||||
|
|
||||||
class AgentRow(Base):
|
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
|
# Stored as a plain string so the same id can be used as the filesystem
|
||||||
# directory name under {base_dir}/agents/{id}/.
|
# directory name under {base_dir}/agents/{id}/.
|
||||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
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="")
|
description: Mapped[str] = mapped_column(String(1024), nullable=False, default="")
|
||||||
user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
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)
|
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))
|
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), 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))
|
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||||
@@ -12,6 +12,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
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
|
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
@@ -27,6 +30,32 @@ _engine: AsyncEngine | None = None
|
|||||||
_session_factory: async_sessionmaker[AsyncSession] | 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:
|
async def _auto_create_postgres_db(url: str) -> None:
|
||||||
"""Connect to the ``postgres`` maintenance DB and CREATE DATABASE.
|
"""Connect to the ``postgres`` maintenance DB and CREATE DATABASE.
|
||||||
|
|
||||||
@@ -65,8 +94,8 @@ async def init_engine(
|
|||||||
"""Create the async engine and session factory, then auto-create tables.
|
"""Create the async engine and session factory, then auto-create tables.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
backend: "memory", "sqlite", or "postgres".
|
backend: "memory", "sqlite", "postgres", or "mysql".
|
||||||
url: SQLAlchemy async URL (for sqlite/postgres).
|
url: SQLAlchemy async URL (for sqlite/postgres/mysql).
|
||||||
echo: Echo SQL to log.
|
echo: Echo SQL to log.
|
||||||
pool_size: Postgres connection pool size.
|
pool_size: Postgres connection pool size.
|
||||||
sqlite_dir: Directory to create for SQLite (ensured to exist).
|
sqlite_dir: Directory to create for SQLite (ensured to exist).
|
||||||
@@ -82,6 +111,15 @@ async def init_engine(
|
|||||||
import asyncpg # noqa: F401
|
import asyncpg # noqa: F401
|
||||||
except ImportError:
|
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
|
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":
|
if backend == "sqlite":
|
||||||
import os
|
import os
|
||||||
@@ -120,6 +158,15 @@ async def init_engine(
|
|||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
json_serializer=_json_serializer,
|
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:
|
else:
|
||||||
raise ValueError(f"Unknown persistence backend: {backend!r}")
|
raise ValueError(f"Unknown persistence backend: {backend!r}")
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -4,10 +4,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from deerflow.persistence.base import Base
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.types import BeijingDateTime
|
||||||
|
|
||||||
|
|
||||||
class FeedbackRow(Base):
|
class FeedbackRow(Base):
|
||||||
@@ -29,4 +30,4 @@ class FeedbackRow(Base):
|
|||||||
comment: Mapped[str | None] = mapped_column(Text)
|
comment: Mapped[str | None] = mapped_column(Text)
|
||||||
# Optional text feedback from the user
|
# 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))
|
||||||
+3
-2
@@ -4,10 +4,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from deerflow.persistence.base import Base
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.types import BeijingDateTime
|
||||||
|
|
||||||
|
|
||||||
class LlmCallMetricRow(Base):
|
class LlmCallMetricRow(Base):
|
||||||
@@ -18,7 +19,7 @@ class LlmCallMetricRow(Base):
|
|||||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
# Wall-clock time the LLM call ended. Indexed because admin views always
|
# Wall-clock time the LLM call ended. Indexed because admin views always
|
||||||
# sort/filter by recency.
|
# 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)
|
user_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||||
thread_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
thread_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||||
|
|||||||
+2
-1
@@ -45,10 +45,11 @@ def run_migrations_offline() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def do_run_migrations(connection):
|
def do_run_migrations(connection):
|
||||||
|
render_as_batch = connection.dialect.name == "sqlite"
|
||||||
context.configure(
|
context.configure(
|
||||||
connection=connection,
|
connection=connection,
|
||||||
target_metadata=target_metadata,
|
target_metadata=target_metadata,
|
||||||
render_as_batch=True, # Required for SQLite ALTER TABLE support
|
render_as_batch=render_as_batch, # Required for SQLite ALTER TABLE support
|
||||||
)
|
)
|
||||||
with context.begin_transaction():
|
with context.begin_transaction():
|
||||||
context.run_migrations()
|
context.run_migrations()
|
||||||
|
|||||||
+3
-1
@@ -11,6 +11,8 @@ from alembic import op
|
|||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from deerflow.persistence.types import PortableJSON
|
||||||
|
|
||||||
|
|
||||||
revision = "20260512_01"
|
revision = "20260512_01"
|
||||||
down_revision = None
|
down_revision = None
|
||||||
@@ -25,7 +27,7 @@ def _has_column(table_name: str, column_name: str) -> bool:
|
|||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
if not _has_column("scheduled_task_runs", "result_json"):
|
if not _has_column("scheduled_task_runs", "result_json"):
|
||||||
op.add_column("scheduled_task_runs", sa.Column("result_json", sa.JSON(), nullable=True))
|
op.add_column("scheduled_task_runs", sa.Column("result_json", PortableJSON(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ def upgrade() -> None:
|
|||||||
op.create_table(
|
op.create_table(
|
||||||
"agents",
|
"agents",
|
||||||
sa.Column("id", sa.String(64), primary_key=True),
|
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("description", sa.String(1024), nullable=False, server_default=""),
|
||||||
sa.Column("user_id", sa.String(64), nullable=True),
|
sa.Column("user_id", sa.String(64), nullable=True),
|
||||||
sa.Column("published", sa.Boolean(), nullable=False, server_default=sa.false()),
|
sa.Column("published", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ def upgrade() -> None:
|
|||||||
op.create_table(
|
op.create_table(
|
||||||
"tags",
|
"tags",
|
||||||
sa.Column("id", sa.String(64), primary_key=True),
|
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("tag_type", sa.String(32), nullable=False),
|
||||||
sa.Column("color", sa.String(16), nullable=False, server_default="gray"),
|
sa.Column("color", sa.String(16), nullable=False, server_default="gray"),
|
||||||
sa.Column("description", sa.String(1024), nullable=False, server_default=""),
|
sa.Column("description", sa.String(1024), nullable=False, server_default=""),
|
||||||
|
|||||||
+28
-18
@@ -76,29 +76,39 @@ def upgrade() -> None:
|
|||||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
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()
|
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)
|
now = datetime.now(UTC)
|
||||||
for t in _DEFAULT_TYPES:
|
for t in _DEFAULT_TYPES:
|
||||||
if t["key"] not in existing_keys:
|
if t["key"] not in existing_keys:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
sa.text(
|
article_types.insert().values(
|
||||||
"INSERT INTO ai_writing_article_types "
|
id=str(uuid.uuid4()),
|
||||||
"(id, key, label, description, prompt_hint, default_word_count, sort_order, created_at, updated_at) "
|
key=t["key"],
|
||||||
"VALUES (:id, :key, :label, :description, :prompt_hint, :default_word_count, :sort_order, :created_at, :updated_at)"
|
label=t["label"],
|
||||||
),
|
description=t["description"],
|
||||||
{
|
prompt_hint=t["prompt_hint"],
|
||||||
"id": str(uuid.uuid4()),
|
default_word_count=t["default_word_count"],
|
||||||
"key": t["key"],
|
sort_order=t["sort_order"],
|
||||||
"label": t["label"],
|
created_at=now,
|
||||||
"description": t["description"],
|
updated_at=now,
|
||||||
"prompt_hint": t["prompt_hint"],
|
)
|
||||||
"default_word_count": t["default_word_count"],
|
|
||||||
"sort_order": t["sort_order"],
|
|
||||||
"created_at": now,
|
|
||||||
"updated_at": now,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+53
@@ -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")
|
||||||
+57
@@ -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
|
||||||
+39
@@ -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")
|
||||||
+44
@@ -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")
|
||||||
+28
@@ -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
|
||||||
+2
@@ -32,6 +32,7 @@ from deerflow.persistence.scheduled_tasks.model import (
|
|||||||
from deerflow.persistence.skills.model import SkillRow
|
from deerflow.persistence.skills.model import SkillRow
|
||||||
from deerflow.persistence.tags.model import TagAssignmentRow, TagRow
|
from deerflow.persistence.tags.model import TagAssignmentRow, TagRow
|
||||||
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
from deerflow.persistence.thread_meta.model import ThreadMetaRow
|
||||||
|
from deerflow.persistence.thread_shares.model import ThreadShareRow
|
||||||
from deerflow.persistence.user.model import UserRow
|
from deerflow.persistence.user.model import UserRow
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -51,5 +52,6 @@ __all__ = [
|
|||||||
"TagAssignmentRow",
|
"TagAssignmentRow",
|
||||||
"TagRow",
|
"TagRow",
|
||||||
"ThreadMetaRow",
|
"ThreadMetaRow",
|
||||||
|
"ThreadShareRow",
|
||||||
"UserRow",
|
"UserRow",
|
||||||
]
|
]
|
||||||
+4
-3
@@ -4,10 +4,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from deerflow.persistence.base import Base
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.types import BeijingDateTime, PortableJSON
|
||||||
|
|
||||||
|
|
||||||
class RunEventRow(Base):
|
class RunEventRow(Base):
|
||||||
@@ -24,9 +25,9 @@ class RunEventRow(Base):
|
|||||||
category: Mapped[str] = mapped_column(String(16), nullable=False)
|
category: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
# "message" | "trace" | "lifecycle"
|
# "message" | "trace" | "lifecycle"
|
||||||
content: Mapped[str] = mapped_column(Text, default="")
|
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)
|
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__ = (
|
__table_args__ = (
|
||||||
UniqueConstraint("thread_id", "seq", name="uq_events_thread_seq"),
|
UniqueConstraint("thread_id", "seq", name="uq_events_thread_seq"),
|
||||||
|
|||||||
+4
-3
@@ -5,10 +5,11 @@ from __future__ import annotations
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from deerflow.persistence.base import Base
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.types import BeijingDateTime, PortableJSON
|
||||||
|
|
||||||
|
|
||||||
class NotificationRow(Base):
|
class NotificationRow(Base):
|
||||||
@@ -19,6 +20,6 @@ class NotificationRow(Base):
|
|||||||
type: Mapped[str] = mapped_column(String(64), nullable=False)
|
type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
title: Mapped[str] = mapped_column(String(256), nullable=False, default="")
|
title: Mapped[str] = mapped_column(String(256), nullable=False, default="")
|
||||||
body: Mapped[str] = mapped_column(Text, 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)
|
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
-3
@@ -4,10 +4,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from deerflow.persistence.base import Base
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.types import BeijingDateTime
|
||||||
|
|
||||||
|
|
||||||
class RecommendedQuestionRow(Base):
|
class RecommendedQuestionRow(Base):
|
||||||
@@ -17,5 +18,5 @@ class RecommendedQuestionRow(Base):
|
|||||||
title: Mapped[str] = mapped_column(String(256), nullable=False)
|
title: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
|
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))
|
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), 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))
|
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 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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from deerflow.persistence.base import Base
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.types import BeijingDateTime, PortableJSON
|
||||||
|
|
||||||
|
|
||||||
class RunRow(Base):
|
class RunRow(Base):
|
||||||
@@ -22,8 +23,8 @@ class RunRow(Base):
|
|||||||
|
|
||||||
model_name: Mapped[str | None] = mapped_column(String(128))
|
model_name: Mapped[str | None] = mapped_column(String(128))
|
||||||
multitask_strategy: Mapped[str] = mapped_column(String(20), default="reject")
|
multitask_strategy: Mapped[str] = mapped_column(String(20), default="reject")
|
||||||
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
metadata_json: Mapped[dict] = mapped_column(PortableJSON(), default=dict)
|
||||||
kwargs_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
kwargs_json: Mapped[dict] = mapped_column(PortableJSON(), default=dict)
|
||||||
error: Mapped[str | None] = mapped_column(Text)
|
error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
# Convenience fields (for listing pages without querying RunEventStore)
|
# Convenience fields (for listing pages without querying RunEventStore)
|
||||||
@@ -43,7 +44,7 @@ class RunRow(Base):
|
|||||||
# Follow-up association
|
# Follow-up association
|
||||||
follow_up_to_run_id: Mapped[str | None] = mapped_column(String(64))
|
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))
|
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=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"),)
|
__table_args__ = (Index("ix_runs_thread_status", "thread_id", "status"),)
|
||||||
+34
@@ -36,6 +36,35 @@ class ScheduledTaskStore(ABC):
|
|||||||
async def due_tasks(self, now: datetime, *, limit: int = 20) -> list[dict[str, Any]]:
|
async def due_tasks(self, now: datetime, *, limit: int = 20) -> list[dict[str, Any]]:
|
||||||
raise NotImplementedError
|
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
|
@abstractmethod
|
||||||
async def create_run_once(self, data: dict[str, Any]) -> dict[str, Any] | None:
|
async def create_run_once(self, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
@@ -56,6 +85,11 @@ class ScheduledTaskStore(ABC):
|
|||||||
async def delete_run(self, run_id: str, user_id: str) -> bool:
|
async def delete_run(self, run_id: str, user_id: str) -> bool:
|
||||||
raise NotImplementedError
|
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
|
@abstractmethod
|
||||||
async def get_delivery_profile(self, user_id: str) -> dict[str, Any]:
|
async def get_delivery_profile(self, user_id: str) -> dict[str, Any]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|||||||
+43
-2
@@ -59,6 +59,37 @@ class MemoryScheduledTaskStore(ScheduledTaskStore):
|
|||||||
due.sort(key=lambda task: task["next_run_at"])
|
due.sort(key=lambda task: task["next_run_at"])
|
||||||
return due[:limit]
|
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:
|
async def create_run_once(self, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
key = (data["task_id"], data["scheduled_for"].isoformat())
|
key = (data["task_id"], data["scheduled_for"].isoformat())
|
||||||
if key in self._runs_by_key:
|
if key in self._runs_by_key:
|
||||||
@@ -90,11 +121,21 @@ class MemoryScheduledTaskStore(ScheduledTaskStore):
|
|||||||
run = self._runs.get(run_id)
|
run = self._runs.get(run_id)
|
||||||
if run is None or run["user_id"] != user_id:
|
if run is None or run["user_id"] != user_id:
|
||||||
return False
|
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")
|
scheduled_for = run.get("scheduled_for")
|
||||||
if hasattr(scheduled_for, "isoformat"):
|
if hasattr(scheduled_for, "isoformat"):
|
||||||
self._runs_by_key.discard((run["task_id"], 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]:
|
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})
|
return dict(self._profiles.get(user_id) or {"user_id": user_id, "element_user_id": None})
|
||||||
|
|||||||
+26
-21
@@ -4,10 +4,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from deerflow.persistence.base import Base
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.types import BeijingDateTime, PortableJSON
|
||||||
|
|
||||||
|
|
||||||
class SchedulerThreadRow(Base):
|
class SchedulerThreadRow(Base):
|
||||||
@@ -15,8 +16,8 @@ class SchedulerThreadRow(Base):
|
|||||||
|
|
||||||
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
thread_id: Mapped[str] = mapped_column(String(64), unique=True, index=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))
|
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
|
||||||
class ScheduledTaskRow(Base):
|
class ScheduledTaskRow(Base):
|
||||||
@@ -29,20 +30,20 @@ class ScheduledTaskRow(Base):
|
|||||||
source_thread_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
source_thread_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||||
source_agent_name: Mapped[str | None] = mapped_column(String(128), index=True)
|
source_agent_name: Mapped[str | None] = mapped_column(String(128), index=True)
|
||||||
execution_agent_name: Mapped[str | None] = mapped_column(String(128), index=True)
|
execution_agent_name: Mapped[str | None] = mapped_column(String(128), index=True)
|
||||||
name: Mapped[str] = mapped_column(String(256))
|
name: Mapped[str] = mapped_column(String(127))
|
||||||
prompt: Mapped[str] = mapped_column(Text)
|
prompt: Mapped[str] = mapped_column(Text)
|
||||||
schedule_text: Mapped[str] = mapped_column(String(256))
|
schedule_text: Mapped[str] = mapped_column(String(256))
|
||||||
cron_expr: Mapped[str] = mapped_column(String(128))
|
cron_expr: Mapped[str] = mapped_column(String(128))
|
||||||
timezone: Mapped[str] = mapped_column(String(64), default="UTC")
|
timezone: Mapped[str] = mapped_column(String(64), default="UTC")
|
||||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
next_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
next_run_at: Mapped[datetime | None] = mapped_column(BeijingDateTime(), index=True)
|
||||||
last_run_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_run_at: Mapped[datetime | None] = mapped_column(BeijingDateTime())
|
||||||
last_status: Mapped[str | None] = mapped_column(String(32))
|
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: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
published_at: Mapped[datetime | None] = mapped_column(BeijingDateTime())
|
||||||
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))
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
|
||||||
class ScheduledTaskSubscriptionRow(Base):
|
class ScheduledTaskSubscriptionRow(Base):
|
||||||
@@ -53,8 +54,8 @@ class ScheduledTaskSubscriptionRow(Base):
|
|||||||
task_id: Mapped[str] = mapped_column(String(64), index=True)
|
task_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
user_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)
|
notify_element: Mapped[bool] = mapped_column(Boolean, default=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))
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
|
||||||
class ScheduledTaskRunRow(Base):
|
class ScheduledTaskRunRow(Base):
|
||||||
@@ -66,21 +67,25 @@ class ScheduledTaskRunRow(Base):
|
|||||||
user_id: Mapped[str] = mapped_column(String(64), index=True)
|
user_id: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
scheduler_thread_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)
|
agent_run_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||||
scheduled_for: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
scheduled_for: Mapped[datetime] = mapped_column(BeijingDateTime(), index=True)
|
||||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
started_at: Mapped[datetime | None] = mapped_column(BeijingDateTime())
|
||||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
finished_at: Mapped[datetime | None] = mapped_column(BeijingDateTime())
|
||||||
status: Mapped[str] = mapped_column(String(32), default="pending")
|
status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||||
error: Mapped[str | None] = mapped_column(Text)
|
error: Mapped[str | None] = mapped_column(Text)
|
||||||
result_json: Mapped[dict | None] = mapped_column(JSON, default=dict)
|
result_json: Mapped[dict | None] = mapped_column(PortableJSON(), default=dict)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
# Number of recovery attempts made after the first try. A run is retried
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
# 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):
|
class ScheduledTaskDeliveryProfileRow(Base):
|
||||||
__tablename__ = "scheduled_task_delivery_profiles"
|
__tablename__ = "scheduled_task_delivery_profiles"
|
||||||
|
|
||||||
user_id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
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)
|
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))
|
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC))
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||||
+71
-1
@@ -124,6 +124,60 @@ class ScheduledTaskRepository(ScheduledTaskStore):
|
|||||||
)
|
)
|
||||||
return [_to_dict(row) for row in result.scalars()]
|
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:
|
async def create_run_once(self, data: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
row = ScheduledTaskRunRow(
|
row = ScheduledTaskRunRow(
|
||||||
id=data["id"],
|
id=data["id"],
|
||||||
@@ -180,6 +234,15 @@ class ScheduledTaskRepository(ScheduledTaskStore):
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
return True
|
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 def get_delivery_profile(self, user_id: str) -> dict[str, Any]:
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
row = await session.get(ScheduledTaskDeliveryProfileRow, user_id)
|
row = await session.get(ScheduledTaskDeliveryProfileRow, user_id)
|
||||||
@@ -232,7 +295,14 @@ class ScheduledTaskRepository(ScheduledTaskStore):
|
|||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(ScheduledTaskRow)
|
select(ScheduledTaskRow)
|
||||||
.where(ScheduledTaskRow.published.is_(True))
|
.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))
|
.offset(max(0, offset))
|
||||||
.limit(max(1, limit))
|
.limit(max(1, limit))
|
||||||
)
|
)
|
||||||
|
|||||||
+6
-5
@@ -14,10 +14,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import DateTime, String, UniqueConstraint
|
from sqlalchemy import String, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from deerflow.persistence.base import Base
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.types import BeijingDateTime
|
||||||
|
|
||||||
# Allowed tag/target types. Kept here so persistence + API share one source.
|
# Allowed tag/target types. Kept here so persistence + API share one source.
|
||||||
TAG_TYPES: tuple[str, ...] = ("agent", "skill", "scheduled_task")
|
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"),)
|
__table_args__ = (UniqueConstraint("tag_type", "name", name="uq_tags_type_name"),)
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
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)
|
tag_type: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
||||||
color: Mapped[str] = mapped_column(String(16), nullable=False, default="gray")
|
color: Mapped[str] = mapped_column(String(16), nullable=False, default="gray")
|
||||||
description: Mapped[str] = mapped_column(String(1024), nullable=False, default="")
|
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))
|
created_at: Mapped[datetime] = mapped_column(BeijingDateTime(), 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))
|
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||||
|
|
||||||
|
|
||||||
class TagAssignmentRow(Base):
|
class TagAssignmentRow(Base):
|
||||||
@@ -44,4 +45,4 @@ class TagAssignmentRow(Base):
|
|||||||
tag_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
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_type: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
||||||
target_id: Mapped[str] = mapped_column(String(64), 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))
|
||||||
+5
-4
@@ -4,10 +4,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from sqlalchemy import JSON, DateTime, String
|
from sqlalchemy import String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from deerflow.persistence.base import Base
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.types import BeijingDateTime, PortableJSON
|
||||||
|
|
||||||
|
|
||||||
class ThreadMetaRow(Base):
|
class ThreadMetaRow(Base):
|
||||||
@@ -18,6 +19,6 @@ class ThreadMetaRow(Base):
|
|||||||
user_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
user_id: Mapped[str | None] = mapped_column(String(64), index=True)
|
||||||
display_name: Mapped[str | None] = mapped_column(String(256))
|
display_name: Mapped[str | None] = mapped_column(String(256))
|
||||||
status: Mapped[str] = mapped_column(String(20), default="idle")
|
status: Mapped[str] = mapped_column(String(20), default="idle")
|
||||||
metadata_json: Mapped[dict] = mapped_column(JSON, default=dict)
|
metadata_json: Mapped[dict] = mapped_column(PortableJSON(), default=dict)
|
||||||
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))
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
updated_at: Mapped[datetime] = mapped_column(BeijingDateTime(), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
|
||||||
+19
@@ -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)
|
||||||
+50
@@ -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
|
||||||
+59
@@ -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
|
||||||
+37
@@ -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))
|
||||||
+80
@@ -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)
|
||||||
+4
-3
@@ -13,10 +13,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime
|
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 sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from deerflow.persistence.base import Base
|
from deerflow.persistence.base import Base
|
||||||
|
from deerflow.persistence.types import BeijingDateTime
|
||||||
|
|
||||||
|
|
||||||
class UserRow(Base):
|
class UserRow(Base):
|
||||||
@@ -25,7 +26,7 @@ class UserRow(Base):
|
|||||||
# UUIDs are stored as 36-char strings for cross-backend portability.
|
# UUIDs are stored as 36-char strings for cross-backend portability.
|
||||||
id: Mapped[str] = mapped_column(String(36), primary_key=True)
|
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)
|
password_hash: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
|
||||||
# "admin" | "user" — kept as plain string to avoid ALTER TABLE pain
|
# "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")
|
system_role: Mapped[str] = mapped_column(String(16), nullable=False, default="user")
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True),
|
BeijingDateTime(),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default=lambda: datetime.now(UTC),
|
default=lambda: datetime.now(UTC),
|
||||||
)
|
)
|
||||||
|
|||||||
+1
@@ -87,6 +87,7 @@ class RunEventStore(abc.ABC):
|
|||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
before_seq: int | None = None,
|
before_seq: int | None = None,
|
||||||
after_seq: int | None = None,
|
after_seq: int | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Return displayable messages (category=message) for a specific run, ordered by seq ascending.
|
"""Return displayable messages (category=message) for a specific run, ordered by seq ascending.
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -152,7 +152,9 @@ class JsonlRunEventStore(RunEventStore):
|
|||||||
events = [e for e in events if e.get("event_type") in event_types]
|
events = [e for e in events if e.get("event_type") in event_types]
|
||||||
return events[:limit]
|
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)
|
events = self._read_run_events(thread_id, run_id)
|
||||||
filtered = [e for e in events if e.get("category") == "message"]
|
filtered = [e for e in events if e.get("category") == "message"]
|
||||||
if before_seq is not None:
|
if before_seq is not None:
|
||||||
|
|||||||
+3
-1
@@ -97,7 +97,9 @@ class MemoryRunEventStore(RunEventStore):
|
|||||||
filtered = [e for e in filtered if e["event_type"] in event_types]
|
filtered = [e for e in filtered if e["event_type"] in event_types]
|
||||||
return filtered[:limit]
|
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, [])
|
all_events = self._events.get(thread_id, [])
|
||||||
filtered = [e for e in all_events if e["run_id"] == run_id and e["category"] == "message"]
|
filtered = [e for e in all_events if e["run_id"] == run_id and e["category"] == "message"]
|
||||||
if before_seq is not None:
|
if before_seq is not None:
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
from .registry import get_scheduled_task_service, set_scheduled_task_service
|
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"]
|
||||||
+313
-57
@@ -9,7 +9,7 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -27,6 +27,27 @@ from deerflow.utils.time import now_iso
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
@dataclass
|
||||||
class _RuntimeUser:
|
class _RuntimeUser:
|
||||||
@@ -337,15 +358,20 @@ class ScheduledTaskService:
|
|||||||
"kind": item.get("kind"),
|
"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:
|
async def delete_run(self, user_id: str, run_id: str) -> bool:
|
||||||
deleted = await self.store.delete_run(run_id, user_id)
|
deleted = await self.store.delete_run(run_id, user_id)
|
||||||
if deleted:
|
if deleted:
|
||||||
archive_dir = get_paths().base_dir / "scheduled-task-runs" / run_id
|
self._remove_run_archive(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)
|
|
||||||
return True
|
return True
|
||||||
candidate = await self.store.get_run_any(run_id)
|
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:
|
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]:
|
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)
|
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(
|
async def create_task(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -374,6 +414,8 @@ class ScheduledTaskService:
|
|||||||
tz = _normalize_timezone(timezone)
|
tz = _normalize_timezone(timezone)
|
||||||
cron_expr = _schedule_to_cron(schedule)
|
cron_expr = _schedule_to_cron(schedule)
|
||||||
next_run_at = _next_run_at(cron_expr, tz)
|
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)
|
scheduler_thread = await self.ensure_scheduler_thread(user_id)
|
||||||
context = dict(execution_context or {})
|
context = dict(execution_context or {})
|
||||||
if execution_agent_name:
|
if execution_agent_name:
|
||||||
@@ -386,7 +428,7 @@ class ScheduledTaskService:
|
|||||||
"source_thread_id": source_thread_id,
|
"source_thread_id": source_thread_id,
|
||||||
"source_agent_name": source_agent_name,
|
"source_agent_name": source_agent_name,
|
||||||
"execution_agent_name": execution_agent_name,
|
"execution_agent_name": execution_agent_name,
|
||||||
"name": name.strip() or "Scheduled task",
|
"name": final_name,
|
||||||
"prompt": prompt.strip(),
|
"prompt": prompt.strip(),
|
||||||
"schedule_text": schedule.strip(),
|
"schedule_text": schedule.strip(),
|
||||||
"cron_expr": cron_expr,
|
"cron_expr": cron_expr,
|
||||||
@@ -398,31 +440,106 @@ class ScheduledTaskService:
|
|||||||
)
|
)
|
||||||
return task
|
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] = {}
|
update_data: dict[str, Any] = {}
|
||||||
for key in ("name", "prompt", "enabled", "execution_agent_name"):
|
for key in ("name", "prompt", "enabled", "execution_agent_name"):
|
||||||
if key in data and data[key] is not None:
|
if key in data and data[key] is not None:
|
||||||
update_data[key] = data[key]
|
update_data[key] = data[key]
|
||||||
|
# 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")
|
timezone = data.get("timezone")
|
||||||
if timezone is not None:
|
if timezone is not None:
|
||||||
update_data["timezone"] = _normalize_timezone(timezone)
|
update_data["timezone"] = _normalize_timezone(timezone)
|
||||||
if data.get("schedule") is not None:
|
if data.get("schedule") is not None:
|
||||||
schedule = str(data["schedule"])
|
schedule = str(data["schedule"])
|
||||||
cron_expr = _schedule_to_cron(schedule)
|
cron_expr = _schedule_to_cron(schedule)
|
||||||
tz = update_data.get("timezone")
|
tz = update_data.get("timezone") or (existing or {}).get("timezone") or "UTC"
|
||||||
if tz is None:
|
|
||||||
existing = await self.store.get_task(task_id, user_id)
|
|
||||||
tz = (existing or {}).get("timezone", "UTC")
|
|
||||||
update_data["schedule_text"] = schedule
|
update_data["schedule_text"] = schedule
|
||||||
update_data["cron_expr"] = cron_expr
|
update_data["cron_expr"] = cron_expr
|
||||||
update_data["next_run_at"] = _next_run_at(cron_expr, tz)
|
update_data["next_run_at"] = _next_run_at(cron_expr, tz)
|
||||||
if data.get("execution_context") is not None:
|
if data.get("execution_context") is not None:
|
||||||
update_data["execution_context"] = data["execution_context"]
|
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)
|
return await self.store.update_task(task_id, user_id, update_data)
|
||||||
|
|
||||||
async def delete_task(self, user_id: str, task_id: str) -> bool:
|
async def delete_task(self, user_id: str, task_id: str) -> bool:
|
||||||
return await self.store.delete_task(task_id, user_id)
|
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:
|
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})
|
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)
|
task = await self.store.get_task(task_id, user_id)
|
||||||
if task is None:
|
if task is None:
|
||||||
return 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
|
return task
|
||||||
|
|
||||||
async def _run_loop(self) -> None:
|
async def _run_loop(self) -> None:
|
||||||
while not self._stopping.is_set():
|
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:
|
try:
|
||||||
due = await self.store.due_tasks(datetime.now(UTC), limit=20)
|
due = await self.store.due_tasks(datetime.now(UTC), limit=20)
|
||||||
for task in due:
|
for task in due:
|
||||||
@@ -466,64 +591,195 @@ class ScheduledTaskService:
|
|||||||
finally:
|
finally:
|
||||||
self._running_task_ids.discard(task["task_id"])
|
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
|
# 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
|
# 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 —
|
# task["scheduler_thread_id"] is no longer used to drive the LLM call —
|
||||||
# we keep it on the task row only for backwards compatibility.
|
# we keep it on the task row only for backwards compatibility.
|
||||||
runtime_thread_id = str(uuid.uuid4())
|
is_recovery = run_row is not None
|
||||||
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),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if run_row is 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)
|
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"})
|
await self.store.update_task(task["task_id"], task["user_id"], {"next_run_at": next_run_at, "last_status": "running"})
|
||||||
|
|
||||||
try:
|
run_id = run_row["id"]
|
||||||
await self._ensure_thread_meta(task["user_id"], runtime_thread_id)
|
retry_count = int(run_row.get("retry_count") or 0)
|
||||||
async with self._agent_run_lock:
|
|
||||||
output_snapshot = self._snapshot_result_files(runtime_thread_id, task["user_id"])
|
while True:
|
||||||
record = await self._start_agent_run(task, scheduled_for=scheduled_for, runtime_thread_id=runtime_thread_id)
|
runtime_thread_id = str(uuid.uuid4())
|
||||||
await self.store.update_run(run_row["id"], {"agent_run_id": record.run_id})
|
try:
|
||||||
if record.task is not None:
|
result = await asyncio.wait_for(
|
||||||
await record.task
|
self._run_agent_attempt(task, runtime_thread_id=runtime_thread_id, scheduled_for=scheduled_for, run_id=run_id),
|
||||||
result_text = await self._extract_latest_ai_text(runtime_thread_id)
|
timeout=_RUN_ATTEMPT_TIMEOUT_SECONDS,
|
||||||
result_files = await self._extract_result_files(
|
|
||||||
runtime_thread_id,
|
|
||||||
task["user_id"],
|
|
||||||
run_id=run_row["id"],
|
|
||||||
output_snapshot=output_snapshot,
|
|
||||||
)
|
)
|
||||||
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(
|
await self.store.update_run(
|
||||||
run_row["id"],
|
run_id,
|
||||||
{"status": "succeeded", "finished_at": datetime.now(UTC), "result": result},
|
{"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.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"])
|
await self._deliver_element_result(task, result["content"])
|
||||||
except Exception as exc:
|
return
|
||||||
logger.exception("Scheduled task %s failed", task["task_id"])
|
|
||||||
result = _build_result(
|
async def _run_agent_attempt(
|
||||||
task,
|
self,
|
||||||
scheduled_for=scheduled_for,
|
task: dict[str, Any],
|
||||||
content=f"Scheduled task failed: {exc}",
|
*,
|
||||||
|
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(
|
return _build_result(task, scheduled_for=scheduled_for, content=result_text, files=result_files)
|
||||||
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"})
|
|
||||||
|
|
||||||
async def _ensure_thread_meta(self, user_id: str, thread_id: str) -> None:
|
async def _ensure_thread_meta(self, user_id: str, thread_id: str) -> None:
|
||||||
token = set_current_user(_RuntimeUser(id=user_id))
|
token = set_current_user(_RuntimeUser(id=user_id))
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ postgres = [
|
|||||||
"psycopg[binary]>=3.3.3",
|
"psycopg[binary]>=3.3.3",
|
||||||
"psycopg-pool>=3.3.0",
|
"psycopg-pool>=3.3.0",
|
||||||
]
|
]
|
||||||
|
mysql = ["asyncmy>=0.2.10"]
|
||||||
pymupdf = ["pymupdf4llm>=0.0.17"]
|
pymupdf = ["pymupdf4llm>=0.0.17"]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ dependencies = [
|
|||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
postgres = ["deerflow-harness[postgres]"]
|
postgres = ["deerflow-harness[postgres]"]
|
||||||
|
mysql = ["deerflow-harness[mysql]"]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
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 (
|
from app.gateway.routers.skills import (
|
||||||
SkillDetailRequest,
|
SkillDetailRequest,
|
||||||
SkillResponse,
|
SkillResponse,
|
||||||
|
_can_view_skill_source,
|
||||||
_skill_to_response,
|
_skill_to_response,
|
||||||
)
|
)
|
||||||
from deerflow.config import paths as paths_module
|
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)
|
resp = _skill_to_response(skill, None)
|
||||||
assert resp.detail is None
|
assert resp.detail is None
|
||||||
assert resp.builtin is True
|
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
|
||||||
@@ -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"
|
||||||
Reference in new issue
Block a user