diff --git a/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx b/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx
new file mode 100644
index 00000000..c64b18a8
--- /dev/null
+++ b/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx
@@ -0,0 +1,164 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { Sparkles } from 'lucide-react';
+import { toast } from 'sonner';
+
+import {
+ PromptInput,
+ PromptInputFooter,
+ PromptInputSubmit,
+ PromptInputTextarea,
+} from '@/components/ai-elements/prompt-input';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { ArtifactsProvider } from '@/components/workspace/artifacts';
+import {
+ MessageList,
+ type SetupWritingApprovalPayload,
+} from '@/components/workspace/messages';
+import { ThreadContext } from '@/components/workspace/messages/context';
+import { getAPIClient } from '@/core/api';
+import { useModels } from '@/core/models/hooks';
+import { useThreadStream } from '@/core/threads/hooks';
+import { uuid } from '@/core/utils/uuid';
+import { cn } from '@/lib/utils';
+
+import { useArticleTypes } from '../../hooks/useArticleTypes';
+import { useWritingForm } from '../../contexts/WritingFormContext';
+
+/**
+ * 写作配置 Q&A 面板(AI 写作入口页右侧,仅在未开始写作时显示)。
+ *
+ * 复用智能体创建页的「对话 → setup_writing 工具审批卡 → 回填表单」模式:
+ * - 用 ``useThreadStream`` 起一条独立的 lead_agent 线程,context 带 ``is_writing_setup``,
+ * 后端走写作配置专用 bootstrap(只挂 setup_writing 工具)。
+ * - 模型调用 setup_writing 被 SetupWritingApprovalMiddleware 拦截成审批卡。
+ * - 用户点「应用到表单」→ ``applyProposal`` 把建议字段写进左侧写作表单(WritingFormContext)。
+ *
+ * 这条线程只负责「帮用户把表单聊清」,与真正写作的 ai_writing graph 完全解耦。
+ */
+export function WritingSetupChat() {
+ const threadId = useMemo(() => uuid(), []);
+ const [threadPrepared, setThreadPrepared] = useState(false);
+ const [chatModel, setChatModel] = useState('');
+ const { models, isLoading: modelsLoading } = useModels();
+ const { articleTypes } = useArticleTypes();
+ const { applyProposal } = useWritingForm();
+
+ // 首个模型可用时默认选中
+ useEffect(() => {
+ if (models.length === 0) return;
+ setChatModel((prev) => (prev && models.some((m) => m.name === prev) ? prev : models[0]!.name));
+ }, [models]);
+
+ // 注入后端 prompt 的合法文章类型(只取 key/label)
+ const validArticleTypes = useMemo(
+ () => articleTypes.map((t) => ({ key: t.key, label: t.label })),
+ [articleTypes],
+ );
+
+ const [thread, sendMessage] = useThreadStream({
+ threadId,
+ context: {
+ mode: 'flash',
+ is_writing_setup: true,
+ valid_article_types: validArticleTypes,
+ model_name: chatModel || undefined,
+ },
+ });
+
+ const ensureThreadExists = useCallback(async () => {
+ if (threadPrepared) return;
+ await getAPIClient().threads.create({ threadId, metadata: { thread_type: 'writing_setup' } });
+ setThreadPrepared(true);
+ }, [threadId, threadPrepared]);
+
+ const handleChatSubmit = useCallback(
+ async (text: string) => {
+ const trimmed = text.trim();
+ if (!trimmed || thread.isLoading) return;
+ await ensureThreadExists();
+ await sendMessage(threadId, { text: trimmed, files: [] });
+ },
+ [ensureThreadExists, sendMessage, thread.isLoading, threadId],
+ );
+
+ const handleApprove = useCallback(
+ async (payload: SetupWritingApprovalPayload) => {
+ applyProposal(payload);
+ toast.success('已应用到左侧表单,可继续编辑或开始写作');
+ },
+ [applyProposal],
+ );
+
+ const handleReject = useCallback(async () => {
+ await handleChatSubmit('这份配置先不应用,请继续帮我调整。');
+ }, [handleChatSubmit]);
+
+ return (
+
+
+
+
+
+ 写作助手
+ 描述你想写什么,我来帮你填好左侧配置
+
+
+
+
+
+
+
+
+
当前对话模型
+ {modelsLoading || models.length === 0 ? (
+
+ {modelsLoading ? '加载中…' : '暂无模型'}
+
+ ) : (
+
+ )}
+
+
void handleChatSubmit(text)}>
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx b/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx
index 8c45edcf..c217631d 100644
--- a/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx
+++ b/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx
@@ -14,6 +14,7 @@ import { CanvasChatPanel } from "../chat/CanvasChatPanel";
import { CanvasArtifactPanel } from "../artifact/CanvasArtifactPanel";
import { AIWritingPanel } from "../ai-writing/AIWritingPanel";
import { AIWritingProvider } from "../../contexts/AIWritingContext";
+import { WritingFormProvider } from "../../contexts/WritingFormContext";
const LAYOUT_COLLAPSED = { chat: 0, artifact: 100 };
const LAYOUT_DEFAULT = { chat: 35, artifact: 65 };
@@ -68,7 +69,9 @@ export function CanvasResizableLayout() {
{leftMode === "chat" ?
: (
-
+
+
+
)}
diff --git a/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx b/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx
index 9241bd54..15666c54 100644
--- a/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx
+++ b/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx
@@ -259,6 +259,8 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
await stream.submit(
{
user_intent: request.userIntent,
+ // 用户自带大纲原文(留空则后端走自动规划)
+ user_outline_raw: request.userOutline?.trim() || '',
article_type: request.articleType,
target_audience: request.targetAudience,
word_count_target: request.wordCountTarget,
diff --git a/frontend-web/src/open-canvas/contexts/WritingFormContext.tsx b/frontend-web/src/open-canvas/contexts/WritingFormContext.tsx
new file mode 100644
index 00000000..bef1fcf3
--- /dev/null
+++ b/frontend-web/src/open-canvas/contexts/WritingFormContext.tsx
@@ -0,0 +1,102 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+ type ReactNode,
+} from 'react';
+
+import type { SetupWritingApprovalPayload } from '@/components/workspace/messages';
+
+/**
+ * 写作配置表单的共享状态。
+ *
+ * 上提到 Context 是为了让「右侧写作助手 Q&A」在用户批准模型建议后,能把建议字段
+ * 一次性回填到「左侧写作表单」的输入框(``applyProposal``)。表单与 Q&A 面板共用同一份
+ * state,因此 Provider 必须同时包住两侧面板(见 AIWritingPage)。
+ */
+export interface WritingFormValue {
+ intent: string;
+ setIntent: (v: string) => void;
+ showOutline: boolean;
+ setShowOutline: (v: boolean) => void;
+ outlineText: string;
+ setOutlineText: (v: string) => void;
+ articleType: string;
+ setArticleType: (v: string) => void;
+ wordCountInput: string;
+ setWordCountInput: (v: string) => void;
+ audience: string;
+ setAudience: (v: string) => void;
+ strictMode: boolean;
+ setStrictMode: (v: boolean) => void;
+ materialSource: 'knowledge_base' | 'skill';
+ setMaterialSource: (v: 'knowledge_base' | 'skill') => void;
+ modelName: string;
+ setModelName: (v: string) => void;
+ /** 把模型建议(setup_writing 审批 payload)回填到表单各字段。 */
+ applyProposal: (payload: SetupWritingApprovalPayload) => void;
+}
+
+const WritingFormContext = createContext
(null);
+
+export function useWritingForm(): WritingFormValue {
+ const ctx = useContext(WritingFormContext);
+ if (!ctx) {
+ throw new Error('useWritingForm must be used within WritingFormProvider');
+ }
+ return ctx;
+}
+
+export function useWritingFormOptional(): WritingFormValue | null {
+ return useContext(WritingFormContext);
+}
+
+export function WritingFormProvider({ children }: { children: ReactNode }) {
+ const [intent, setIntent] = useState('');
+ const [showOutline, setShowOutline] = useState(false);
+ const [outlineText, setOutlineText] = useState('');
+ const [articleType, setArticleType] = useState('analysis');
+ const [wordCountInput, setWordCountInput] = useState('800');
+ const [audience, setAudience] = useState('普通读者');
+ const [strictMode, setStrictMode] = useState(false);
+ const [materialSource, setMaterialSource] = useState<'knowledge_base' | 'skill'>('knowledge_base');
+ const [modelName, setModelName] = useState('');
+
+ const applyProposal = useCallback((payload: SetupWritingApprovalPayload) => {
+ if (payload.user_intent?.trim()) setIntent(payload.user_intent.trim());
+ if (payload.article_type?.trim()) setArticleType(payload.article_type.trim());
+ if (typeof payload.word_count_target === 'number' && payload.word_count_target > 0) {
+ setWordCountInput(String(payload.word_count_target));
+ }
+ if (payload.target_audience?.trim()) setAudience(payload.target_audience.trim());
+ if (payload.writing_mode === 'strict') setStrictMode(true);
+ else if (payload.writing_mode === 'loose') setStrictMode(false);
+ if (payload.material_source === 'skill' || payload.material_source === 'knowledge_base') {
+ setMaterialSource(payload.material_source);
+ }
+ if (payload.outline?.trim()) {
+ setOutlineText(payload.outline);
+ setShowOutline(true);
+ }
+ }, []);
+
+ const value = useMemo(
+ () => ({
+ intent, setIntent,
+ showOutline, setShowOutline,
+ outlineText, setOutlineText,
+ articleType, setArticleType,
+ wordCountInput, setWordCountInput,
+ audience, setAudience,
+ strictMode, setStrictMode,
+ materialSource, setMaterialSource,
+ modelName, setModelName,
+ applyProposal,
+ }),
+ [intent, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, materialSource, 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 0f855879..234d7729 100644
--- a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts
+++ b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts
@@ -623,6 +623,7 @@ export function interruptToEvent(value: unknown, sessionId: string): ProgressEve
export type AIWritingValues = {
user_intent?: string;
+ user_outline_raw?: string;
article_type?: string;
status?: string;
material_package?: unknown;
diff --git a/frontend-web/src/open-canvas/pages/AIWritingPage.tsx b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx
index 716f4610..cd96dc1a 100644
--- a/frontend-web/src/open-canvas/pages/AIWritingPage.tsx
+++ b/frontend-web/src/open-canvas/pages/AIWritingPage.tsx
@@ -90,7 +90,9 @@ import { CanvasGraphProvider } from '../contexts/CanvasGraphContext';
import { CanvasUIProvider } from '../contexts/CanvasUIContext';
import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
-import { AIWritingProvider } from '../contexts/AIWritingContext';
+import { WritingSetupChat } from '../components/ai-writing/WritingSetupChat';
+import { AIWritingProvider, useAIWriting } from '../contexts/AIWritingContext';
+import { WritingFormProvider } from '../contexts/WritingFormContext';
import {
ResizableHandle,
ResizablePanel,
@@ -104,6 +106,15 @@ const DRAFT_ID = 'aw-draft';
// Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
+/**
+ * 右侧面板:未开始写作(idle)时显示「写作助手 Q&A」,帮用户聊清并回填左侧表单;
+ * 一旦开始写作(status !== idle)就切回草稿预览。
+ */
+function AIWritingRightPanel() {
+ const { state } = useAIWriting();
+ return state.status === 'idle' ? : ;
+}
+
export default function AIWritingPage({
hideAdvancedFields = false,
}: {
@@ -116,6 +127,7 @@ export default function AIWritingPage({
+
@@ -147,12 +159,13 @@ export default function AIWritingPage({
minSize={40}
className="overflow-hidden"
>
-
+
+
diff --git a/frontend-web/src/open-canvas/types/ai-writing.ts b/frontend-web/src/open-canvas/types/ai-writing.ts
index 3d48aaec..f286962d 100644
--- a/frontend-web/src/open-canvas/types/ai-writing.ts
+++ b/frontend-web/src/open-canvas/types/ai-writing.ts
@@ -220,6 +220,8 @@ export interface UserInterventionPayload {
export interface AIWritingRequest {
userIntent: string;
+ /** 用户粘贴的「已有大纲」原文(可选)。非空时后端按此大纲生成,不再自动规划 */
+ userOutline?: string;
articleType: string;
targetAudience: string;
wordCountTarget: number;
diff --git a/frontend-web/src/pages/AdminScheduledTasksPage.tsx b/frontend-web/src/pages/AdminScheduledTasksPage.tsx
index 04366e73..289be4bb 100644
--- a/frontend-web/src/pages/AdminScheduledTasksPage.tsx
+++ b/frontend-web/src/pages/AdminScheduledTasksPage.tsx
@@ -445,10 +445,11 @@ export default function AdminScheduledTasksPage() {