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 b1207024..e620b0e2 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx @@ -28,7 +28,7 @@ import { uuid } from '@/core/utils/uuid'; import { cn } from '@/lib/utils'; import { useArticleTypes } from '../../hooks/useArticleTypes'; -import { useWritingForm } from '../../contexts/WritingFormContext'; +import { useWritingFormOptional } from '../../contexts/WritingFormContext'; import { WritingWelcome } from './WritingWelcome'; /** @@ -42,13 +42,25 @@ import { WritingWelcome } from './WritingWelcome'; * * 这条线程只负责「帮用户把表单聊清」,与真正写作的 ai_writing graph 完全解耦。 */ -export function WritingSetupChat() { +export function WritingSetupChat({ + onApprove: onApproveProp, + title = '写作助手', + hint = '描述你想写什么,我来帮你填好右侧配置', +}: { + /** 审批卡「应用」回调;缺省回填写作表单(需 WritingFormProvider)。传入则脱离表单上下文, + * 用于复用到「新建研究模板」等场景,把建议字段写进调用方的表单。 */ + onApprove?: (payload: SetupWritingApprovalPayload) => void; + title?: string; + hint?: string; +} = {}) { const threadId = useMemo(() => uuid(), []); const [threadPrepared, setThreadPrepared] = useState(false); const [chatModel, setChatModel] = useState(''); const { models, isLoading: modelsLoading } = useModels(); const { articleTypes } = useArticleTypes(); - const { applyProposal } = useWritingForm(); + // 缺省回填写作表单;onApprove 传入时脱离 WritingFormProvider(如模板创建页)。 + const formCtx = useWritingFormOptional(); + const applyProposal = onApproveProp ?? formCtx?.applyProposal; // 首个模型可用时默认选中 useEffect(() => { @@ -98,8 +110,8 @@ export function WritingSetupChat() { const handleApprove = useCallback( async (payload: SetupWritingApprovalPayload) => { - applyProposal(payload); - toast.success('已应用到左侧表单,可继续编辑或开始写作'); + applyProposal?.(payload); + toast.success('已应用到表单,可继续编辑'); }, [applyProposal], ); @@ -119,8 +131,8 @@ export function WritingSetupChat() { {!showWelcome && (
-

写作助手

- 描述你想写什么,我来帮你填好右侧配置 +

{title}

+ {hint}
)} diff --git a/frontend-web/src/open-canvas/components/ai-writing/WritingWelcome.tsx b/frontend-web/src/open-canvas/components/ai-writing/WritingWelcome.tsx index 30110ee4..6c0cd0e5 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/WritingWelcome.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/WritingWelcome.tsx @@ -39,7 +39,7 @@ export function WritingWelcome() {

- 欢迎使用「妙笔 AI」智力打字协作工坊 + 欢迎使用「ai写作」智力打字协作工坊

这是一个专业的自动化写作系统。你只需在下方对话框中 diff --git a/frontend-web/src/open-canvas/pages/ResearchTemplateEditorPage.tsx b/frontend-web/src/open-canvas/pages/ResearchTemplateEditorPage.tsx new file mode 100644 index 00000000..adcdf976 --- /dev/null +++ b/frontend-web/src/open-canvas/pages/ResearchTemplateEditorPage.tsx @@ -0,0 +1,300 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { ArrowLeft, Loader2 } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import type { SetupWritingApprovalPayload } from '@/components/workspace/messages'; + +import { useResearchTemplates } from '../hooks/useResearchTemplates'; +import { useArticleTypes } from '../hooks/useArticleTypes'; +import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat'; +import type { + CreateResearchTemplateRequest, + ResearchTemplate, +} from '../api/ai-writing-research-templates'; + +const LIST_PATH = '/page/canvas/research-templates'; + +const INPUT_CLASS = + 'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm ' + + 'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50'; +const TEXTAREA_CLASS = + 'flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm ' + + 'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring resize-none'; +const LABEL_CLASS = 'mb-1 block text-xs font-medium text-muted-foreground'; + +interface FormState { + name: string; + description: string; + article_type_key: string; + outline: string; + material_source: 'general' | 'notebook'; + word_count: string; + audience: string; + strict_mode: boolean; + topic: string; + sort_order: string; +} + +const EMPTY_FORM: FormState = { + name: '', + description: '', + article_type_key: '', + outline: '', + material_source: 'general', + word_count: '800', + audience: '', + strict_mode: false, + topic: '', + sort_order: '0', +}; + +function toForm(t: ResearchTemplate): FormState { + return { + name: t.name, + description: t.description ?? '', + article_type_key: t.article_type_key ?? '', + outline: t.outline ?? '', + material_source: t.material_source, + word_count: String(t.word_count), + audience: t.audience ?? '', + strict_mode: t.strict_mode, + topic: t.topic ?? '', + sort_order: String(t.sort_order), + }; +} + +function toPayload(form: FormState): CreateResearchTemplateRequest { + return { + name: form.name.trim(), + description: form.description.trim() || null, + article_type_key: form.article_type_key || null, + outline: form.outline.trim() || null, + material_source: form.material_source, + word_count: Math.max(100, parseInt(form.word_count, 10) || 800), + audience: form.audience.trim() || null, + strict_mode: form.strict_mode, + topic: form.topic.trim() || null, + sort_order: parseInt(form.sort_order, 10) || 0, + }; +} + +export default function ResearchTemplateEditorPage() { + const navigate = useNavigate(); + const { id } = useParams<{ id: string }>(); + const editing = !!id; + + const { templates, refresh, create, update } = useResearchTemplates(); + const { articleTypes, refresh: refreshTypes } = useArticleTypes(); + + const [form, setForm] = useState(EMPTY_FORM); + // 新建即就绪;编辑需等列表加载后回填一次。 + const [initialized, setInitialized] = useState(!editing); + const [formError, setFormError] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + void refresh(); + void refreshTypes(); + }, [refresh, refreshTypes]); + + useEffect(() => { + if (!editing || initialized) return; + const t = templates.find((x) => x.id === id); + if (t) { + setForm(toForm(t)); + setInitialized(true); + } + }, [editing, initialized, templates, id]); + + const set = (key: K, value: FormState[K]) => + setForm((f) => ({ ...f, [key]: value })); + + // 问答助手「应用」→ 把 setup_writing 建议映射进模板表单字段(与 AI 写作同款机制)。 + const applyProposalToForm = (p: SetupWritingApprovalPayload) => + setForm((f) => ({ + ...f, + name: f.name || (p.user_intent?.trim() ? p.user_intent.trim().slice(0, 20) : f.name), + topic: p.user_intent?.trim() || f.topic, + article_type_key: p.article_type?.trim() || f.article_type_key, + outline: p.outline?.trim() || f.outline, + word_count: + typeof p.word_count_target === 'number' && p.word_count_target > 0 + ? String(p.word_count_target) + : f.word_count, + audience: p.target_audience?.trim() || f.audience, + strict_mode: + p.writing_mode === 'strict' ? true : p.writing_mode === 'loose' ? false : f.strict_mode, + material_source: p.material_source + ? p.material_source === 'notebook' + ? 'notebook' + : 'general' + : f.material_source, + })); + + const handleSave = async () => { + if (!form.name.trim()) { + setFormError('请填写模板名称'); + return; + } + setSaving(true); + setFormError(null); + try { + const payload = toPayload(form); + if (editing && id) await update(id, payload); + else await create(payload); + navigate(LIST_PATH); + } catch (e) { + setFormError(e instanceof Error ? e.message : '保存失败'); + setSaving(false); + } + }; + + return ( +

+
+
+ +
+

+ {editing ? '编辑研究模板' : '新建研究模板'} +

+

+ 左侧用问答描述需求,自动填好右侧字段;也可手动编辑。 +

+
+
+
+ {formError && {formError}} + + +
+
+ +
+ {/* 左:模板助手问答(同 AI 写作 setup 机制)→ 应用到右侧表单。问答区更宽(约 7:3)。 */} +
+ +
+ + {/* 右:模板表单 */} +
+
+ + set('name', e.target.value)} + placeholder="如:深度舆情分析" + /> +
+
+ + set('description', e.target.value)} + placeholder="模板用途简述" + /> +
+
+ + +
+
+ + +
+
+ + set('word_count', e.target.value)} + /> +
+
+ + set('audience', e.target.value)} + placeholder="如:决策者" + /> +
+
+ +