From ae3788a87d182e148980817be9b01921d53d09d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Wed, 24 Jun 2026 10:00:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1):=20?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF=E6=95=B0=E6=8D=AE=E8=BD=AC=E6=8D=A2=E5=99=A8?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E9=A1=B5=E5=8A=A0=E3=80=8C=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF+=E6=B8=B2=E6=9F=93=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E3=80=8D=E8=B0=83=E8=AF=95=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 POST /api/scheduled-tasks/template-fill/render:不调模型,抽取回复里的 JSON → 校验 → 填充,返回完整 HTML + 校验错误/缺失字段(非法也照样填充便于看效果), 只展示模板原样返回 - 前端仅 template-json-builder 智能体的 AgentChatPage:顶部加页面模板下拉 +「渲染预览」 按钮,取最近一条 AI 回复填进所选模板,弹窗用 sandbox=allow-scripts iframe 预览 + 校验状态;严格按 agent id 门控,不影响其他智能体对话页 - 文档同步 Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- frontend-web/src/core/scheduled-tasks/api.ts | 20 +++ frontend-web/src/pages/AgentChatPage.tsx | 150 ++++++++++++++++++ offline-backend-20260512/backend/CLAUDE.md | 2 +- .../app/gateway/routers/scheduled_tasks.py | 44 +++++ 5 files changed, 216 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 68ca3fe5..163dad12 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx` - **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks), `LightAppManagementPage`/`LightAppListPage`/`LightAppIframeView` (轻应用中心 — register/list/embed mini-apps) -- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, and dispatch. +- **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source can be **pure JSON, plain text, or text-with-embedded-JSON** — the backend pre-extracts any embedded JSON and the agent's `SOUL.md` maps it (or the plain text's semantics) to the template's schema skeleton. **Chat-page debug entry (only this agent's `AgentChatPage`)**: when `agent_id === "template-json-builder"`, the chat header shows a 「页面模板」 select + a 「渲染预览」 button — after a normal Q&A run, the button takes the **latest AI reply**, POSTs it to `POST /api/scheduled-tasks/template-fill/render` (`renderTemplateFill`), and pops a dialog rendering the filled HTML in a `sandbox="allow-scripts"` iframe plus the structure-validation status/errors/gaps — for debugging the agent's conversion effect without creating a scheduled task. Gated strictly on the agent id, so other agents' chat pages are untouched. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, dispatch, and the render API. - **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). - **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). - **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. diff --git a/frontend-web/src/core/scheduled-tasks/api.ts b/frontend-web/src/core/scheduled-tasks/api.ts index 9b73c043..5258bb07 100644 --- a/frontend-web/src/core/scheduled-tasks/api.ts +++ b/frontend-web/src/core/scheduled-tasks/api.ts @@ -42,6 +42,26 @@ export async function listHtmlTemplates() { return data.templates ?? []; } +/** 「模板数据转换器」对话页「渲染预览」结果。 */ +export type TemplateRenderResult = { + ok: boolean; + display_only?: boolean; + errors: string[]; + gaps: string[]; + html: string | null; +}; + +/** 调试用:把一段(智能体回复的)文本里的 JSON 填进所选 HTML 模板,返回完整 HTML 与校验信息。 */ +export async function renderTemplateFill(templateId: string, text: string) { + return parseResponse( + await apiFetch(`${baseUrl()}/template-fill/render`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ template_id: templateId, text }), + }), + ); +} + export interface ListScheduledTasksParams { search?: string; tagIds?: string[]; diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index e3cfe6c0..1a2f6034 100644 --- a/frontend-web/src/pages/AgentChatPage.tsx +++ b/frontend-web/src/pages/AgentChatPage.tsx @@ -31,11 +31,32 @@ import { saveLastAgentId, useThreadSettings } from "@/core/settings"; import { stopThreadRun, useThreadStream } from "@/core/threads/hooks"; import { textOfMessage } from "@/core/threads/utils"; import { OptType, writeYLog } from "@/core/log/y-log"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + renderTemplateFill, + useHtmlTemplates, + type TemplateRenderResult, +} from "@/core/scheduled-tasks"; import { env } from "@/env"; import { readTaskContext, taskThreadMetadata } from "@/core/auth/task-purpose"; import { TaskPurposePanel } from "@/strategy-components/components/task-components/TaskPurposePanel"; import { desensitize } from "@/lib/desensitize"; +/** 内置「模板数据转换器」agent 的稳定 id。仅此 agent 的对话页显示「页面模板 + 渲染预览」调试入口。 */ +const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder"; + export default function AgentChatPage({ embeddedAgentId, chatBasePath, @@ -190,6 +211,41 @@ export default function AgentChatPage({ }, }); + // ── 「模板数据转换器」对话页专属调试入口(仅此 agent)────────────────────────── + // 选一个页面模板,问答跑完后点「渲染预览」:取最近一条 AI 回复里的 JSON 填进模板, + // 弹出 HTML 预览 + 结构校验信息,用于调试该智能体的转换效果。 + const isTemplateBuilder = agent_id === TEMPLATE_BUILDER_AGENT_ID; + const { data: debugTemplates = [] } = useHtmlTemplates(isTemplateBuilder); + const [debugTemplateId, setDebugTemplateId] = useState(""); + const [debugPreviewOpen, setDebugPreviewOpen] = useState(false); + const [debugBusy, setDebugBusy] = useState(false); + const [debugPreview, setDebugPreview] = useState( + null, + ); + const runDebugPreview = useCallback(async () => { + if (!debugTemplateId) return; + const lastAi = [...thread.messages] + .reverse() + .find((m) => (m as { type?: string }).type === "ai"); + const text = (lastAi ? textOfMessage(lastAi) : "") ?? ""; + setDebugBusy(true); + setDebugPreview(null); + setDebugPreviewOpen(true); + try { + const result = await renderTemplateFill(debugTemplateId, text); + setDebugPreview(result); + } catch (error) { + setDebugPreview({ + ok: false, + errors: [error instanceof Error ? error.message : "渲染失败"], + gaps: [], + html: null, + }); + } finally { + setDebugBusy(false); + } + }, [debugTemplateId, thread.messages]); + const handleSubmit = useCallback( (message: PromptInputMessage) => { setHasStartedConversation(true); @@ -327,6 +383,36 @@ export default function AgentChatPage({
+ {/* 「模板数据转换器」专属:选页面模板 + 渲染预览(调试该智能体效果)。 */} + {isTemplateBuilder && ( +
+ + + + +
+ )} {/* 3qfx/xdfx 任务深链:用户主动「新建对话」进问答页后,点此发出任务开场信息开始问答。 首次进入且无历史(taskAutoStart)会自动发,不显示此按钮。 */} {taskStartText && isLandingState && !taskAutoStart && ( @@ -489,6 +575,70 @@ export default function AgentChatPage({ showRecentChats={!taskCtxOn} />
+ + {/* 「模板数据转换器」调试预览弹窗(仅此 agent)。 */} + {isTemplateBuilder && ( + + + + + 模板填充预览 · 渲染结果 + + +
+ {debugBusy && ( + 正在渲染… + )} + {!debugBusy && debugPreview && ( +
+ + {debugPreview.display_only + ? "该模板为「只展示」,已原样输出现有数据" + : debugPreview.html + ? debugPreview.ok + ? "✓ 结构校验通过" + : "⚠ 结构校验有问题(已照常填充,可对照下方修正后再让智能体重答)" + : "✗ 渲染失败"} + + {debugPreview.errors.length > 0 && ( + + 错误:{debugPreview.errors.join(";")} + + )} + {debugPreview.gaps.length > 0 && ( + + 缺失字段:{debugPreview.gaps.slice(0, 20).join("、")} + {debugPreview.gaps.length > 20 ? " …" : ""} + + )} +
+ )} +
+
+ {debugPreview?.html ? ( +