From e0c1cb658144a0c6bf9bcae30105b6361c6f3ed3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Mon, 18 May 2026 15:39:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E7=AE=A1=E7=90=86=20+=20=E4=BC=9A=E8=AF=9D=E5=8F=AA=E8=AF=BB?= =?UTF-8?q?=E5=88=86=E4=BA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 定时任务: - 管理员可查看/编辑/删除/触发全部用户的定时任务(新增 /api/scheduled-tasks/admin/* 接口与管理页) - 卡死任务恢复:单次执行超 30 分钟超时重试(最多 3 次),耗尽后标记 failed;看门狗回收服务重启遗留的「执行中」任务 会话分享: - 新增「只读链接」分享模式,生成公开免登录的只读对话快照页(/share/:code) - 公开接口 /api/public/shares/* 提供对话快照与图片附件 Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend-web/src/App.tsx | 3 + .../components/workspace/messages/context.ts | 7 + .../workspace/messages/message-list-item.tsx | 38 +- .../components/workspace/recent-chat-list.tsx | 195 ++++---- .../scheduled-tasks/schedule-builder.tsx | 220 +++++++++ .../workspace/skills/skill-detail-dialog.tsx | 186 +++++--- .../workspace/thread-share-dialogs.tsx | 328 +++++++++++++ .../workspace/workspace-nav-chat-list.tsx | 20 +- frontend-web/src/core/i18n/locales/en-US.ts | 29 ++ frontend-web/src/core/i18n/locales/types.ts | 26 ++ frontend-web/src/core/i18n/locales/zh-CN.ts | 27 ++ frontend-web/src/core/scheduled-tasks/api.ts | 58 +++ .../src/core/scheduled-tasks/hooks.ts | 57 +++ .../src/core/scheduled-tasks/types.ts | 1 + frontend-web/src/core/thread-shares/api.ts | 137 ++++++ frontend-web/src/core/thread-shares/hooks.ts | 61 +++ .../src/pages/AdminScheduledTasksPage.tsx | 440 ++++++++++++++++++ frontend-web/src/pages/ChatsPage.tsx | 20 +- frontend-web/src/pages/PublicSharePage.tsx | 105 +++++ frontend-web/src/pages/ScheduledTasksPage.tsx | 222 +-------- frontend-web/src/pages/WorkspaceRoutes.tsx | 13 + offline-backend-20260512/backend/CLAUDE.md | 11 +- .../backend/app/gateway/app.py | 5 + .../backend/app/gateway/auth_middleware.py | 5 + .../backend/app/gateway/deps.py | 12 + .../app/gateway/routers/scheduled_tasks.py | 109 ++++- .../backend/app/gateway/routers/skills.py | 34 +- .../app/gateway/routers/thread_shares.py | 340 ++++++++++++++ .../backend/app/gateway/thread_copy.py | 67 +++ .../versions/20260518_02_thread_shares.py | 53 +++ .../20260518_03_thread_shares_thread_id.py | 57 +++ .../20260518_04_scheduled_task_run_retry.py | 39 ++ .../versions/20260518_05_thread_share_mode.py | 44 ++ .../deerflow/persistence/models/__init__.py | 2 + .../persistence/scheduled_tasks/base.py | 29 ++ .../persistence/scheduled_tasks/memory.py | 31 ++ .../persistence/scheduled_tasks/model.py | 6 +- .../persistence/scheduled_tasks/sql.py | 54 +++ .../persistence/thread_shares/__init__.py | 19 + .../persistence/thread_shares/base.py | 50 ++ .../persistence/thread_shares/memory.py | 59 +++ .../persistence/thread_shares/model.py | 36 ++ .../deerflow/persistence/thread_shares/sql.py | 80 ++++ .../deerflow/runtime/scheduler/service.py | 307 ++++++++++-- .../backend/tests/test_skill_detail.py | 27 ++ 45 files changed, 3235 insertions(+), 434 deletions(-) create mode 100644 frontend-web/src/components/workspace/scheduled-tasks/schedule-builder.tsx create mode 100644 frontend-web/src/components/workspace/thread-share-dialogs.tsx create mode 100644 frontend-web/src/core/thread-shares/api.ts create mode 100644 frontend-web/src/core/thread-shares/hooks.ts create mode 100644 frontend-web/src/pages/AdminScheduledTasksPage.tsx create mode 100644 frontend-web/src/pages/PublicSharePage.tsx create mode 100644 offline-backend-20260512/backend/app/gateway/routers/thread_shares.py create mode 100644 offline-backend-20260512/backend/app/gateway/thread_copy.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260518_02_thread_shares.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260518_03_thread_shares_thread_id.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260518_04_scheduled_task_run_retry.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260518_05_thread_share_mode.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_shares/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_shares/base.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_shares/memory.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_shares/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/thread_shares/sql.py diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index 66fda5ba..3319a2d6 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -13,6 +13,7 @@ import { HashRouter, useLocation } from "react-router-dom"; import LandingPage from "./pages/LandingPage"; import LoginPage from "./pages/LoginPage"; import PageRoutes from "./pages/PageRoutes"; +import PublicSharePage from "./pages/PublicSharePage"; import { NotFoundPage } from "./pages/NotFoundPage"; function AppProviders({ children }: { children: React.ReactNode }) { @@ -47,6 +48,8 @@ export function App() { } /> } /> } /> + {/* Public read-only conversation viewer — no authentication. */} + } /> } /> } /> diff --git a/frontend-web/src/components/workspace/messages/context.ts b/frontend-web/src/components/workspace/messages/context.ts index 315ff017..952180e3 100644 --- a/frontend-web/src/components/workspace/messages/context.ts +++ b/frontend-web/src/components/workspace/messages/context.ts @@ -6,6 +6,13 @@ import type { AgentThreadState } from "@/core/threads"; export interface ThreadContextType { thread: BaseStream; 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( diff --git a/frontend-web/src/components/workspace/messages/message-list-item.tsx b/frontend-web/src/components/workspace/messages/message-list-item.tsx index da3acecf..578d9925 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -2,6 +2,8 @@ import type { Message } from "@langchain/langgraph-sdk"; import { FileIcon, Loader2Icon } from "lucide-react"; import { memo, + useCallback, + useContext, useMemo, type AnchorHTMLAttributes, type ImgHTMLAttributes, @@ -23,6 +25,7 @@ import { import { Task, TaskTrigger } from "@/components/ai-elements/task"; import { Badge } from "@/components/ui/badge"; import { resolveArtifactURL } from "@/core/artifacts/utils"; +import { getBackendBaseURL } from "@/core/config"; import { useI18n } from "@/core/i18n/hooks"; import { extractContentFromMessage, @@ -37,9 +40,33 @@ import { cn } from "@/lib/utils"; import { CopyButton } from "../copy-button"; +import { ThreadContext } from "./context"; import { MarkdownContent } from "./markdown-content"; import { MessageTokenUsage } from "./message-token-usage"; +/** + * Resolve an in-conversation file path to a fetchable URL. + * + * On a public read-only share (`ThreadContext.publicShareCode` set) artifact + * URLs point at the unauthenticated `/api/public/shares/{code}/files/...` + * endpoint; otherwise they use the normal auth-gated thread artifact route. + */ +function useArtifactResolver(threadId: string): (path: string) => string { + const publicShareCode = useContext(ThreadContext)?.publicShareCode; + return useCallback( + (path: string) => { + if (publicShareCode) { + const normalized = path.startsWith("/") ? path : `/${path}`; + return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent( + publicShareCode, + )}/files${normalized}`; + } + return resolveArtifactURL(path, threadId); + }, + [publicShareCode, threadId], + ); +} + export function MessageListItem({ className, message, @@ -101,6 +128,7 @@ function MessageImage({ threadId: string; maxWidth?: string; }) { + const resolveArtifact = useArtifactResolver(threadId); if (!src) return null; const imgClassName = cn("overflow-hidden rounded-lg", `max-w-[${maxWidth}]`); @@ -109,7 +137,7 @@ function MessageImage({ return {alt}; } - const url = src.startsWith("/mnt/") ? resolveArtifactURL(src, threadId) : src; + const url = src.startsWith("/mnt/") ? resolveArtifact(src) : src; return ( @@ -133,6 +161,7 @@ function MessageContent_({ }) { const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const isHuman = message.type === "human"; + const resolveArtifact = useArtifactResolver(threadId); const components = useMemo( () => ({ img: (props: ImgHTMLAttributes) => ( @@ -140,7 +169,7 @@ function MessageContent_({ ), a: ({ href, ...props }: AnchorHTMLAttributes) => { if (href?.startsWith("/mnt/")) { - const url = resolveArtifactURL(href, threadId); + const url = resolveArtifact(href); return ( ; }, }), - [threadId], + [threadId, resolveArtifact], ); const rawContent = extractContentFromMessage(message); @@ -346,6 +375,7 @@ function RichFileCard({ threadId: string; }) { const { t } = useI18n(); + const resolveArtifact = useArtifactResolver(threadId); const isUploading = file.status === "uploading"; const isImage = isImageFile(file.filename); @@ -378,7 +408,7 @@ function RichFileCard({ if (!file.path) return null; - const fileUrl = resolveArtifactURL(file.path, threadId); + const fileUrl = resolveArtifact(file.path); if (isImage) { return ( diff --git a/frontend-web/src/components/workspace/recent-chat-list.tsx b/frontend-web/src/components/workspace/recent-chat-list.tsx index 20e833cf..de78d861 100644 --- a/frontend-web/src/components/workspace/recent-chat-list.tsx +++ b/frontend-web/src/components/workspace/recent-chat-list.tsx @@ -42,6 +42,7 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar"; +import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs"; import { getAPIClient } from "@/core/api"; import { useI18n } from "@/core/i18n/hooks"; import { @@ -76,6 +77,9 @@ export function RecentChatList() { const [renameThreadId, setRenameThreadId] = useState(null); const [renameValue, setRenameValue] = useState(""); + // Share dialog state — the conversation whose share code is being shown. + const [shareThreadId, setShareThreadId] = useState(null); + const handleDelete = useCallback( (threadId: string) => { deleteThread({ threadId }); @@ -115,18 +119,10 @@ export function RecentChatList() { } }, [renameThread, renameThreadId, renameValue]); - const handleShare = useCallback( - async (thread: AgentThread) => { - const shareUrl = `${window.location.origin}${pathOfThread(thread)}`; - try { - await navigator.clipboard.writeText(shareUrl); - toast.success(t.clipboard.linkCopied); - } catch { - toast.error(t.clipboard.failedToCopyToClipboard); - } - }, - [t], - ); + const handleShare = useCallback((thread: AgentThread) => { + // Opens the share-code dialog; the recipient imports a copy via that code. + setShareThreadId(thread.thread_id); + }, []); const handleExport = useCallback( async (thread: AgentThread, format: "markdown" | "json") => { @@ -173,89 +169,89 @@ export function RecentChatList() {
{visibleThreads.map((thread) => { const isActive = pathOfThread(thread) === pathname; - return ( - - - - {titleOfThread(thread)} - - - {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && ( - - - - - {t.common.more} - - - - - handleRenameClick( - thread.thread_id, - titleOfThread(thread), - ) - } - > - - {t.common.rename} - - handleShare(thread)} - > - - {t.common.share} - - - - - {t.common.export} - - - - handleExport(thread, "markdown") - } - > - - {t.common.exportAsMarkdown} - - - handleExport(thread, "json") - } - > - - {t.common.exportAsJSON} - - - - - handleDelete(thread.thread_id)} - > - - {t.common.delete} - - - - )} - - ); - })} + return ( + + + + {titleOfThread(thread)} + + + {env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && ( + + + + + {t.common.more} + + + + + handleRenameClick( + thread.thread_id, + titleOfThread(thread), + ) + } + > + + {t.common.rename} + + handleShare(thread)} + > + + {t.common.share} + + + + + {t.common.export} + + + + handleExport(thread, "markdown") + } + > + + {t.common.exportAsMarkdown} + + + handleExport(thread, "json") + } + > + + {t.common.exportAsJSON} + + + + + handleDelete(thread.thread_id)} + > + + {t.common.delete} + + + + )} + + ); + })}
@@ -291,6 +287,15 @@ export function RecentChatList() { + + {/* Share Dialog */} + { + if (!o) setShareThreadId(null); + }} + /> ); } diff --git a/frontend-web/src/components/workspace/scheduled-tasks/schedule-builder.tsx b/frontend-web/src/components/workspace/scheduled-tasks/schedule-builder.tsx new file mode 100644 index 00000000..58410867 --- /dev/null +++ b/frontend-web/src/components/workspace/scheduled-tasks/schedule-builder.tsx @@ -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) => onChange({ ...value, ...patch }); + return ( +
+ + + {value.freq === "hourly" && ( + + )} + + {value.freq === "weekly" && ( + + )} + + {value.freq === "monthly" && ( + + )} + + {value.freq !== "hourly" && ( + + )} +
+ ); +} + +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"; +} diff --git a/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx b/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx index a731c087..3b5f20a6 100644 --- a/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx +++ b/frontend-web/src/components/workspace/skills/skill-detail-dialog.tsx @@ -1,10 +1,24 @@ "use client"; -import { Loader2, PencilIcon } from "lucide-react"; +import { + ChevronDownIcon, + FileCode2Icon, + FileTextIcon, + GlobeIcon, + Loader2, + PencilIcon, + SparklesIcon, +} from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; import { Dialog, DialogContent, @@ -23,15 +37,18 @@ import { } from "@/core/skills/hooks"; import type { Skill } from "@/core/skills/type"; import { reasoningPlugins } from "@/core/streamdown"; +import { cn } from "@/lib/utils"; /** - * Whether the current user may edit a skill's detail text. + * Whether the current user may edit a skill's detail text and view its raw + * SKILL.md source. * - * Admins can edit any skill; the publisher (owner of a custom skill) can edit + * Admins qualify for any skill; the publisher (owner of a custom skill) for * their own. Built-in skills have no human owner, so only admins qualify. - * Mirrors the backend authz in `PUT /api/skills/{name}/detail`. + * Mirrors the backend authz in `PUT /api/skills/{name}/detail` and + * `GET /api/skills/{name}/content`. */ -function canEditSkillDetail(skill: Skill | null | undefined): boolean { +function canManageSkill(skill: Skill | null | undefined): boolean { if (!skill) return false; const auth = getStoredAuth(); if (!auth) return false; @@ -45,8 +62,9 @@ function canEditSkillDetail(skill: Skill | null | undefined): boolean { /** * Reusable skill detail dialog. Given a skill name it fetches the skill - * metadata (including the admin/owner-authored `detail`) and the raw SKILL.md - * source, and lets authorized users edit the detail inline. + * metadata (including the admin/owner-authored `detail`) and lets authorized + * users edit the detail inline. The raw SKILL.md source is shown only to the + * publisher or an admin — everyone else sees just the description and detail. */ export function SkillDetailDialog({ skillName, @@ -62,17 +80,22 @@ export function SkillDetailDialog({ // Only fetch while the dialog is open to avoid background requests. const activeName = open ? skillName : null; const { data: skill, isLoading: isSkillLoading } = useSkill(activeName); - const { - data: contentData, - isLoading: isContentLoading, - error: contentError, - } = useSkillContent(activeName); const setDetail = useSetSkillDetail(); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(""); const [showSource, setShowSource] = useState(false); + const canManage = useMemo(() => canManageSkill(skill), [skill]); + + // The SKILL.md source endpoint is publisher/admin-only — only request it + // when the viewer is actually allowed to see it. + const { + data: contentData, + isLoading: isContentLoading, + error: contentError, + } = useSkillContent(open && canManage ? skillName : null); + // Reset transient UI whenever the dialog closes or switches to another skill. useEffect(() => { if (!open) { @@ -81,8 +104,6 @@ export function SkillDetailDialog({ } }, [open, skillName]); - const canEdit = useMemo(() => canEditSkillDetail(skill), [skill]); - function startEditing() { setDraft(skill?.detail ?? ""); setEditing(true); @@ -101,22 +122,54 @@ export function SkillDetailDialog({ return ( - - - {skill?.name ?? skillName ?? ""} - {skill?.description} + + {/* Header */} + +
+
+ +
+
+
+ + {skill?.name ?? skillName ?? ""} + + {skill?.builtin && ( + + {tx.badgeBuiltin} + + )} + {skill?.published && ( + + + {tx.badgePublished} + + )} +
+ + {skill?.description || "—"} + +
+
-
+ {/* Body */} +
{/* Detail — human-authored long-form introduction */}
-

{tx.detailSectionTitle}

- {canEdit && !editing && ( +

+ + {tx.detailSectionTitle} +

+ {canManage && !editing && ( )}
+ {editing ? (