- 欢迎使用「妙笔 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 (
+
+
+
+
+ {/* 左:模板助手问答(同 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="如:决策者"
+ />
+
+
+
+
+
+
+
+
+ set('strict_mode', e.target.checked)}
+ className="size-4"
+ />
+
+
+
+
+ set('sort_order', e.target.value)}
+ />
+
+
+
+
+ );
+}
diff --git a/frontend-web/src/open-canvas/pages/ResearchTemplatesConfigPage.tsx b/frontend-web/src/open-canvas/pages/ResearchTemplatesConfigPage.tsx
index 94da2258..ad9ebfa1 100644
--- a/frontend-web/src/open-canvas/pages/ResearchTemplatesConfigPage.tsx
+++ b/frontend-web/src/open-canvas/pages/ResearchTemplatesConfigPage.tsx
@@ -13,86 +13,15 @@ import {
import { useResearchTemplates } from '../hooks/useResearchTemplates';
import { useArticleTypes } from '../hooks/useArticleTypes';
-import type {
- CreateResearchTemplateRequest,
- ResearchTemplate,
-} from '../api/ai-writing-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,
- };
-}
+const NEW_PATH = '/page/canvas/research-templates/new';
+const editPath = (id: string) => `/page/canvas/research-templates/edit/${id}`;
export default function ResearchTemplatesConfigPage() {
const navigate = useNavigate();
- const { templates, loading, error, refresh, create, update, remove } = useResearchTemplates();
+ const { templates, loading, error, refresh, remove } = useResearchTemplates();
const { articleTypes, refresh: refreshTypes } = useArticleTypes();
- const [dialogOpen, setDialogOpen] = useState(false);
- const [editingId, setEditingId] = useState(null);
- const [form, setForm] = useState(EMPTY_FORM);
- const [formError, setFormError] = useState(null);
- const [saving, setSaving] = useState(false);
-
const [deleteTargetId, setDeleteTargetId] = useState(null);
const [deleting, setDeleting] = useState(false);
@@ -101,42 +30,6 @@ export default function ResearchTemplatesConfigPage() {
void refreshTypes();
}, [refresh, refreshTypes]);
- const set = (key: K, value: FormState[K]) =>
- setForm((f) => ({ ...f, [key]: value }));
-
- const openCreate = () => {
- setEditingId(null);
- setForm(EMPTY_FORM);
- setFormError(null);
- setDialogOpen(true);
- };
-
- const openEdit = (t: ResearchTemplate) => {
- setEditingId(t.id);
- setForm(toForm(t));
- setFormError(null);
- setDialogOpen(true);
- };
-
- const handleSave = async () => {
- if (!form.name.trim()) {
- setFormError('请填写模板名称');
- return;
- }
- setSaving(true);
- setFormError(null);
- try {
- const payload = toPayload(form);
- if (editingId) await update(editingId, payload);
- else await create(payload);
- setDialogOpen(false);
- } catch (e) {
- setFormError(e instanceof Error ? e.message : '保存失败');
- } finally {
- setSaving(false);
- }
- };
-
const handleDelete = async () => {
if (!deleteTargetId) return;
setDeleting(true);
@@ -165,7 +58,7 @@ export default function ResearchTemplatesConfigPage() {