feat(scheduled-tasks): 新增 HTML 页面定时任务(生成/预览/收藏/技能)

后端:
- 新增 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 <noreply@anthropic.com>
This commit is contained in:
AdministratorandClaude Opus 4.8 committed 2026-06-02 23:46:20 +08:00
1 parent e1e9be8daa
commit d9afaca51f
22 files changed
+1996 -107

No files matched your search

@@ -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<ScheduledTask>(
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<ScheduledTask>(
@@ -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<ScheduledTaskRunHtml>(
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}/html`),
);
}
const favoritesUrl = () => `${getBackendBaseURL()}/api/html-page-favorites`;
export async function listHtmlPageFavorites() {
return parseResponse<HtmlPageFavorite[]>(await apiFetch(favoritesUrl()));
}
export async function getHtmlPageFavorite(favoriteId: string) {
return parseResponse<HtmlPageFavorite>(
await apiFetch(`${favoritesUrl()}/${encodeURIComponent(favoriteId)}`),
);
}
export async function createHtmlPageFavorite(data: {
page_id: string;
title?: string | null;
description?: string | null;
tags?: string[];
}) {
return parseResponse<HtmlPageFavorite>(
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",
}),
);
}
@@ -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"],
+59 -6
View File
@@ -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 = {
@@ -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<PublicF
);
}
type PublicRunHtml = { run_id: string; title?: string | null; html?: string | null };
async function fetchPublicRunHtml(runId: string): Promise<PublicRunHtml> {
return fetchJson<PublicRunHtml>(`${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 }) {
下载原文件
</a>
)}
<Button size="sm" variant="outline" onClick={handleDownloadWord}>
<FileText className="size-4" />
下载 Word
</Button>
{isEditing ? (
<>
{!isHtmlRun && (
<Button size="sm" variant="outline" onClick={handleDownloadWord}>
<FileText className="size-4" />
下载 Word
</Button>
)}
{!isHtmlRun &&
(isEditing ? (
<>
<Button
size="sm"
variant="outline"
onClick={() => {
setIsEditing(false);
setDraft(activeSource);
}}
disabled={saveMutation.isPending}
>
<X className="size-4" />
取消
</Button>
<Button
size="sm"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Check className="size-4" />
)}
保存
</Button>
</>
) : (
<Button
size="sm"
variant="outline"
onClick={() => {
setIsEditing(false);
setDraft(activeSource);
setIsEditing(true);
}}
disabled={saveMutation.isPending}
disabled={fileIsLoading}
>
<X className="size-4" />
取消
<Pencil className="size-4" />
编辑
</Button>
<Button
size="sm"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending}
>
{saveMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Check className="size-4" />
)}
保存
</Button>
</>
) : (
<Button
size="sm"
variant="outline"
onClick={() => {
setDraft(activeSource);
setIsEditing(true);
}}
disabled={fileIsLoading}
>
<Pencil className="size-4" />
编辑
</Button>
)}
))}
</div>
</CardHeader>
<CardContent className="min-h-0 flex-1 overflow-hidden p-0">
{tabs.length === 0 ? (
{isHtmlRun ? (
<div className="h-full p-4">
{htmlContent ? (
<iframe
title="HTML 预览"
sandbox="allow-same-origin"
srcDoc={htmlContent}
className="h-full min-h-[60vh] w-full rounded-md border bg-white"
/>
) : (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
HTML 页面尚未生成或正在生成中。
</div>
)}
</div>
) : tabs.length === 0 ? (
<div className="text-muted-foreground p-6 text-sm">无可用内容。</div>
) : (
<Tabs
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Activity, ChevronDown, ChevronLeft, Download, Edit3, FileText, Loader2, Save, Trash2, X } from "lucide-react";
import { Activity, ChevronDown, ChevronLeft, Download, Edit3, FileText, Heart, Loader2, Save, Code2, Trash2, X } from "lucide-react";
import { useParams, useSearchParams } from "react-router-dom";
import { useRouteNavigate as useNavigate } from "@/core/route-base";
import { toast } from "sonner";
@@ -26,8 +26,11 @@ import {
type ScheduledTaskRun,
type ScheduledTaskRunMessage,
updateScheduledTaskRunFile,
useHtmlPageFavoriteActions,
useHtmlPageFavorites,
useScheduledTaskActions,
useScheduledTaskRun,
useScheduledTaskRunHtml,
useScheduledTaskRunMessages,
} from "@/core/scheduled-tasks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
@@ -97,6 +100,22 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
return <pre className="whitespace-pre-wrap text-sm">{file.content}</pre>;
}
/**
* Sandboxed preview of a generated HTML document. The iframe has no
* ``allow-scripts`` token, so embedded JS cannot run — defense-in-depth on top
* of the server-side sanitization that already stripped script vectors.
*/
function HtmlPreviewFrame({ html }: { html: string }) {
return (
<iframe
title="HTML 预览"
sandbox="allow-same-origin"
srcDoc={html}
className="h-[70vh] w-full rounded-md border bg-white"
/>
);
}
type EditState = { draft: string; saving: boolean };
/** Flatten a LangChain-style message content (string | parts | nested) to text. */
@@ -217,6 +236,40 @@ export default function ScheduledTaskRunDetailPage() {
const canEditRun = run?.can_edit === true;
const canDeleteRun = canEditRun;
// HTML page run: surface a sandboxed iframe preview + favorite button.
const isHtmlRun = useMemo(() => {
if (!result) return false;
if (result.format === "html") return true;
return (result.files ?? []).some(
(f) => f.kind === "html" || (f.name ?? "").toLowerCase().endsWith(".html"),
);
}, [result]);
const { data: htmlData } = useScheduledTaskRunHtml(run_id, {
enabled: isHtmlRun,
live: runLive,
});
const htmlContent = htmlData?.html ?? result?.html ?? null;
const htmlPageId = htmlData?.page_id ?? null;
const favoriteActions = useHtmlPageFavoriteActions();
const { data: htmlFavorites = [] } = useHtmlPageFavorites(isHtmlRun);
const alreadyFavorited = !!htmlPageId && htmlFavorites.some((f) => f.page_id === htmlPageId);
const handleFavorite = async () => {
if (!htmlPageId) {
toast.error("页面尚未生成或无法收藏");
return;
}
if (alreadyFavorited) {
toast.error("该页面已收藏,请勿重复收藏");
return;
}
try {
await favoriteActions.create.mutateAsync({ page_id: htmlPageId });
toast.success("已收藏,可在新建 HTML 任务时作为参考风格");
} catch (error) {
toast.error(error instanceof Error ? error.message : "收藏失败");
}
};
const resultFiles = useMemo(() => result?.files ?? [], [result]);
const fileCount = resultFiles.length;
const resultMetadata = useMemo(() => {
@@ -444,11 +497,19 @@ export default function ScheduledTaskRunDetailPage() {
)}
{run && (result || hasExecution) && (
<Tabs
defaultValue={result ? (fileCount > 0 ? "files" : "body") : "execution"}
defaultValue={
result ? (isHtmlRun ? "html" : fileCount > 0 ? "files" : "body") : "execution"
}
className="flex min-h-0 flex-1 flex-col"
>
<div className="border-b px-3 pt-1">
<TabsList className="h-8">
{result && isHtmlRun && (
<TabsTrigger value="html" className="text-xs">
<Code2 className="mr-1 size-3" />
HTML 预览
</TabsTrigger>
)}
{result && (
<TabsTrigger value="body" className="text-xs">
<FileText className="mr-1 size-3" />
@@ -476,6 +537,33 @@ export default function ScheduledTaskRunDetailPage() {
<ScrollArea className="min-h-0 flex-1">
{result && (
<>
{isHtmlRun && (
<TabsContent value="html" className="m-0 p-4">
<div className="mb-3 flex flex-wrap items-center justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={handleFavorite}
disabled={!htmlPageId || alreadyFavorited || favoriteActions.create.isPending}
title={
alreadyFavorited
? "该页面已收藏"
: "收藏此页面,作为后续 HTML 任务的参考风格"
}
>
<Heart className={cn("size-4", alreadyFavorited && "fill-current text-rose-500")} />
{alreadyFavorited ? "已收藏" : "收藏页面"}
</Button>
</div>
{htmlContent ? (
<HtmlPreviewFrame html={htmlContent} />
) : (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
HTML 页面尚未生成或正在生成中。
</div>
)}
</TabsContent>
)}
<TabsContent value="body" className="m-0 p-4">
{result.format === "markdown" || result.format === "mixed" ? (
<MarkdownContent
+258 -54
View File
@@ -45,10 +45,14 @@ import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useAgents } from "@/core/agents";
import { getStoredAuth } from "@/core/auth";
import { useModels } from "@/core/models/hooks";
import {
downloadScheduledTaskRun,
type PublishedScheduledTask,
type ScheduledTask,
type ScheduledTaskExecutionContext,
useHtmlPageFavoriteActions,
useHtmlPageFavorites,
useScheduledTaskActions,
useScheduledTaskDeliveryProfile,
useScheduledTaskRuns,
@@ -110,6 +114,15 @@ const emptyForm = {
prompt: "",
execution_agent_name: "",
require_markdown: false,
// HTML page task fields. ``task_kind`` "agent" keeps the original behavior.
// Data gathering is delegated to the agent's own search skills — no explicit
// search query / data source. ``html_model_name`` empty means "use the first
// available model" — never a hardcoded GLM-5.
task_kind: "agent" as "agent" | "html_page",
html_page_prompt: "",
html_negative_prompt: "",
html_reference_favorite_id: "",
html_model_name: "",
};
export default function ScheduledTasksPage() {
@@ -140,6 +153,25 @@ export default function ScheduledTasksPage() {
const [editing, setEditing] = useState<ScheduledTask | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState(emptyForm);
const dialogOpen = creating || !!editing;
const { data: htmlFavorites = [] } = useHtmlPageFavorites(dialogOpen);
const htmlFavoriteActions = useHtmlPageFavoriteActions();
const { models: availableModels } = useModels({ enabled: dialogOpen });
const handleDeleteFavorite = async (favoriteId: string) => {
try {
await htmlFavoriteActions.remove.mutateAsync(favoriteId);
// Clear the selection if the removed favorite was the chosen reference.
setForm((value) =>
value.html_reference_favorite_id === favoriteId
? { ...value, html_reference_favorite_id: "" }
: value,
);
toast.success("收藏已删除", darkToastOpts.success);
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败", darkToastOpts.error);
}
};
const [scheduleConfig, setScheduleConfig] = useState<ScheduleConfig>(DEFAULT_SCHEDULE_CONFIG);
const [runsTimeFilter, setRunsTimeFilter] = useState<"all" | "today" | "7d" | "30d">("all");
const [subscribeTarget, setSubscribeTarget] = useState<PublishedScheduledTask | null>(null);
@@ -197,6 +229,7 @@ export default function ScheduledTasksPage() {
const openEdit = (task: ScheduledTask) => {
setCreating(false);
setEditing(task);
const html = task.execution_context?.html_config ?? {};
setForm({
name: task.name,
description: task.description ?? "",
@@ -205,6 +238,11 @@ export default function ScheduledTasksPage() {
prompt: task.prompt,
execution_agent_name: task.execution_agent_name ?? "",
require_markdown: !!task.execution_context?.require_markdown,
task_kind: task.execution_context?.task_kind === "html_page" ? "html_page" : "agent",
html_page_prompt: html.page_prompt ?? "",
html_negative_prompt: html.negative_prompt ?? "",
html_reference_favorite_id: html.reference_favorite_id ?? "",
html_model_name: html.model_name ?? "",
});
// Prefer the canonical cron_expr over the user-typed schedule_text: the
// latter may be natural language ("每天9点") that parseCron can't read,
@@ -223,18 +261,48 @@ export default function ScheduledTasksPage() {
setScheduleConfig(DEFAULT_SCHEDULE_CONFIG);
};
const isHtmlTask = form.task_kind === "html_page";
// The model actually shown/used: the user's choice, else the first configured
// model. Never a hardcoded GLM-5 — it follows config order and the selection.
const effectiveHtmlModel = form.html_model_name || availableModels[0]?.name || "";
// Build the execution_context for the current form. HTML tasks persist their
// config under ``html_config``; regular tasks send an explicit ``agent`` kind
// so switching a task back from HTML clears the old config.
const buildExecutionContext = (): ScheduledTaskExecutionContext => {
if (!isHtmlTask) return { task_kind: "agent", html_config: undefined };
return {
task_kind: "html_page",
html_config: {
page_prompt: form.html_page_prompt.trim() || null,
negative_prompt: form.html_negative_prompt.trim() || null,
reference_favorite_id: form.html_reference_favorite_id || null,
// Send the user's selection; fall back to the first configured model,
// else null (backend then uses the system default). No hardcoded GLM-5.
model_name: form.html_model_name.trim() || availableModels[0]?.name || null,
},
};
};
const saveTask = async () => {
try {
const cronSchedule = buildCron(scheduleConfig);
const executionContext = buildExecutionContext();
// HTML tasks may leave the agent prompt empty — fall back to the page
// prompt so the (non-null) prompt column always has something useful.
const effectivePrompt = isHtmlTask
? form.prompt.trim() || form.html_page_prompt.trim() || "生成 HTML 页面"
: form.prompt;
if (creating) {
const task = await actions.create.mutateAsync({
name: form.name || "定时任务",
description: form.description.trim() || null,
schedule: cronSchedule,
timezone: form.timezone || "Asia/Shanghai",
prompt: form.prompt,
execution_agent_name: form.execution_agent_name || null,
require_markdown: form.require_markdown,
prompt: effectivePrompt,
execution_agent_name: isHtmlTask ? null : form.execution_agent_name || null,
require_markdown: isHtmlTask ? false : form.require_markdown,
execution_context: executionContext,
});
setSelectedTaskId(task.task_id);
toast.success("任务已创建", darkToastOpts.success);
@@ -246,9 +314,10 @@ export default function ScheduledTasksPage() {
description: form.description.trim() || null,
schedule: cronSchedule,
timezone: form.timezone,
prompt: form.prompt,
execution_agent_name: form.execution_agent_name || null,
require_markdown: form.require_markdown,
prompt: effectivePrompt,
execution_agent_name: isHtmlTask ? null : form.execution_agent_name || null,
require_markdown: isHtmlTask ? false : form.require_markdown,
execution_context: executionContext,
},
});
toast.success("任务已更新", darkToastOpts.success);
@@ -404,6 +473,14 @@ export default function ScheduledTasksPage() {
<Globe className="size-3" />已发布
</Badge>
)}
{task.execution_context?.task_kind === "html_page" && (
<Badge
variant="outline"
className="shrink-0 border-sky-500/30 bg-sky-500/10 text-xs text-sky-600 dark:text-sky-400"
>
HTML 页面
</Badge>
)}
</div>
<div className="text-muted-foreground mt-1 truncate text-xs">
{desensitize(task.schedule_text ?? "")} · {task.timezone}
@@ -828,7 +905,7 @@ export default function ScheduledTasksPage() {
<DialogHeader className="min-w-0">
<DialogTitle>{creating ? "新建定时任务" : "编辑定时任务"}</DialogTitle>
</DialogHeader>
<div className="flex min-w-0 flex-col gap-3">
<div className="flex min-h-0 max-h-[70vh] min-w-0 flex-col gap-3 overflow-y-auto pr-1">
<Input
value={form.name}
onChange={(event) =>
@@ -844,6 +921,20 @@ export default function ScheduledTasksPage() {
}
placeholder="任务描述(可选,将显示在公开页标题下方)"
/>
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">任务类型</p>
<Tabs
value={form.task_kind}
onValueChange={(v) =>
setForm((value) => ({ ...value, task_kind: v as "agent" | "html_page" }))
}
>
<TabsList className="w-full">
<TabsTrigger value="agent" className="flex-1">普通任务</TabsTrigger>
<TabsTrigger value="html_page" className="flex-1">HTML 页面</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">执行频率</p>
<ScheduleBuilder value={scheduleConfig} onChange={setScheduleConfig} />
@@ -855,55 +946,168 @@ export default function ScheduledTasksPage() {
}
placeholder="时区,例如:Asia/Shanghai"
/>
<Select
value={form.execution_agent_name || "__default__"}
onValueChange={(v) =>
setForm((value) => ({
...value,
execution_agent_name: v === "__default__" ? "" : v,
}))
}
>
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
<SelectValue placeholder="选择执行智能体" />
</SelectTrigger>
<SelectContent
position="popper"
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
>
<SelectItem value="__default__">默认助手(不指定)</SelectItem>
{agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
<span className="block truncate">
{desensitize(agent.name)}
{agent.description ? ` — ${desensitize(agent.description)}` : ""}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Textarea
className="min-h-40"
value={form.prompt}
onChange={(event) =>
setForm((value) => ({ ...value, prompt: event.target.value }))
}
placeholder="任务 Prompt"
/>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">必须生成 Markdown 文件</p>
{!isHtmlTask && (
<>
<Select
value={form.execution_agent_name || "__default__"}
onValueChange={(v) =>
setForm((value) => ({
...value,
execution_agent_name: v === "__default__" ? "" : v,
}))
}
>
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
<SelectValue placeholder="选择执行智能体" />
</SelectTrigger>
<SelectContent
position="popper"
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
>
<SelectItem value="__default__">默认助手(不指定)</SelectItem>
{agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
<span className="block truncate">
{desensitize(agent.name)}
{agent.description ? ` — ${desensitize(agent.description)}` : ""}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Textarea
className="min-h-40"
value={form.prompt}
onChange={(event) =>
setForm((value) => ({ ...value, prompt: event.target.value }))
}
placeholder="任务 Prompt"
/>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">必须生成 Markdown 文件</p>
<p className="text-muted-foreground text-xs">
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
</p>
</div>
<Switch
checked={form.require_markdown}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_markdown: v }))
}
/>
</div>
</>
)}
{isHtmlTask && (
<>
<Textarea
className="min-h-24"
value={form.html_page_prompt}
onChange={(event) =>
setForm((value) => ({ ...value, html_page_prompt: event.target.value }))
}
placeholder="页面生成要求,例如:检索新能源车最新舆情,生成适合领导快速浏览的态势页"
/>
<Textarea
className="min-h-16"
value={form.html_negative_prompt}
onChange={(event) =>
setForm((value) => ({ ...value, html_negative_prompt: event.target.value }))
}
placeholder="限制条件(可选),例如:不要使用深色背景,不要出现外部链接脚本"
/>
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">参考收藏页面(可选)</p>
<div className="flex flex-col gap-1 rounded-md border p-1">
<button
type="button"
onClick={() =>
setForm((value) => ({ ...value, html_reference_favorite_id: "" }))
}
className={cn(
"rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/60",
!form.html_reference_favorite_id && "bg-accent",
)}
>
不参考(默认)
</button>
{htmlFavorites.map((fav) => {
const selected = form.html_reference_favorite_id === fav.id;
return (
<div key={fav.id} className="flex items-center gap-1">
<button
type="button"
onClick={() =>
setForm((value) => ({ ...value, html_reference_favorite_id: fav.id }))
}
className={cn(
"min-w-0 flex-1 truncate rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/60",
selected && "bg-accent",
)}
title={fav.title}
>
{fav.title}
</button>
<Button
type="button"
size="icon"
variant="ghost"
className="size-7 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => handleDeleteFavorite(fav.id)}
disabled={htmlFavoriteActions.remove.isPending}
title="删除该收藏"
>
<Trash2 className="size-4" />
</Button>
</div>
);
})}
{htmlFavorites.length === 0 && (
<div className="text-muted-foreground px-2 py-1.5 text-xs">
暂无收藏页面。可在执行记录的 HTML 预览中收藏页面后在此引用。
</div>
)}
</div>
</div>
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">生成模型</p>
<Select
value={effectiveHtmlModel}
onValueChange={(v) =>
setForm((value) => ({ ...value, html_model_name: v }))
}
>
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
<SelectValue placeholder="选择生成模型" />
</SelectTrigger>
<SelectContent
position="popper"
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
>
{availableModels.map((model) => (
<SelectItem key={model.id || model.name} value={model.name}>
<span className="block truncate">{model.display_name || model.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Textarea
className="min-h-16"
value={form.prompt}
onChange={(event) =>
setForm((value) => ({ ...value, prompt: event.target.value }))
}
placeholder="补充说明(可选):进一步约束智能体如何检索数据、页面包含哪些内容"
/>
<p className="text-muted-foreground text-xs">
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
执行流程:智能体运用自身检索技能整理真实数据 → 选定模型渲染为完整 HTML → 自动安全清洗与结构校验 → 可在执行记录中预览。
</p>
</div>
<Switch
checked={form.require_markdown}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_markdown: v }))
}
/>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={closeDialog}>