diff --git a/CLAUDE.md b/CLAUDE.md index 454ed8b5..25688bec 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/components/workspace/appearance-controls.tsx b/frontend-web/src/components/workspace/appearance-controls.tsx index 02efa7c8..bc11af42 100644 --- a/frontend-web/src/components/workspace/appearance-controls.tsx +++ b/frontend-web/src/components/workspace/appearance-controls.tsx @@ -2,6 +2,7 @@ import { Check, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { Button } from "@/components/ui/button"; import { @@ -140,6 +141,7 @@ export function LayoutModeSwitcher({ fallback?: "full" | "compact"; }) { const [mode, setLayoutMode] = useLayoutMode(); + const navigate = useNavigate(); const current = mode ?? fallback; const TriggerIcon = current === "full" ? PanelLeftIcon : LayoutIcon; @@ -166,7 +168,10 @@ export function LayoutModeSwitcher({ return ( setLayoutMode(option.id)} + onClick={() => { + setLayoutMode(option.id); + navigate("/page/workspace/chats/new"); + }} className="gap-2" > diff --git a/frontend-web/src/core/scheduled-tasks/api.ts b/frontend-web/src/core/scheduled-tasks/api.ts index 5f1c5d9b..12096664 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/core/scheduled-tasks/types.ts b/frontend-web/src/core/scheduled-tasks/types.ts index a7eaa734..63cfac2e 100644 --- a/frontend-web/src/core/scheduled-tasks/types.ts +++ b/frontend-web/src/core/scheduled-tasks/types.ts @@ -22,6 +22,8 @@ export type HtmlPageConfig = { negative_prompt?: string | null; reference_favorite_id?: string | null; model_name?: string | null; + /** 仅当参考选了内置模板(tpl:)时生效:关闭则直接用模板默认数据,开启才检索抽取。 */ + template_extract?: boolean; }; /** 选中内置「模板数据转换器」agent 后,要把源 JSON 填充进的 HTML 模板。 diff --git a/frontend-web/src/open-canvas/api/ai-writing-sessions.ts b/frontend-web/src/open-canvas/api/ai-writing-sessions.ts index 36b2abad..a3a9b1f4 100644 --- a/frontend-web/src/open-canvas/api/ai-writing-sessions.ts +++ b/frontend-web/src/open-canvas/api/ai-writing-sessions.ts @@ -82,6 +82,27 @@ export async function renameAIWritingSession(id: string, title: string): Promise return resp.json(); } +/** + * 更新历史会话的成稿正文(历史回看时用户在右侧编辑器里直接修改并保存)。 + * draftTitle 省略时后端不动原标题。 + */ +export async function updateAIWritingDraft( + id: string, + draftMarkdown: string, + draftTitle?: string, +): Promise { + const resp = await apiFetch(`${BASE()}/${id}/draft`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + draft_markdown: draftMarkdown, + ...(draftTitle === undefined ? {} : { draft_title: draftTitle }), + }), + }); + if (!resp.ok) throw new Error(`保存修改失败: ${resp.status}`); + return resp.json(); +} + export async function deleteAIWritingSession(id: string): Promise { const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' }); if (!resp.ok) throw new Error(`删除失败: ${resp.status}`); diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx index 55dbbbe8..343020a4 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx @@ -41,10 +41,23 @@ export function AIWritingDraftPanel() { const showCompletedDraftView = isDraftEditable || isHistoryMode; const writingStatus = writing?.state.status; + const streamingSectionCount = writing?.state.streamingDraftSections.length ?? 0; + + // 按用户意见修改中、且重写流还没开始(无任何流式章节):右侧先恢复初始空占位, + // 把旧稿清掉;一旦重写流式 chunk 到达(streamingDraftSections 非空)就走下面的 + // 流式预览,实时展示重写过程;draft_ready 后 isRevising 置回 false 显示新成稿。 + if (writing?.state.isRevising && streamingSectionCount === 0) { + return ( +
+ +
+ ); + } + // 仅当章节仍在累积时走流式 Markdown;draft_ready 清空 sections 后即使 status 尚未切走也进画布 const isActivelyStreamingDraft = (writingStatus === 'writing_draft' || writingStatus === 'revising') && - (writing?.state.streamingDraftSections.length ?? 0) > 0; + streamingSectionCount > 0; const streamingMarkdown = isActivelyStreamingDraft && writing ? assembleStreamingDraftMarkdown( @@ -102,7 +115,8 @@ export function AIWritingDraftPanel() { 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 fad55ff1..cbf26130 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx @@ -3,6 +3,7 @@ import { PenLine, SlidersHorizontal } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; +import { cn } from '@/lib/utils'; import { Select, SelectContent, @@ -67,6 +68,7 @@ export function AIWritingForm({ strictMode, setStrictMode, addReferences, setAddReferences, materialSource, setMaterialSource, + skipResearch, setSkipResearch, // 笔记来源(search_space_id + document_id 列表)已上提到 WritingFormContext, // 以便左栏「笔记本」点击可直接设为素材来源(需求 0616 #5)。 selectedNotebookId, setSelectedNotebookId, @@ -164,13 +166,16 @@ export function AIWritingForm({ : audience.trim() || DEFAULT_AUDIENCE, wordCountTarget: wordCount, keywordCount: DEFAULT_KEYWORD_COUNT, - writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose', + // 跳过素材收集时强制 loose(无素材可严格依据,后端也会强制) + writingMode: skipResearch ? 'loose' : hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose', addReferences, - materialSource: materialSource, + skipResearch, + // 跳过素材收集时检索类型无意义,归一为 general + materialSource: skipResearch ? 'general' : materialSource, modelName: selectedModel?.name || modelName || undefined, - // 只有选了"我的空间"才传笔记参数 - noteSearchSpaceId: materialSource === 'notebook' ? (selectedNotebookId ?? undefined) : undefined, - noteSourceIds: materialSource === 'notebook' && selectedNotebookId != null ? selectedDocumentIds : undefined, + // 只有选了"我的空间"且未跳过素材收集才传笔记参数 + noteSearchSpaceId: !skipResearch && materialSource === 'notebook' ? (selectedNotebookId ?? undefined) : undefined, + noteSourceIds: !skipResearch && materialSource === 'notebook' && selectedNotebookId != null ? selectedDocumentIds : undefined, }); } @@ -278,6 +283,25 @@ export function AIWritingForm({ + {/* 不收集素材,直接用通用知识写作 */} +
+ setSkipResearch(e.target.checked)} + disabled={disabled} + className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-primary" + /> + +
+ + {!skipResearch && (
setStrictMode(e.target.checked)} - disabled={disabled} - className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-primary" + disabled={disabled || skipResearch} + className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-primary disabled:cursor-not-allowed" /> -
diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryView.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryView.tsx index de75eee7..207b587e 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryView.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingHistoryView.tsx @@ -26,6 +26,7 @@ function normalizeCompletedIntervention(rec: Record): Completed if (rr) out.reviewResult = normalizeReviewResult(rr); if (out.draftTitle == null && rec.draft_title != null) out.draftTitle = rec.draft_title; if (out.draftWordCount == null && rec.draft_word_count != null) out.draftWordCount = rec.draft_word_count; + if (out.userInput == null && rec.user_input != null) out.userInput = rec.user_input; return out as unknown as CompletedIntervention; } diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx index 683b79f5..8dc166b8 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingPanel.tsx @@ -496,9 +496,13 @@ export function AIWritingPanel({ /> )} - {/* 大纲流式思考框 */} + {/* 大纲流式思考框(用户自带大纲时文案显示「整理」) */} {state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText) && ( - + )} {/* 编辑审核流式结果 */} @@ -560,7 +564,11 @@ export function AIWritingPanel({ /* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */ !state.intentStreamingText && !state.intentThinkingText && (
- +
)} @@ -605,7 +613,9 @@ export function AIWritingPanel({ )} {state.status === 'researching' && state.researchProgress ? state.researchProgress - : STATUS_LABEL[state.status] ?? state.status} + : state.status === 'writing_outline' && state.request?.userOutline + ? '作家整理你的大纲中…' + : STATUS_LABEL[state.status] ?? state.status} )} diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingTimeline.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingTimeline.tsx index c86a652d..81edb0fb 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingTimeline.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingTimeline.tsx @@ -1,6 +1,7 @@ import type { CompletedIntervention, ProgressEvent } from '../../types/ai-writing'; import { TimelineStep } from './TimelineStep'; import { CompletedInterventionCard } from './CompletedInterventionCard'; +import { UserRequirementCard } from './UserRequirementCard'; interface AIWritingTimelineProps { events: ProgressEvent[]; @@ -48,8 +49,13 @@ export function AIWritingTimeline({ awaitUserIdx += 1; if (completedRecord) { - // 已完成的干预点 → 渲染折叠历史卡 - slot = ; + // 已完成的干预点 → 先以独立样式回显用户的输入要求,再渲染折叠历史卡 + slot = ( +
+ + +
+ ); } else if (currentPause && isLast) { // 当前活跃干预点 → 渲染操作卡 slot = currentInterventionSlot; diff --git a/frontend-web/src/open-canvas/components/ai-writing/InterventionCard.tsx b/frontend-web/src/open-canvas/components/ai-writing/InterventionCard.tsx index b75f199f..696cdf5a 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/InterventionCard.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/InterventionCard.tsx @@ -455,13 +455,6 @@ function DraftConfirmCard({ draftTitle, draftWordCount, wordCountTarget, revisio )}
- - {/* 按编辑审核意见修改(有审核结果时始终显示) */} {hasReview && (
+ + ); diff --git a/frontend-web/src/open-canvas/components/ai-writing/OutlineStreamingView.tsx b/frontend-web/src/open-canvas/components/ai-writing/OutlineStreamingView.tsx index 4ac3de84..c7d10526 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/OutlineStreamingView.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/OutlineStreamingView.tsx @@ -4,9 +4,11 @@ import { FileText, ChevronDown, ChevronRight, Brain } from 'lucide-react'; interface OutlineStreamingViewProps { text: string; thinkingText?: string; + /** 用户自带大纲时为 true:文案显示「整理你的大纲」而非「规划大纲」。 */ + organizing?: boolean; } -export function OutlineStreamingView({ text, thinkingText }: OutlineStreamingViewProps) { +export function OutlineStreamingView({ text, thinkingText, organizing }: OutlineStreamingViewProps) { const preRef = useRef(null); const thinkRef = useRef(null); const [thinkOpen, setThinkOpen] = useState(true); @@ -66,7 +68,7 @@ export function OutlineStreamingView({ text, thinkingText }: OutlineStreamingVie
- 作家正在规划大纲… + {organizing ? '作家正在整理你的大纲…' : '作家正在规划大纲…'} {[0, 1, 2].map((i) => ( diff --git a/frontend-web/src/open-canvas/components/ai-writing/UserRequirementCard.tsx b/frontend-web/src/open-canvas/components/ai-writing/UserRequirementCard.tsx new file mode 100644 index 00000000..3ed3b5b9 --- /dev/null +++ b/frontend-web/src/open-canvas/components/ai-writing/UserRequirementCard.tsx @@ -0,0 +1,50 @@ +import { UserRound } from 'lucide-react'; +import type { CompletedIntervention } from '../../types/ai-writing'; + +/** + * 时间线里「用户输入交互的要求」独立样式卡 —— 与 AI 步骤(灰)、完成卡(绿)、 + * 等待卡(黄)在视觉上明确区分:靛蓝主色 + 用户图标 + 顶部要求类型标签。 + * + * 触发场景(record.userInput 非空时): + * 素材确认 → 按用户要求补充检索内容 + * 大纲确认 → 用户提出大纲修改意见 + * 草稿确认 → 用户提出写作修改意见 + * 编辑意见处理 → 用户在编辑审核时提出处理意见 + */ + +function requirementLabel(record: CompletedIntervention): string { + switch (record.pausePoint) { + case 'material_confirm': + return '补充检索内容'; + case 'outline_confirm': + return '修改大纲意见'; + case 'draft_confirm': + return '修改写作意见'; + case 'review_confirm': + return '编辑审核意见'; + default: + return '我的要求'; + } +} + +interface Props { + record: CompletedIntervention; +} + +export function UserRequirementCard({ record }: Props) { + if (!record.userInput) return null; + + return ( +
+
+ + + 我的要求 · {requirementLabel(record)} + +
+

+ {record.userInput} +

+
+ ); +} 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 adbdc80e..746b03af 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx @@ -15,6 +15,8 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { Zap } from 'lucide-react'; import { ArtifactsProvider } from '@/components/workspace/artifacts'; import { MessageList, @@ -59,6 +61,8 @@ export function WritingSetupChat({ const threadId = useMemo(() => uuid(), []); const [threadPrepared, setThreadPrepared] = useState(false); const [chatModel, setChatModel] = useState(''); + // 快捷模式:开启后跳过逐项选项澄清(文章类型/字数/读者用默认),给标题直接生成大纲。 + const [quickMode, setQuickMode] = useState(false); const { models, isLoading: modelsLoading } = useModels(); const { articleTypes } = useArticleTypes(); // 缺省回填写作表单;onApprove 传入时脱离 WritingFormProvider(如模板创建页)。 @@ -85,6 +89,7 @@ export function WritingSetupChat({ context: { mode: 'flash', is_writing_setup: true, + writing_setup_quick: quickMode, valid_article_types: validArticleTypes, model_name: chatModel || undefined, }, @@ -133,7 +138,8 @@ export function WritingSetupChat({ 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 skipResearch = formCtx?.skipResearch ?? false; + const materialSource = skipResearch ? 'general' : (formCtx?.materialSource ?? 'general'); const request: AIWritingRequest = { userIntent: payload.user_intent?.trim() || formCtx?.intent?.trim() || '', @@ -145,9 +151,11 @@ export function WritingSetupChat({ targetAudience: payload.target_audience?.trim() || formCtx?.audience?.trim() || '普通读者', wordCountTarget: wordCount, keywordCount: 1, - writingMode: payload.writing_mode === 'strict' ? 'strict' : 'loose', + // 跳过素材收集时强制 loose + writingMode: skipResearch ? 'loose' : payload.writing_mode === 'strict' ? 'strict' : 'loose', // 跟随表单的「添加参考文献」开关(默认开)。 addReferences: formCtx?.addReferences ?? true, + skipResearch, materialSource, modelName: chatModel || formCtx?.modelName || undefined, noteSearchSpaceId: @@ -258,9 +266,25 @@ export function WritingSetupChat({ )}
- - 输入想法,按 Enter 直接解析填充 - + {/* 快捷模式开关:放在模型选择右侧。开启后跳过逐项选项确认,给标题直接出大纲。 */} + void handleChatSubmit(text)}>
- 作家规划大纲中… + {organizing ? '作家整理你的大纲中…' : '作家规划大纲中…'}
{/* 文章标题占位 */} @@ -213,7 +215,7 @@ const SKELETON_MAP: Partial> = { awaiting_review_confirm: RevisingSkeleton, // 采纳编辑意见后 }; -export function WritingSkeletonCard({ status, researchProgress }: WritingSkeletonCardProps) { +export function WritingSkeletonCard({ status, researchProgress, organizing }: WritingSkeletonCardProps) { // 素材收集阶段单独渲染,以便透传实时进度文案 if (status === 'researching' || status === 'awaiting_material_confirm') { return ( @@ -222,6 +224,14 @@ export function WritingSkeletonCard({ status, researchProgress }: WritingSkeleto ); } + // 大纲阶段单独渲染,以便透传「整理 / 规划」文案(用户自带大纲走「整理」) + if (status === 'writing_outline' || status === 'awaiting_outline_confirm') { + return ( +
+ +
+ ); + } const Skeleton = SKELETON_MAP[status]; if (!Skeleton) return null; return ( diff --git a/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx b/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx index 68c245f1..d7fc8365 100644 --- a/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx +++ b/frontend-web/src/open-canvas/components/artifact/TextArtifactRenderer.tsx @@ -24,6 +24,8 @@ interface TextArtifactRendererProps { isStreaming?: boolean; /** 完成态只读(如历史回看):保留目录 + 排版,禁用编辑与 AI 润色 */ readOnly?: boolean; + /** 历史回看可编辑场景:提供后工具栏出现「保存」按钮,把修改持久化回会话 */ + onSaveDraft?: (markdown: string, title?: string) => Promise; } interface PanelPos { @@ -40,7 +42,7 @@ interface LastAction { customInstruction?: string; } -export function TextArtifactRenderer({ content, isStreaming, readOnly = false }: TextArtifactRendererProps) { +export function TextArtifactRenderer({ content, isStreaming, readOnly = false, onSaveDraft }: TextArtifactRendererProps) { const { artifact, setArtifact } = useCanvasGraphContext(); const { writingSelection } = useCanvasUIContext(); @@ -280,6 +282,7 @@ export function TextArtifactRenderer({ content, isStreaming, readOnly = false }: markdown={content.fullMarkdown} title={content.title} readOnly={readOnly} + onSave={onSaveDraft} />
diff --git a/frontend-web/src/open-canvas/components/artifact/WritingToolbar.tsx b/frontend-web/src/open-canvas/components/artifact/WritingToolbar.tsx index 0d1dbe7a..d4a04f35 100644 --- a/frontend-web/src/open-canvas/components/artifact/WritingToolbar.tsx +++ b/frontend-web/src/open-canvas/components/artifact/WritingToolbar.tsx @@ -14,6 +14,7 @@ import { Pilcrow, Download, Loader2, + Save, } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; @@ -71,14 +72,32 @@ interface WritingToolbarProps { title?: string; /** 历史回看等场景:仅保留导出,隐藏格式工具 */ readOnly?: boolean; + /** 提供后显示「保存」按钮,把当前正文持久化(历史回看可编辑场景) */ + onSave?: (markdown: string, title?: string) => Promise; } -export function WritingToolbar({ editor, markdown, title, readOnly = false }: WritingToolbarProps) { +export function WritingToolbar({ editor, markdown, title, readOnly = false, onSave }: WritingToolbarProps) { const [exporting, setExporting] = useState(false); + const [saving, setSaving] = useState(false); if (!editor) return null; const canExport = !exporting && !!markdown?.trim(); + const canSave = !!onSave && !saving && !!markdown?.trim(); + + const handleSave = async () => { + if (!onSave || saving || !markdown?.trim()) return; + setSaving(true); + try { + await onSave(markdown, title); + toast.success("已保存修改"); + } catch (err) { + console.error("[writing-save] failed", err); + toast.error("保存失败,请稍后重试"); + } finally { + setSaving(false); + } + }; const handleExport = async () => { if (!canExport) return; @@ -240,8 +259,40 @@ export function WritingToolbar({ editor, markdown, title, readOnly = false }: Wr - {/* Right side: export + model selector */} + {/* Right side: save + export + model selector */}
+ {onSave && ( + <> + + + + + + 保存修改到历史记录 + + + + + )}
@@ -168,7 +168,7 @@ export default function ResearchTemplateEditorPage() {
{formError && {formError}} -
diff --git a/frontend-web/src/open-canvas/types/ai-writing.ts b/frontend-web/src/open-canvas/types/ai-writing.ts index 407d6535..82c245a8 100644 --- a/frontend-web/src/open-canvas/types/ai-writing.ts +++ b/frontend-web/src/open-canvas/types/ai-writing.ts @@ -267,6 +267,9 @@ export interface AIWritingRequest { addReferences?: boolean; /** 检索类型:general=通用检索(素材收集专家按配置技能检索,默认知识库检索技能)/ notebook=我的空间 */ materialSource: "general" | "notebook"; + /** 不收集素材,直接用通用知识写作:勾选后跳过整个素材检索流程(含素材确认暂停点), + * 直接进入大纲规划;后端强制 loose 写作模式。默认 false。 */ + skipResearch?: boolean; /** 笔记来源(可选):空间 search_space_id */ noteSearchSpaceId?: number; /** 笔记来源(可选):空间内指定文档的 document_id 列表,空数组表示检索整个空间 */ @@ -316,6 +319,10 @@ export interface CompletedIntervention { | "review_confirm" | "section_help"; action: string; + /** 用户在该干预点输入的自然语言要求(补充检索内容 / 大纲修改意见 / 写作修改意见 / + * 编辑审核处理意见)。仅当用户实际输入了文字时存在,用于在时间线里以「我的要求」 + * 独立样式回显——区别于 AI 步骤与绿色完成卡。 */ + userInput?: string; // material_confirm materials?: AIMaterial[]; // outline_confirm @@ -382,4 +389,7 @@ export interface AIWritingState { skippedIssues: ReviewIssue[]; oldWordCount: number; } | null; + /** 是否正按用户意见修改中(revising 提交起 → draft_ready 止)。 + * 为 true 时右侧画布恢复初始空占位,不展示旧稿 / 流式重写过程。 */ + isRevising: boolean; } diff --git a/frontend-web/src/pages/AgentChatPage.tsx b/frontend-web/src/pages/AgentChatPage.tsx index ed950343..b5319a92 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 ? ( +