From adcc5a358bc2997e17a26638bbede17b015985e1 Mon Sep 17 00:00:00 2001 From: Administrator Date: Tue, 2 Jun 2026 23:46:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(scheduled-tasks):=20=E6=96=B0=E5=A2=9E=20H?= =?UTF-8?q?TML=20=E9=A1=B5=E9=9D=A2=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1(?= =?UTF-8?q?=E7=94=9F=E6=88=90/=E9=A2=84=E8=A7=88/=E6=94=B6=E8=97=8F/?= =?UTF-8?q?=E6=8A=80=E8=83=BD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 新增 task_kind=html_page 任务类型,与普通定时任务并存,配置存 execution_context(无需迁移) - 执行流水线:智能体检索数据 -> 选定模型渲染 HTML -> 清洗/校验 -> 失败自动修复 -> 完整性闸门(缺 doctype/html/head/body/title 或残留脚本即失败) -> 归档 index.html -> 落库 - 新增 scheduled_html_pages / html_page_favorites 表与 store;收藏去重(不可重复)、可删除、收藏时提取可复用风格 - HTML 预览接口(鉴权 + 公开)、收藏接口 - 渲染注入已启用的 HTML 技能(metadata.kind=html_page);全程使用用户所选模型,去除硬编码 GLM-5 - 定时任务跳过 TitleMiddleware,避免标题生成回退默认模型 前端: - 创建/编辑弹窗增加任务类型与 HTML 配置(页面要求/限制/参考收藏页/模型下拉) - 运行详情页与公开页 iframe 沙箱预览 HTML;收藏按钮(已收藏禁用) - 参考收藏页可选可删 Co-Authored-By: Claude Opus 4.8 --- frontend-web/src/core/scheduled-tasks/api.ts | 49 ++ .../src/core/scheduled-tasks/hooks.ts | 40 ++ .../src/core/scheduled-tasks/types.ts | 65 ++- .../src/pages/PublicScheduledTaskPage.tsx | 128 +++-- .../src/pages/ScheduledTaskRunDetailPage.tsx | 92 +++- frontend-web/src/pages/ScheduledTasksPage.tsx | 312 ++++++++++-- offline-backend-20260512/backend/CLAUDE.md | 8 +- .../backend/app/gateway/app.py | 3 + .../backend/app/gateway/deps.py | 4 + .../gateway/routers/html_page_favorites.py | 93 ++++ .../gateway/routers/public_scheduled_tasks.py | 33 ++ .../app/gateway/routers/scheduled_tasks.py | 23 + .../backend/extensions_config.json | 4 +- .../deerflow/agents/lead_agent/agent.py | 10 +- .../persistence/html_pages/__init__.py | 23 + .../deerflow/persistence/html_pages/base.py | 53 ++ .../deerflow/persistence/html_pages/memory.py | 102 ++++ .../deerflow/persistence/html_pages/model.py | 65 +++ .../deerflow/persistence/html_pages/sql.py | 141 +++++ .../deerflow/runtime/scheduler/__init__.py | 10 +- .../deerflow/runtime/scheduler/html_page.py | 365 +++++++++++++ .../deerflow/runtime/scheduler/service.py | 480 +++++++++++++++++- 22 files changed, 1996 insertions(+), 107 deletions(-) create mode 100644 offline-backend-20260512/backend/app/gateway/routers/html_page_favorites.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/html_pages/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/html_pages/base.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/html_pages/memory.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/html_pages/model.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/html_pages/sql.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/runtime/scheduler/html_page.py diff --git a/frontend-web/src/core/scheduled-tasks/api.ts b/frontend-web/src/core/scheduled-tasks/api.ts index 248e0fe3..c7b1a3af 100644 --- a/frontend-web/src/core/scheduled-tasks/api.ts +++ b/frontend-web/src/core/scheduled-tasks/api.ts @@ -2,12 +2,15 @@ import { apiFetch } from "@/core/api"; import { getBackendBaseURL } from "@/core/config"; import type { + HtmlPageFavorite, PublishedScheduledTask, ScheduledTask, ScheduledTaskDeliveryProfile, + ScheduledTaskExecutionContext, ScheduledTaskResultFileContent, ScheduledTaskRun, ScheduledTaskRunFileUpdateResponse, + ScheduledTaskRunHtml, ScheduledTaskRunMessagesResponse, ScheduledTaskSubscription, SchedulerThread, @@ -52,6 +55,7 @@ export async function createScheduledTask(data: { timezone?: string | null; execution_agent_name?: string | null; require_markdown?: boolean; + execution_context?: ScheduledTaskExecutionContext | null; }) { return parseResponse( await apiFetch(baseUrl(), { @@ -212,6 +216,7 @@ export async function updateScheduledTask( enabled: boolean; execution_agent_name: string | null; require_markdown: boolean; + execution_context: ScheduledTaskExecutionContext | null; }>, ) { return parseResponse( @@ -368,3 +373,47 @@ export async function adminTriggerScheduledTask(taskId: string) { }), ); } + +// --- HTML page tasks --- + +/** Fetch the generated HTML document for an ``html_page`` run. */ +export async function getScheduledTaskRunHtml(runId: string) { + return parseResponse( + await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}/html`), + ); +} + +const favoritesUrl = () => `${getBackendBaseURL()}/api/html-page-favorites`; + +export async function listHtmlPageFavorites() { + return parseResponse(await apiFetch(favoritesUrl())); +} + +export async function getHtmlPageFavorite(favoriteId: string) { + return parseResponse( + await apiFetch(`${favoritesUrl()}/${encodeURIComponent(favoriteId)}`), + ); +} + +export async function createHtmlPageFavorite(data: { + page_id: string; + title?: string | null; + description?: string | null; + tags?: string[]; +}) { + return parseResponse( + await apiFetch(favoritesUrl(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }), + ); +} + +export async function deleteHtmlPageFavorite(favoriteId: string) { + return parseResponse<{ success: boolean }>( + await apiFetch(`${favoritesUrl()}/${encodeURIComponent(favoriteId)}`, { + method: "DELETE", + }), + ); +} diff --git a/frontend-web/src/core/scheduled-tasks/hooks.ts b/frontend-web/src/core/scheduled-tasks/hooks.ts index 454198fe..3f4f1b3a 100644 --- a/frontend-web/src/core/scheduled-tasks/hooks.ts +++ b/frontend-web/src/core/scheduled-tasks/hooks.ts @@ -10,14 +10,18 @@ import { adminListScheduledTaskRuns, adminTriggerScheduledTask, adminUpdateScheduledTask, + createHtmlPageFavorite, createScheduledTask, + deleteHtmlPageFavorite, deleteScheduledTask, deleteScheduledTaskRun, getSchedulerThread, getScheduledTaskDeliveryProfile, getScheduledTaskRun, + getScheduledTaskRunHtml, getScheduledTaskRunMessages, listAllScheduledTasks, + listHtmlPageFavorites, listPublishedScheduledTasks, listScheduledTaskRuns, listScheduledTasks, @@ -112,6 +116,42 @@ export function useScheduledTaskRunMessages( }); } +/** Generated HTML for an ``html_page`` run; polls while the run is in flight. */ +export function useScheduledTaskRunHtml( + runId?: string | null, + options: { enabled?: boolean; live?: boolean } = {}, +) { + return useQuery({ + queryKey: ["scheduled-tasks", "runs", runId, "html"], + queryFn: () => getScheduledTaskRunHtml(runId!), + enabled: !!runId && (options.enabled ?? true), + refetchOnWindowFocus: false, + retry: false, + refetchInterval: options.live ? 5000 : false, + }); +} + +/** The current user's bookmarked HTML pages. */ +export function useHtmlPageFavorites(enabled: boolean = true) { + return useQuery({ + queryKey: ["html-page-favorites"], + queryFn: listHtmlPageFavorites, + enabled, + refetchOnWindowFocus: false, + }); +} + +/** Create / delete HTML page favorites. */ +export function useHtmlPageFavoriteActions() { + const queryClient = useQueryClient(); + const invalidate = () => + queryClient.invalidateQueries({ queryKey: ["html-page-favorites"] }); + return { + create: useMutation({ mutationFn: createHtmlPageFavorite, onSuccess: invalidate }), + remove: useMutation({ mutationFn: deleteHtmlPageFavorite, onSuccess: invalidate }), + }; +} + export function useScheduledTaskDeliveryProfile() { return useQuery({ queryKey: ["scheduled-tasks", "delivery-profile"], diff --git a/frontend-web/src/core/scheduled-tasks/types.ts b/frontend-web/src/core/scheduled-tasks/types.ts index 284a5801..3c3cc8c6 100644 --- a/frontend-web/src/core/scheduled-tasks/types.ts +++ b/frontend-web/src/core/scheduled-tasks/types.ts @@ -11,6 +11,26 @@ export type ScheduledTaskSubscription = { updated_at?: string | null; }; +/** Task kinds. A bare/absent kind is the regular agent task. */ +export type ScheduledTaskKind = "agent" | "html_page"; + +/** Config for an ``html_page`` task, persisted inside ``execution_context``. + * Data gathering is delegated to the agent's own search skills, so there is no + * explicit search query / data source — the page prompt drives everything. */ +export type HtmlPageConfig = { + page_prompt?: string | null; + negative_prompt?: string | null; + reference_favorite_id?: string | null; + model_name?: string | null; +}; + +export type ScheduledTaskExecutionContext = { + require_markdown?: boolean; + task_kind?: ScheduledTaskKind; + html_config?: HtmlPageConfig; + [key: string]: unknown; +}; + export type ScheduledTask = { task_id: string; name: string; @@ -21,11 +41,11 @@ export type ScheduledTask = { timezone: string; enabled: boolean; /** - * Free-form execution context persisted with the task. ``require_markdown`` - * lives here (rather than a dedicated column) so the feature needs no schema - * migration. + * Free-form execution context persisted with the task. ``require_markdown``, + * ``task_kind`` and ``html_config`` live here (rather than dedicated columns) + * so the feature needs no schema migration. */ - execution_context?: { require_markdown?: boolean; [key: string]: unknown } | null; + execution_context?: ScheduledTaskExecutionContext | null; next_run_at?: string | null; last_run_at?: string | null; last_status?: string | null; @@ -70,7 +90,7 @@ export type ScheduledTaskResultFile = { path?: string | null; mime_type?: string | null; size?: number | null; - kind?: "markdown" | "attachment" | "artifact" | null; + kind?: "markdown" | "attachment" | "artifact" | "html" | null; }; export type ScheduledTaskResultFileContent = { @@ -86,9 +106,42 @@ export type ScheduledTaskResultFileContent = { export type ScheduledTaskRunResult = { title: string; content: string; - format: "markdown" | "text" | "json" | "mixed"; + format: "markdown" | "text" | "json" | "mixed" | "html"; files: ScheduledTaskResultFile[]; created_at: string; + /** Present for ``html_page`` runs: the full generated HTML document. */ + html?: string | null; + style_summary?: string | null; + layout_summary?: string | null; +}; + +/** Response of the run/latest HTML preview endpoints. */ +export type ScheduledTaskRunHtml = { + run_id: string; + task_id?: string | null; + title?: string | null; + html?: string | null; + style_summary?: string | null; + layout_summary?: string | null; + page_id?: string | null; + created_at?: string | null; +}; + +/** A user-bookmarked generated HTML page. */ +export type HtmlPageFavorite = { + id: string; + user_id: string; + page_id: string; + title: string; + description?: string | null; + tags?: string[]; + html_snapshot?: string; + style_summary?: string | null; + layout_summary?: string | null; + content_structure_summary?: string | null; + reuse_prompt?: string | null; + created_at?: string | null; + updated_at?: string | null; }; export type ScheduledTaskRun = { diff --git a/frontend-web/src/pages/PublicScheduledTaskPage.tsx b/frontend-web/src/pages/PublicScheduledTaskPage.tsx index ef623b9d..87300e09 100644 --- a/frontend-web/src/pages/PublicScheduledTaskPage.tsx +++ b/frontend-web/src/pages/PublicScheduledTaskPage.tsx @@ -68,9 +68,11 @@ type PublicRunFile = { type PublicRunResult = { title: string; content: string; - format: "markdown" | "text" | "json" | "mixed"; + format: "markdown" | "text" | "json" | "mixed" | "html"; files: PublicRunFile[]; created_at: string; + /** Present for ``html_page`` runs: the full generated HTML document. */ + html?: string | null; }; type PublicRun = { @@ -126,6 +128,12 @@ async function fetchPublicRunFile(runId: string, index: number): Promise { + return fetchJson(`${publicBaseUrl()}/runs/${encodeURIComponent(runId)}/html`); +} + async function updatePublicRunFile( runId: string, index: number, @@ -296,6 +304,13 @@ export default function PublicScheduledTaskPage() { if (run.status !== "succeeded") return false; const result = run.result; if (!result) return false; + // HTML page runs always have something to render (the iframe preview). + const isHtml = + result.format === "html" || + (result.files ?? []).some( + (file) => file.kind === "html" || (file.name ?? "").toLowerCase().endsWith(".html"), + ); + if (isHtml) return true; const hasBody = !!result.content && result.content.trim().length > 0; const hasMarkdownFile = (result.files ?? []).some( (file) => isMarkdownFile(file) && isFileAvailable(file), @@ -550,6 +565,23 @@ type ContentTab = { function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) { const queryClient = useQueryClient(); + // HTML page run: render a sandboxed iframe preview instead of the MD tabs. + const isHtmlRun = + run.result?.format === "html" || + (run.result?.files ?? []).some( + (f) => f.kind === "html" || (f.name ?? "").toLowerCase().endsWith(".html"), + ); + // Prefer the inline copy; fall back to the LONGTEXT-backed /html endpoint for + // large pages whose HTML was not inlined into result_json. + const { data: htmlData } = useQuery({ + queryKey: ["public-scheduled-task", "html", run.id], + queryFn: () => fetchPublicRunHtml(run.id), + enabled: isHtmlRun && !run.result?.html, + refetchOnWindowFocus: false, + retry: false, + }); + const htmlContent = run.result?.html ?? htmlData?.html ?? null; + // Skip files whose archive is missing on disk — otherwise the public viewer // would render a tab that 404s with "Result file is no longer available". const markdownFiles = useMemo( @@ -719,56 +751,74 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) { 下载原文件 )} - - {isEditing ? ( - <> + {!isHtmlRun && ( + + )} + {!isHtmlRun && + (isEditing ? ( + <> + + + + ) : ( - - - ) : ( - - )} + ))} - {tabs.length === 0 ? ( + {isHtmlRun ? ( +
+ {htmlContent ? ( +