diff --git a/CLAUDE.md b/CLAUDE.md index 51d1dbae..4f68d459 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,11 +79,12 @@ 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 JSON is **any** shape — the agent's `SOUL.md` maps it 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. - **轻应用中心 (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. - **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。 -- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. +- **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」页,在 **both** 单智能体 (`TaskFxWorkspace`) 与 多智能体会商 (`RoundtablePlanningPage` 深链 rwfx-A) 都显示(共用 `TaskJumpBar`)。**`runTaskButton` 的 `url` 类型支持应用内路由**:target 以单个 `/` 开头时按内部路由处理——`self` 走 react-router 同窗导航(不刷新),`blank` 拼成当前源的 hash 路由新标签打开;外链行为不变。 The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. - **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes` - **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE - **UI**: Radix UI primitives + TailwindCSS v4 + shadcn-style components in `src/components/ui/` diff --git a/frontend-web/.env.development b/frontend-web/.env.development index b78f859a..3837c075 100644 --- a/frontend-web/.env.development +++ b/frontend-web/.env.development @@ -8,7 +8,7 @@ VITE_USE_REAL_DATA=true VITE_MOCK_COP_TASK_DETAIL=true # 研判情报报告(大屏绘制智能体深链 selectSqReport)数据源:true 走本地样例假数据(真实接口未就绪时调试用);联调通过后置 false。 -VITE_MOCK_SQ_REPORT=false +VITE_MOCK_SQ_REPORT=true # 深链任务详情(cop-task-detail:任务名称/描述)数据源:true 走本地假数据(真实接口未就绪时调试用);联调通过后置 false。 VITE_MOCK_COP_TASK_DETAIL=true diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index f10dc1bc..95c5668c 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -1,6 +1,6 @@ import type { Message } from "@langchain/langgraph-sdk"; import type { BaseStream } from "@langchain/langgraph-sdk/react"; -import { CheckIcon, SendIcon, XIcon } from "lucide-react"; +import { CheckIcon, PenLineIcon, SendIcon, XIcon } from "lucide-react"; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { useStickToBottomContext } from "use-stick-to-bottom"; @@ -20,10 +20,10 @@ import { groupMessages, hasContent, hasPresentFiles, - hasReasoning, - hasToolCalls, - isHiddenFromUIMessage, -} from "@/core/messages/utils"; + hasReasoning, + hasToolCalls, + isHiddenFromUIMessage, +} from "@/core/messages/utils"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { useSkills } from "@/core/skills/hooks"; @@ -103,6 +103,7 @@ export function MessageList({ onSetupAgentReject, onSetupWritingApprove, onSetupWritingReject, + onSetupWritingStart, }: { className?: string; contentClassName?: string; @@ -120,6 +121,8 @@ export function MessageList({ onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise; onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise; onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise; + /** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */ + onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise; }) { const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const rehypePlugins = useMemo( @@ -152,10 +155,10 @@ export function MessageList({ }); }, []); - const allMessages = useMemo( - () => thread.messages.filter((m) => !isHiddenFromUIMessage(m)), - [thread.messages], - ); + const allMessages = useMemo( + () => thread.messages.filter((m) => !isHiddenFromUIMessage(m)), + [thread.messages], + ); const messages = useMemo( () => hiddenMessageIds.size === 0 @@ -430,6 +433,7 @@ export function MessageList({ message={message} onApprove={onSetupWritingApprove} onReject={onSetupWritingReject} + onStart={onSetupWritingStart} /> void | Promise; onReject?: (payload: SetupWritingApprovalPayload) => void | Promise; + onStart?: (payload: SetupWritingApprovalPayload) => void | Promise; }) { const payload = getSetupWritingApprovalPayload(message); - const [submitting, setSubmitting] = useState<"approve" | "reject" | null>( + const [submitting, setSubmitting] = useState<"approve" | "reject" | "start" | null>( null, ); @@ -838,7 +846,7 @@ function SetupWritingApprovalCard({ const approvalPayload = payload; const busy = disabled || submitting !== null; - async function run(action: "approve" | "reject") { + async function run(action: "approve" | "reject" | "start") { if (busy) { return; } @@ -846,6 +854,8 @@ function SetupWritingApprovalCard({ try { if (action === "approve") { await onApprove?.(approvalPayload); + } else if (action === "start") { + await onStart?.(approvalPayload); } else { await onReject?.(approvalPayload); } @@ -854,9 +864,14 @@ function SetupWritingApprovalCard({ } } + const hasNewType = !!approvalPayload.article_type_detail?.trim(); const rows: Array<{ label: string; value: string }> = []; if (approvalPayload.article_type) { - rows.push({ label: "文章类型", value: approvalPayload.article_type }); + rows.push({ + label: "文章类型", + // 新类型(列表外)标注「· 新」,让用户知道这是模型新识别的类型。 + value: hasNewType ? `${approvalPayload.article_type} · 新` : approvalPayload.article_type, + }); } if (typeof approvalPayload.word_count_target === "number") { rows.push({ label: "目标字数", value: `${approvalPayload.word_count_target} 字` }); @@ -903,6 +918,13 @@ function SetupWritingApprovalCard({ ) : null} + {hasNewType ? ( +
+
新类型说明
+

{approvalPayload.article_type_detail}

+
+ ) : null} + {approvalPayload.outline ? (
已有大纲
@@ -926,6 +948,7 @@ function SetupWritingApprovalCard({ + {onStart ? ( + + ) : null}
); diff --git a/frontend-web/src/core/auth/task-buttons.ts b/frontend-web/src/core/auth/task-buttons.ts index edc31642..84be6bfe 100644 --- a/frontend-web/src/core/auth/task-buttons.ts +++ b/frontend-web/src/core/auth/task-buttons.ts @@ -7,7 +7,8 @@ * 任务深链配置读出 rwfx 的 4 个历史按钮,3qfx/xdfx 为空)——这样真实跳转地址通过实时 * config 进种子,避免写死;一旦保存过,后端即唯一数据源。 * - * 按钮两类(``linkType``):``url`` 外链(``window.open``,可选追加 ``?id=任务id``)/ + * 按钮两类(``linkType``):``url``(外链 ``window.open``;**或应用内路由**——target 以 ``/`` 开头 + * 时按内部路由处理:``self`` 走 react-router 同窗导航、``blank`` 拼 hash 路由新标签;可选追加 ``?{idParam}=id``)/ * ``business`` 内部切换到另一业务(复用 [[task-deeplink]] 的 ``resolveTaskDeeplinkTarget``)。 */ @@ -20,6 +21,9 @@ import { resolveTaskDeeplinkTarget, } from "@/core/auth/task-deeplink"; +/** 「大屏绘制」独立页应用内路由(深链「上一步」默认指向它,跳转追加 ?taskId=)。 */ +export const DASHBOARD_AGENT_ROUTE = "/page/strategy/dashboard-agent"; + /** 任务工作区业务码(与路由 taskBase 一一对应)。 */ export type TaskBusiness = "rwfx" | "3qfx" | "xdfx"; @@ -94,7 +98,8 @@ export const TASK_BUSINESS_TARGETS: { code: TaskBusiness; label: string }[] = [ /** * 代码种子默认按钮(仅在后端整张表为空时使用): - * - rwfx:历史 4 个按钮(编辑/下一步/3q详情,地址取自实时任务深链配置)+ 切换到三情/行动分析; + * - rwfx:历史按钮(编辑/下一步/3q详情,地址取自实时任务深链配置)+「上一步」+ 切换到三情/行动分析; + * 「上一步」紧邻「下一步」左侧(底部栏 prev 组在 next 组前),默认指向应用内大屏绘制页(?taskId=任务id); * - 3qfx / xdfx:互相切换到另外两个业务。 * 即「三个业务都带切换按钮」。已保存过则用页面「导入默认」把缺失项补进来。 */ @@ -147,6 +152,24 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] { enabled: true, sortOrder: 0, }, + { + // 上一步:放在「下一步」左边(底部快捷跳转栏,prev 组渲染在 next 组之前)。 + // 默认指向**应用内**「大屏绘制」页 /page/strategy/dashboard-agent(内部路由,以 / 开头), + // 携带 ?taskId=任务id(大屏页按 taskId 取数)。openMode=self → 同窗 react-router 导航; + // 大屏页路由在 PageLayout 里被**强制显示全局 chrome**(见 PageRoutes 的 forceChromeForDashboardAgent), + // 故即便从去 chrome 的工作区进来也带回顶部导航栏 + 侧边栏(embed=1 外部嵌入态仍隐藏)。 + id: "rwfx-prev", + business: "rwfx", + label: "上一步", + linkType: "url", + target: DASHBOARD_AGENT_ROUTE, + appendTaskId: true, + idParam: "taskId", + idKind: "task", + openMode: "self", + enabled: true, + sortOrder: 1, + }, { id: "rwfx-next", business: "rwfx", @@ -158,7 +181,7 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] { idKind: "task", openMode: "blank", enabled: true, - sortOrder: 1, + sortOrder: 2, }, { id: "rwfx-detail3q", @@ -171,11 +194,11 @@ export function defaultTaskButtons(cfg: TaskDeeplinkConfig): TaskButton[] { idKind: "task", openMode: "blank", enabled: true, - sortOrder: 2, + sortOrder: 3, }, - sw("rwfx-switch3q", "rwfx", "切换到三情分析", "3qfx", 3), - sw("rwfx-switchxd", "rwfx", "切换到行动分析", "xdfx", 4), - pz("rwfx-purpose", "rwfx", 5), + sw("rwfx-switch3q", "rwfx", "切换到三情分析", "3qfx", 4), + sw("rwfx-switchxd", "rwfx", "切换到行动分析", "xdfx", 5), + pz("rwfx-purpose", "rwfx", 6), // 3qfx(三情分析):切换到任务/行动分析 sw("3qfx-switchrw", "3qfx", "切换到任务分析", "rwfx", 0), sw("3qfx-switchxd", "3qfx", "切换到行动分析", "xdfx", 1), @@ -237,7 +260,18 @@ export async function runTaskButton(btn: TaskButton, ctx: RunTaskButtonCtx): Pro const id = btn.idKind === "action" ? ctx.actionId || ctx.taskId : ctx.taskId; const param = (btn.idParam || "id").trim() || "id"; const url = btn.appendTaskId && id ? appendQueryParam(base, param, id) : base; - // 打开方式:self=当前窗口(嵌入态即当前 iframe);否则 blank=新窗口(默认)。 + // 应用内部路由(以单个 / 开头,如「上一步」→ 大屏绘制页): + // self → 走 react-router 同窗导航(不刷新、保留 SPA);blank → 拼成当前源的 hash 路由新标签打开。 + if (base.startsWith("/") && !base.startsWith("//")) { + if (btn.openMode === "blank") { + const full = `${window.location.origin}${window.location.pathname}#${url}`; + window.open(full, "_blank", "noopener,noreferrer"); + } else { + ctx.navigate(url); + } + return; + } + // 外链:self=当前窗口(嵌入态即当前 iframe);否则 blank=新窗口(默认)。 if (btn.openMode === "self") { window.location.assign(url); } else { diff --git a/frontend-web/src/core/messages/utils.ts b/frontend-web/src/core/messages/utils.ts index 2b6ea765..909a1283 100644 --- a/frontend-web/src/core/messages/utils.ts +++ b/frontend-web/src/core/messages/utils.ts @@ -212,21 +212,33 @@ export function extractToolResultPayload( } const THINK_TAG_RE = /\s*([\s\S]*?)\s*<\/think>/g; +const THINK_OPEN = ""; function splitInlineReasoning(content: string) { const reasoningParts: string[] = []; - const cleaned = content - .replace(THINK_TAG_RE, (_, reasoning: string) => { - const normalized = reasoning.trim(); - if (normalized) { - reasoningParts.push(normalized); - } - return ""; - }) - .trim(); + // 1) 先抽出所有**已闭合**的 ...。 + let working = content.replace(THINK_TAG_RE, (_, reasoning: string) => { + const normalized = reasoning.trim(); + if (normalized) { + reasoningParts.push(normalized); + } + return ""; + }); + + // 2) 流式中常见**未闭合**的 (还没等到 就先到达):把开标签之后的内容 + // 当作「实时思考」抽到 reasoning、从答案正文里移除。否则模型慢/先思考时,用户既会看到 + // 字面 "",又会因答案区长时间空白而以为卡死。这样思考内容会实时流进思考区。 + const openIdx = working.indexOf(THINK_OPEN); + if (openIdx !== -1 && !working.slice(openIdx).includes("")) { + const partial = working.slice(openIdx + THINK_OPEN.length).trim(); + if (partial) { + reasoningParts.push(partial); + } + working = working.slice(0, openIdx); + } return { - content: cleaned, + content: working.trim(), reasoning: reasoningParts.length > 0 ? reasoningParts.join("\n\n") : null, }; } diff --git a/frontend-web/src/core/scheduled-tasks/api.ts b/frontend-web/src/core/scheduled-tasks/api.ts index 846d5ffa..6fccbbe8 100644 --- a/frontend-web/src/core/scheduled-tasks/api.ts +++ b/frontend-web/src/core/scheduled-tasks/api.ts @@ -4,6 +4,7 @@ import { OptType, writeYLog } from "@/core/log/y-log"; import type { HtmlPageFavorite, + HtmlTemplateOption, PublishedScheduledTask, ScheduledTask, ScheduledTaskDeliveryProfile, @@ -33,6 +34,14 @@ export async function getSchedulerThread() { return parseResponse(await apiFetch(`${baseUrl()}/thread`)); } +/** 可选的 HTML 模板清单(模板数据转换器填充用)。 */ +export async function listHtmlTemplates() { + const data = await parseResponse<{ templates: HtmlTemplateOption[] }>( + await apiFetch(`${baseUrl()}/html-templates`), + ); + return data.templates ?? []; +} + export interface ListScheduledTasksParams { search?: string; tagIds?: string[]; diff --git a/frontend-web/src/core/scheduled-tasks/hooks.ts b/frontend-web/src/core/scheduled-tasks/hooks.ts index 3f4f1b3a..8db8a4c2 100644 --- a/frontend-web/src/core/scheduled-tasks/hooks.ts +++ b/frontend-web/src/core/scheduled-tasks/hooks.ts @@ -16,6 +16,7 @@ import { deleteScheduledTask, deleteScheduledTaskRun, getSchedulerThread, + listHtmlTemplates, getScheduledTaskDeliveryProfile, getScheduledTaskRun, getScheduledTaskRunHtml, @@ -45,6 +46,17 @@ export function useSchedulerThread() { }); } +/** 可选的 HTML 模板清单(模板数据转换器填充用)。模板是后端静态注册,长缓存即可。 */ +export function useHtmlTemplates(enabled = true) { + return useQuery({ + queryKey: ["scheduled-tasks", "html-templates"], + queryFn: listHtmlTemplates, + enabled, + staleTime: 5 * 60_000, + refetchOnWindowFocus: false, + }); +} + /** A task is currently executing — used to drive live polling of the task list. */ function taskIsLive(status?: string | null): boolean { return status === "running" || status === "pending"; diff --git a/frontend-web/src/core/scheduled-tasks/types.ts b/frontend-web/src/core/scheduled-tasks/types.ts index 6fa9cc4d..a7eaa734 100644 --- a/frontend-web/src/core/scheduled-tasks/types.ts +++ b/frontend-web/src/core/scheduled-tasks/types.ts @@ -24,8 +24,27 @@ export type HtmlPageConfig = { model_name?: string | null; }; +/** 选中内置「模板数据转换器」agent 后,要把源 JSON 填充进的 HTML 模板。 + * 持久化在 ``execution_context.template_config`` 里(无独立列)。 */ +export type TemplateFillConfig = { + template_id?: string | null; + /** 可选:覆盖转换所用模型(留空走 agent.model / 系统默认)。 */ + model_name?: string | null; +}; + +/** ``GET /api/scheduled-tasks/html-templates`` 返回的一条模板选项。 */ +export type HtmlTemplateOption = { + id: string; + name: string; + description?: string | null; + /** false = 暂只「直接展示已有数据」,不需要也不会用源 JSON 转换填充。 */ + convertible?: boolean; +}; + export type ScheduledTaskExecutionContext = { require_markdown?: boolean; + /** 模板填充模式:选中「模板数据转换器」+ 选模板后填充进固定 HTML 模板。 */ + template_config?: TemplateFillConfig; /** * The unified "必须生成 HTML" switch. The task form no longer splits "普通任务 / * HTML 页面" into separate kinds — instead ``require_html`` and diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx index c89efb9f..fad55ff1 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx @@ -65,6 +65,7 @@ export function AIWritingForm({ audience, setAudience, wordCountInput, setWordCountInput, strictMode, setStrictMode, + addReferences, setAddReferences, materialSource, setMaterialSource, // 笔记来源(search_space_id + document_id 列表)已上提到 WritingFormContext, // 以便左栏「笔记本」点击可直接设为素材来源(需求 0616 #5)。 @@ -164,6 +165,7 @@ export function AIWritingForm({ wordCountTarget: wordCount, keywordCount: DEFAULT_KEYWORD_COUNT, writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose', + addReferences, materialSource: materialSource, modelName: selectedModel?.name || modelName || undefined, // 只有选了"我的空间"才传笔记参数 @@ -381,6 +383,23 @@ export function AIWritingForm({ +
+ setAddReferences(e.target.checked)} + disabled={disabled} + className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-primary" + /> + +
+ + {/* before:顶部 12px 命中区,命中时顶边显示蓝色线 */} +
+ {beforeDrop.isOver && } - {index + 1}. - - {editingTitle && !readOnly ? ( - setTitleDraft(e.target.value)} - onBlur={commitTitle} - onKeyDown={(e) => { - if (e.key === 'Enter') commitTitle(); - if (e.key === 'Escape') { setTitleDraft(section.sectionTitle); setEditingTitle(false); } - }} - className="flex-1 bg-transparent border-b border-primary outline-none font-medium" - /> - ) : ( - !readOnly && setEditingTitle(true)} - > - {section.sectionTitle || (未命名)} - - )} - - - {keyPoints.length} 小节{totalSubPoints > 0 ? ` · ${totalSubPoints} 子要点` : ''} - - {!readOnly && onRemove && ( - - )} -
- - {/* 小节列表 (Level 2) */} - -
- {keyPoints.map((kp, i) => ( - updateKeyPoint(i, updated)} - onRemove={readOnly ? undefined : () => removeKeyPoint(i)} - readOnly={readOnly} - /> - ))} + {/* into:包住头部 + 折叠体;本身就是 drop 容器 */} +
+ {/* 章节头 */} +
{!readOnly && ( + )} + + + {index + 1}. + + {editingTitle && !readOnly ? ( + setTitleDraft(e.target.value)} + onBlur={commitTitle} + onKeyDown={(e) => { + if (e.key === 'Enter') commitTitle(); + if (e.key === 'Escape') { setTitleDraft(section.sectionTitle); setEditingTitle(false); } + }} + className="flex-1 bg-transparent border-b border-primary outline-none font-medium" + /> + ) : ( + !readOnly && setEditingTitle(true)} + > + {section.sectionTitle || (未命名)} + + )} + + + {keyPoints.length} 小节{totalSubPoints > 0 ? ` · ${totalSubPoints} 子要点` : ''} + + {!readOnly && onRemove && ( + )}
- + + {/* 小节列表 (Level 2) */} + +
+ {keyPoints.map((kp, i) => ( + updateKeyPoint(i, updated)} + onRemove={readOnly ? undefined : () => removeKeyPoint(i)} + readOnly={readOnly} + /> + ))} + {!readOnly && ( + + )} +
+
+
+ + {/* after:底部 12px 命中区 */} +
+ {afterDrop.isOver && }
); } -// ─── 小节行(Level 2,内嵌 Level 3 子要点) ─────────────────────────────────────── +// ─── 小节 (Level 2) ─────────────────────────────────────────────────────────── interface KeyPointRowProps { keyPoint: KeyPoint; + sectionIndex: number; + keyPointIndex: number; onChange: (updated: KeyPoint) => void; onRemove?: () => void; readOnly?: boolean; } -function KeyPointRow({ keyPoint, onChange, onRemove, readOnly }: KeyPointRowProps) { - const [expanded, setExpanded] = useState(false); +function KeyPointRow({ keyPoint, sectionIndex, keyPointIndex, onChange, onRemove, readOnly }: KeyPointRowProps) { + const [expanded, setExpanded] = useState(true); const [editingTitle, setEditingTitle] = useState(false); const [titleDraft, setTitleDraft] = useState(keyPoint.pointTitle); + const id = `s${sectionIndex}-k${keyPointIndex}`; + const { attributes, listeners, setNodeRef: setDragRef, isDragging } = useDraggable({ + id, disabled: readOnly, + }); + const beforeDrop = useDroppable({ id: `before:${id}`, disabled: readOnly }); + const intoDrop = useDroppable({ id: `into:${id}`, disabled: readOnly }); + const afterDrop = useDroppable({ id: `after:${id}`, disabled: readOnly }); + function commitTitle() { setEditingTitle(false); const trimmed = titleDraft.trim(); @@ -181,120 +205,222 @@ function KeyPointRow({ keyPoint, onChange, onRemove, readOnly }: KeyPointRowProp onChange({ ...keyPoint, pointTitle: trimmed }); } } - function updateSubPoint(i: number, value: string) { const next = [...keyPoint.subPoints]; next[i] = value; onChange({ ...keyPoint, subPoints: next }); } - function removeSubPoint(i: number) { onChange({ ...keyPoint, subPoints: keyPoint.subPoints.filter((_, idx) => idx !== i) }); } - function addSubPoint() { onChange({ ...keyPoint, subPoints: [...keyPoint.subPoints, ''] }); - setExpanded(true); // 添加时自动展开,免得用户看不到刚加的输入框 + setExpanded(true); } return ( -
- {/* 小节头 */} -
- +
+
+ {beforeDrop.isOver && } - ### - - {editingTitle && !readOnly ? ( - setTitleDraft(e.target.value)} - onBlur={commitTitle} - onKeyDown={(e) => { - if (e.key === 'Enter') commitTitle(); - if (e.key === 'Escape') { setTitleDraft(keyPoint.pointTitle); setEditingTitle(false); } - }} - className="flex-1 bg-transparent border-b border-primary outline-none" - placeholder="小节标题" - /> - ) : ( - !readOnly && setEditingTitle(true)} - > - {keyPoint.pointTitle || (未命名小节)} - - )} - - - {keyPoint.subPoints.length > 0 ? `${keyPoint.subPoints.length} 子要点` : '无子要点'} - - {!readOnly && onRemove && ( - - )} -
- - {/* 子要点 (Level 3) — 每个子要点会变成正文里的 #### 标题 + 一段独立论述 */} - -
- {keyPoint.subPoints.map((sp, i) => ( -
- {/* 用 #### 标记替代 bullet,提示这是「四级标题」而非简单列表项 */} - #### - {readOnly ? ( - {sp} - ) : ( - updateSubPoint(i, e.target.value)} - placeholder="子要点标题(会展开成一段正文)" - className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground" - /> - )} - {!readOnly && ( - - )} -
- ))} +
+ {/* 小节头 */} +
{!readOnly && ( + )} + + + ### + + {editingTitle && !readOnly ? ( + setTitleDraft(e.target.value)} + onBlur={commitTitle} + onKeyDown={(e) => { + if (e.key === 'Enter') commitTitle(); + if (e.key === 'Escape') { setTitleDraft(keyPoint.pointTitle); setEditingTitle(false); } + }} + className="flex-1 bg-transparent border-b border-primary outline-none" + placeholder="小节标题" + /> + ) : ( + !readOnly && setEditingTitle(true)} + > + {keyPoint.pointTitle || (未命名小节)} + + )} + + + {keyPoint.subPoints.length > 0 ? `${keyPoint.subPoints.length} 子要点` : '无子要点'} + + {!readOnly && onRemove && ( + )}
- + + {/* 子要点 (Level 3) */} + +
+ {keyPoint.subPoints.map((sp, i) => ( + updateSubPoint(i, value)} + onRemove={readOnly ? undefined : () => removeSubPoint(i)} + readOnly={readOnly} + /> + ))} + {!readOnly && ( + + )} +
+
+
+ +
+ {afterDrop.isOver && }
); } + +// ─── 子要点 (Level 3,叶节点,无 into) ──────────────────────────────────────── +interface SubPointRowProps { + value: string; + sectionIndex: number; + keyPointIndex: number; + subPointIndex: number; + onChange: (value: string) => void; + onRemove?: () => void; + readOnly?: boolean; +} + +function SubPointRow({ + value, sectionIndex, keyPointIndex, subPointIndex, onChange, onRemove, readOnly, +}: SubPointRowProps) { + const id = `s${sectionIndex}-k${keyPointIndex}-p${subPointIndex}`; + const { attributes, listeners, setNodeRef: setDragRef, isDragging } = useDraggable({ + id, disabled: readOnly, + }); + // 叶节点:没有 into;顶半区 = before、底半区 = after,把整行高度铺满 + const beforeDrop = useDroppable({ id: `before:${id}`, disabled: readOnly }); + const afterDrop = useDroppable({ id: `after:${id}`, disabled: readOnly }); + + return ( +
+
+ {beforeDrop.isOver && } + + {!readOnly && ( + + )} + #### + {readOnly ? ( + {value} + ) : ( + onChange(e.target.value)} + placeholder="子要点标题(会展开成一段正文)" + className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground" + /> + )} + {!readOnly && onRemove && ( + + )} + +
+ {afterDrop.isOver && } +
+ ); +} + +// ─── 蓝色插入指示线 ─────────────────────────────────────────────────────────── +function DropLine({ position, thin }: { position: 'top' | 'bottom'; thin?: boolean }) { + return ( +
+ ); +} diff --git a/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx b/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx index e620b0e2..adbdc80e 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx @@ -21,6 +21,7 @@ import { type SetupWritingApprovalPayload, } from '@/components/workspace/messages'; import { ThreadContext } from '@/components/workspace/messages/context'; +import { extractContentFromMessage, extractReasoningContentFromMessage, hasToolCalls } from '@/core/messages/utils'; import { getAPIClient } from '@/core/api'; import { useModels } from '@/core/models/hooks'; import { useThreadStream } from '@/core/threads/hooks'; @@ -29,6 +30,8 @@ import { cn } from '@/lib/utils'; import { useArticleTypes } from '../../hooks/useArticleTypes'; import { useWritingFormOptional } from '../../contexts/WritingFormContext'; +import { useAIWritingOptional } from '../../contexts/AIWritingContext'; +import type { AIWritingRequest } from '../../types/ai-writing'; import { WritingWelcome } from './WritingWelcome'; /** @@ -61,6 +64,9 @@ export function WritingSetupChat({ // 缺省回填写作表单;onApprove 传入时脱离 WritingFormProvider(如模板创建页)。 const formCtx = useWritingFormOptional(); const applyProposal = onApproveProp ?? formCtx?.applyProposal; + // 「直接开始写作」:仅在真正的写作上下文(AIWritingProvider 内、未被复用为模板编辑)提供。 + const aiWriting = useAIWritingOptional(); + const canStartWriting = !onApproveProp && !!aiWriting; // 首个模型可用时默认选中 useEffect(() => { @@ -116,9 +122,68 @@ export function WritingSetupChat({ [applyProposal], ); + // 「直接开始写作」:意图确认卡里已带上**用户已确认的大纲**(setup agent 在确认大纲后才提交), + // 这里把审批建议(含该大纲)直接组装成写作请求并启动 ai_writing 流程,同时回填表单保持一致。 + const handleStart = useCallback( + async (payload: SetupWritingApprovalPayload) => { + if (!aiWriting) return; + applyProposal?.(payload); + + const wordCount = + typeof payload.word_count_target === 'number' && payload.word_count_target > 0 + ? payload.word_count_target + : Math.max(100, parseInt(formCtx?.wordCountInput ?? '', 10) || 800); + const materialSource = formCtx?.materialSource ?? 'general'; + + const request: AIWritingRequest = { + userIntent: payload.user_intent?.trim() || formCtx?.intent?.trim() || '', + // 已与用户确认的大纲:作为用户大纲传入,后端据此规划(留空则后端自动规划) + userOutline: payload.outline?.trim() || formCtx?.outlineText?.trim() || undefined, + articleType: payload.article_type?.trim() || formCtx?.articleType || 'analysis', + // 新(列表外)文章类型的一句话特征,仅本次写作临时使用(命中已有类型时为空)。 + articleTypeDetail: payload.article_type_detail?.trim() || undefined, + targetAudience: payload.target_audience?.trim() || formCtx?.audience?.trim() || '普通读者', + wordCountTarget: wordCount, + keywordCount: 1, + writingMode: payload.writing_mode === 'strict' ? 'strict' : 'loose', + // 跟随表单的「添加参考文献」开关(默认开)。 + addReferences: formCtx?.addReferences ?? true, + materialSource, + modelName: chatModel || formCtx?.modelName || undefined, + noteSearchSpaceId: + materialSource === 'notebook' ? (formCtx?.selectedNotebookId ?? undefined) : undefined, + noteSourceIds: + materialSource === 'notebook' && formCtx?.selectedNotebookId != null + ? formCtx.selectedDocumentIds + : undefined, + }; + + if (!request.userIntent) { + toast.error('写作意图为空,请先补充意图'); + return; + } + await aiWriting.startWriting(request); + }, + [aiWriting, applyProposal, formCtx, chatModel], + ); + // 对话还没有任何消息时显示欢迎/空状态屏(与原型图一致) const showWelcome = thread.messages.length === 0 && !thread.isLoading; + // 首 token 到达前的「正在整理…」占位:模型在跑(isLoading),但当前助手回合还没有任何 + // 可见输出(无答案文本、无 思考、无工具调用)—— 这段纯网络/排队延迟下答案区空白, + // 加个轻量提示避免用户以为卡死(思考一旦开始流式,此提示自动消失,改由思考区接力)。 + const showThinkingPlaceholder = useMemo(() => { + if (!thread.isLoading) return false; + const last = thread.messages[thread.messages.length - 1]; + if (!last || last.type !== 'ai') return true; // 还停在用户消息,AI 尚未开口 + const hasOutput = + !!extractContentFromMessage(last).trim() || + !!extractReasoningContentFromMessage(last) || + hasToolCalls(last); + return !hasOutput; + }, [thread.isLoading, thread.messages]); + const handleReject = useCallback(async () => { await handleChatSubmit('这份配置先不应用,请继续帮我调整。'); }, [handleChatSubmit]); @@ -147,10 +212,21 @@ export function WritingSetupChat({ onClarificationSubmit={handleChatSubmit} onSetupWritingApprove={handleApprove} onSetupWritingReject={handleReject} + onSetupWritingStart={canStartWriting ? handleStart : undefined} /> )}
+ {/* 首 token 前的「正在整理…」占位:避免模型慢/排队时答案区空白像卡死 */} + {showThinkingPlaceholder && ( +
+
+ + 正在整理… +
+
+ )} +
{/* 与原型图一致:白色圆角卡片包裹「模型选择行 + 输入框」 */}
diff --git a/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx b/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx index c2a7cab5..28bc1f87 100644 --- a/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx +++ b/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx @@ -23,7 +23,7 @@ import { import { useCanvasGraphContext } from '../contexts/CanvasGraphContext'; import { saveAIWritingTranscript } from '../api/ai-writing-sessions'; -import { countWords } from '../utils/wordCount'; +import { countArticleWords } from '../utils/wordCount'; import { INITIAL_STATE, customToProgressEvent, @@ -280,10 +280,14 @@ export function AIWritingProvider({ children }: { children: ReactNode }) { // 用户自带大纲原文(留空则后端走自动规划) user_outline_raw: request.userOutline?.trim() || '', article_type: request.articleType, + // 新(列表外)文章类型的一句话特征,仅本次写作临时使用;命中已有类型时为空。 + article_type_detail: request.articleTypeDetail?.trim() || '', target_audience: request.targetAudience, word_count_target: request.wordCountTarget, keyword_count: Math.max(1, Math.min(10, request.keywordCount)), writing_mode: request.writingMode === 'strict' ? 'strict' : 'loose', + // 是否加参考文献(正文 [n] + 文末「参考文献」列表);缺省 false 不改其它入口行为。 + add_references: request.addReferences ?? false, material_source: request.materialSource, notebook_search_space_id: request.noteSearchSpaceId ?? null, notebook_source_ids: request.noteSourceIds ?? null, @@ -328,7 +332,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) { else if (pausePoint === 'outline_confirm') record.outline = state.outline ?? undefined; else if (pausePoint === 'draft_confirm') { record.draftTitle = state.currentDraftTitle; - record.draftWordCount = countWords(state.currentDraftMarkdown); + record.draftWordCount = countArticleWords(state.currentDraftMarkdown); record.reviewResult = state.reviewResult ?? undefined; } else if (pausePoint === 'review_confirm') { record.reviewResult = state.reviewResult ?? undefined; @@ -342,7 +346,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) { pendingRevisionContext = { submittedIssues: allIssues.filter((_, i) => selectedSet.has(i)), skippedIssues: allIssues.filter((_, i) => !selectedSet.has(i)), - oldWordCount: countWords(state.currentDraftMarkdown), + oldWordCount: countArticleWords(state.currentDraftMarkdown), }; } diff --git a/frontend-web/src/open-canvas/contexts/WritingFormContext.tsx b/frontend-web/src/open-canvas/contexts/WritingFormContext.tsx index a05dc175..b6e3642e 100644 --- a/frontend-web/src/open-canvas/contexts/WritingFormContext.tsx +++ b/frontend-web/src/open-canvas/contexts/WritingFormContext.tsx @@ -34,6 +34,9 @@ export interface WritingFormValue { setAudience: (v: string) => void; strictMode: boolean; setStrictMode: (v: boolean) => void; + /** 是否在成稿里加参考文献(正文 [n] 角标 + 文末「参考文献」列表,像论文)。默认开。 */ + addReferences: boolean; + setAddReferences: (v: boolean) => void; materialSource: 'general' | 'notebook'; setMaterialSource: (v: 'general' | 'notebook') => void; /** 我的空间(笔记本)来源:选中的 search_space_id;上提到 Context 以便左栏「笔记本」点击直接设为素材来源。 */ @@ -81,6 +84,7 @@ export function WritingFormProvider({ const [wordCountInput, setWordCountInput] = useState('800'); const [audience, setAudience] = useState('普通读者'); const [strictMode, setStrictMode] = useState(false); + const [addReferences, setAddReferences] = useState(true); const [materialSource, setMaterialSource] = useState<'general' | 'notebook'>(initialMaterialSource ?? 'general'); const [selectedNotebookId, setSelectedNotebookId] = useState(initialNotebookId ?? null); const [selectedDocumentIds, setSelectedDocumentIds] = useState(initialDocumentIds ?? []); @@ -117,13 +121,14 @@ export function WritingFormProvider({ wordCountInput, setWordCountInput, audience, setAudience, strictMode, setStrictMode, + addReferences, setAddReferences, materialSource, setMaterialSource, selectedNotebookId, setSelectedNotebookId, selectedDocumentIds, setSelectedDocumentIds, modelName, setModelName, applyProposal, }), - [intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, materialSource, selectedNotebookId, selectedDocumentIds, modelName, applyProposal], + [intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, addReferences, materialSource, selectedNotebookId, selectedDocumentIds, modelName, applyProposal], ); return {children}; diff --git a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts index 04607a21..143f72fd 100644 --- a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts +++ b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts @@ -21,7 +21,7 @@ * 这样 reducer 逻辑(INITIAL_STATE + switch 各分支)与老 hook 共享同一份代码, * 只换"事件源"。阶段 7 删冗余时把老 hook 整个删掉就行。 */ -import { countWords } from '../utils/wordCount'; +import { countArticleWords } from '../utils/wordCount'; import type { AIWritingRequest, AIWritingState, @@ -339,7 +339,7 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState case 'draft_ready': { const newMarkdown = event.draftMarkdown ?? ''; - const newWordCount = countWords(newMarkdown); + const newWordCount = countArticleWords(newMarkdown); const summary = prev.pendingRevisionContext ? { revisedIssues: prev.pendingRevisionContext.submittedIssues, @@ -701,6 +701,10 @@ export type AIWritingValues = { user_intent?: string; user_outline_raw?: string; article_type?: string; + /** 新(列表外)文章类型的一句话特征,仅本次写作临时使用。 */ + article_type_detail?: string; + /** 是否加参考文献(正文 [n] 角标 + 文末「参考文献」列表)。 */ + add_references?: boolean; status?: string; material_package?: unknown; current_outline?: unknown; diff --git a/frontend-web/src/open-canvas/hooks/useWritingEditor.ts b/frontend-web/src/open-canvas/hooks/useWritingEditor.ts index f9afc30a..fa00a635 100644 --- a/frontend-web/src/open-canvas/hooks/useWritingEditor.ts +++ b/frontend-web/src/open-canvas/hooks/useWritingEditor.ts @@ -25,25 +25,33 @@ export function useWritingEditor({ const onChangeRef = useRef(onChange); useEffect(() => { onChangeRef.current = onChange; }, [onChange]); + // 编辑器自身最近一次 emit 出去的 markdown。父级把它存进 state 后会作为新的 `markdown` prop + // 回灌进来,下面的同步 effect 据此识别「这是我自己刚产生的变更」并跳过 setContent。 + const lastEmittedRef = useRef(null); + const editor = useEditor({ extensions, editable, content: markdown, onUpdate({ editor }) { - const fn = onChangeRef.current; - if (!fn) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const md = (editor as any).storage?.markdown?.getMarkdown?.() ?? editor.getText(); - fn(md); + lastEmittedRef.current = md; + onChangeRef.current?.(md); }, }, []); - // Sync external markdown changes (e.g. after streaming completes) + // Sync external markdown changes (e.g. after streaming completes / AI 改写) useEffect(() => { if (!editor) return; if (markdown === prevMarkdownRef.current) return; prevMarkdownRef.current = markdown; + // 跳过「编辑器自身刚 emit、又被父级回灌」的变更:此时编辑器内容已经是最新的,再 setContent + // 会重新解析整篇文档、重置光标。markdown 序列化对空格/换行是有损的(如行尾空格被裁剪), + // 自激回灌一旦触发 setContent,就会把刚输入的空格吞掉并让光标跳到下一个字(本次修的 bug)。 + if (markdown === lastEmittedRef.current) return; + const { from, to } = editor.state.selection; editor.commands.setContent(markdown, { emitUpdate: false }); const docSize = editor.state.doc.content.size; diff --git a/frontend-web/src/open-canvas/pages/AIWritingPage.tsx b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx index c403bb45..54e9389f 100644 --- a/frontend-web/src/open-canvas/pages/AIWritingPage.tsx +++ b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx @@ -202,7 +202,13 @@ function AIWritingLayout({ hideAdvancedFields, initialNotebookId, initialDocumen - + {/* 隐形拖拽热区只向右(草稿区)延伸、可见抓手也微微右移,二者都不再盖住左侧时间线的 + 滚动条——否则鼠标想抓滚动条会误触左右 resize、导致滑不动。右侧是草稿区留白(px-10), + 盖住无副作用。 */} + {/* 右侧:草稿预览面板 */} = 0 ? markdown.slice(0, idx) : markdown; + return countWords(body); +} diff --git a/frontend-web/src/pages/PageRoutes.tsx b/frontend-web/src/pages/PageRoutes.tsx index aceffd87..a49f984c 100644 --- a/frontend-web/src/pages/PageRoutes.tsx +++ b/frontend-web/src/pages/PageRoutes.tsx @@ -44,8 +44,15 @@ function PageLayout() { () => isEmbedActive() || readEmbedParam(location.search) === "on", [location.pathname, location.search], ); + // 「大屏绘制」独立页(/page/strategy/dashboard-agent)本就是「带导航栏 + 侧边栏」的独立页, + // 但它常被深链「上一步」从去 chrome 的工作区/新标签打开——那里 chromeByPreference 可能为 false + // (界面模式非全量、或会话级 sidebar 未置位),导致整页没有顶栏/侧栏。故对该路由**强制全局 chrome** + // (仍尊重 embed=1 嵌入态:外部 iframe 套用时仍隐藏)。 + const forceChromeForDashboardAgent = location.pathname.includes("/strategy/dashboard-agent"); const pageSidebarAllowed = - chromeByPreference && !isChatsIframeWorkspacePath(location.pathname) && !embedActive; + (forceChromeForDashboardAgent || chromeByPreference) && + !isChatsIframeWorkspacePath(location.pathname) && + !embedActive; // h-full(100%)而非 h-screen(100vh):配合 globals.css 的 html/body/#root height:100% // 高度链,根布局跟随容器高度。全屏下 100% 解析到视口,与 h-screen 等价;无界等嵌套 diff --git a/frontend-web/src/pages/ScheduledTasksPage.tsx b/frontend-web/src/pages/ScheduledTasksPage.tsx index ae93d9e7..6785843f 100644 --- a/frontend-web/src/pages/ScheduledTasksPage.tsx +++ b/frontend-web/src/pages/ScheduledTasksPage.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { type ChangeEvent, useEffect, useMemo, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { AlertCircle, @@ -21,6 +21,7 @@ import { RefreshCw, TagsIcon, Trash2, + Upload, X, } from "lucide-react"; import { useSearchParams } from "react-router-dom"; @@ -54,6 +55,7 @@ import { type ScheduledTaskExecutionContext, useHtmlPageFavoriteActions, useHtmlPageFavorites, + useHtmlTemplates, useScheduledTaskActions, useScheduledTaskDeliveryProfile, useScheduledTaskRuns, @@ -107,6 +109,9 @@ function displayUsername(value?: string | null): string { return at === -1 ? raw : raw.slice(0, at); } +/** 内置「模板数据转换器」agent 的稳定 id(后端 seed)。选中它时表单露出 HTML 模板下拉。 */ +const TEMPLATE_BUILDER_AGENT_ID = "template-json-builder"; + const emptyForm = { name: "", description: "", @@ -114,6 +119,8 @@ const emptyForm = { timezone: "Asia/Shanghai", prompt: "", execution_agent_name: "", + // 模板填充模式:选中「模板数据转换器」后要把源 JSON 填进哪个 HTML 模板。 + template_id: "", // Two independent output switches (may both be on): require a Markdown file // and/or render an HTML page. The form no longer splits into "普通任务 / HTML // 页面" tabs. @@ -172,6 +179,25 @@ export default function ScheduledTasksPage() { const [editing, setEditing] = useState(null); const [creating, setCreating] = useState(false); const [form, setForm] = useState(emptyForm); + // 模板填充模式的 .json 上传:读入文件文本填进 prompt 框(不改后端契约)。 + const jsonUploadRef = useRef(null); + const handleJsonUpload = async (event: ChangeEvent) => { + const file = event.target.files?.[0]; + event.target.value = ""; // 允许重复上传同名文件 + if (!file) return; + try { + const text = await file.text(); + // 校验是 JSON,给即时反馈;非法也照样填入让用户修正。 + try { + JSON.parse(text); + } catch { + toast.error("文件不是合法 JSON,已填入文本框请检查", darkToastOpts.error); + } + setForm((value) => ({ ...value, prompt: text })); + } catch { + toast.error("读取文件失败", darkToastOpts.error); + } + }; const dialogOpen = creating || !!editing; const { data: htmlFavorites = [] } = useHtmlPageFavorites(dialogOpen); const htmlFavoriteActions = useHtmlPageFavoriteActions(); @@ -285,6 +311,7 @@ export default function ScheduledTasksPage() { // stored that under ``html_config.page_prompt``, so prefer it on edit. prompt: html.page_prompt || task.prompt, execution_agent_name: task.execution_agent_name ?? "", + template_id: task.execution_context?.template_config?.template_id ?? "", require_markdown: !!task.execution_context?.require_markdown, // Backward compatible: legacy HTML tasks only carry ``task_kind``. require_html: @@ -311,7 +338,15 @@ export default function ScheduledTasksPage() { setScheduleConfig(DEFAULT_SCHEDULE_CONFIG); }; - const isHtmlTask = form.require_html; + // 模板填充模式:选中内置「模板数据转换器」agent。此时表单露出 HTML 模板下拉, + // 并隐藏「必须生成 HTML/Markdown」开关(本模式必产出 HTML)。 + const isTemplateAgent = form.execution_agent_name === TEMPLATE_BUILDER_AGENT_ID; + const { data: htmlTemplates = [] } = useHtmlTemplates(isTemplateAgent); + const selectedTemplate = htmlTemplates.find((t) => t.id === form.template_id); + // 选中的模板是「只展示」型(convertible===false)时,不需要源 JSON,隐藏上传/输入。 + const templateDisplayOnly = + isTemplateAgent && selectedTemplate?.convertible === false; + const isHtmlTask = !isTemplateAgent && form.require_html; // 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 || ""; @@ -322,8 +357,22 @@ export default function ScheduledTasksPage() { // as the page requirement; ``task_kind`` is kept in sync only for backward // compatibility with older readers. const buildExecutionContext = (): ScheduledTaskExecutionContext => { + // 模板填充模式:只带 template_config,后端据此走「转换 JSON → 填充模板」分支。 + if (isTemplateAgent && form.template_id) { + return { + template_config: { template_id: form.template_id }, + require_html: false, + task_kind: "agent", + html_config: undefined, + }; + } if (!isHtmlTask) { - return { require_html: false, task_kind: "agent", html_config: undefined }; + return { + require_html: false, + task_kind: "agent", + html_config: undefined, + template_config: undefined, + }; } return { require_html: true, @@ -1110,46 +1159,126 @@ export default function ScheduledTasksPage() { ))} -