From ad4b27b7caa0754b490554fff1fe212c4106b3b7 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Wed, 3 Jun 2026 16:20:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=97=A0=E7=95=8C=20ticket=20=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E3=80=81AI=20=E5=86=99=E4=BD=9C=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E4=B8=8E=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 无界 onlineTicket 对接 MaxKey 与 consumer 接口并用 sub 登录;新增 setup_writing 写作配置确认流;定时任务创建弹窗限制多行高度并支持滚动。 --- frontend-web/.env.development | 2 + frontend-web/.env.development.local.example | 1 + frontend-web/.env.example | 4 + frontend-web/.env.network | 2 + frontend-web/.env.production | 2 + frontend-web/.env.sit | 2 + .../workspace/messages/message-list.tsx | 181 ++++++++++++++++++ frontend-web/src/core/auth/external-login.ts | 4 + .../src/core/auth/wujie-ticket-login.ts | 142 ++++++++++++++ frontend-web/src/core/embed/wujie.ts | 4 +- frontend-web/src/core/messages/utils.ts | 22 ++- .../components/ai-writing/AIWritingForm.tsx | 27 +-- .../ai-writing/WritingSetupChat.tsx | 164 ++++++++++++++++ .../layout/CanvasResizableLayout.tsx | 5 +- .../contexts/WritingFormContext.tsx | 102 ++++++++++ .../src/open-canvas/pages/AIWritingPage.tsx | 17 +- .../src/pages/AdminScheduledTasksPage.tsx | 11 +- frontend-web/src/pages/LoginPage.tsx | 26 ++- frontend-web/src/pages/ScheduledTasksPage.tsx | 27 ++- .../backend/app/gateway/services.py | 2 + .../deerflow/agents/lead_agent/agent.py | 27 ++- .../deerflow/agents/lead_agent/prompt.py | 76 ++++++-- .../setup_writing_approval_middleware.py | 129 +++++++++++++ .../deerflow/tools/builtins/__init__.py | 2 + .../tools/builtins/setup_writing_tool.py | 53 +++++ 25 files changed, 980 insertions(+), 54 deletions(-) create mode 100644 frontend-web/src/core/auth/wujie-ticket-login.ts create mode 100644 frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx create mode 100644 frontend-web/src/open-canvas/contexts/WritingFormContext.tsx create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/setup_writing_approval_middleware.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/setup_writing_tool.py diff --git a/frontend-web/.env.development b/frontend-web/.env.development index 6228c720..15aac5a9 100644 --- a/frontend-web/.env.development +++ b/frontend-web/.env.development @@ -26,6 +26,8 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer +# MaxKey 鉴权(getTokenByOnlineTicket) +VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn # 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL) diff --git a/frontend-web/.env.development.local.example b/frontend-web/.env.development.local.example index f01f2187..a5092803 100644 --- a/frontend-web/.env.development.local.example +++ b/frontend-web/.env.development.local.example @@ -17,6 +17,7 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer +VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn diff --git a/frontend-web/.env.example b/frontend-web/.env.example index 80a2b0f7..0c211a17 100644 --- a/frontend-web/.env.example +++ b/frontend-web/.env.example @@ -25,6 +25,10 @@ # --- 生产打包(见 .env.production)--- # 部署前修改 .env.production 中的地址,再执行 pnpm build +# MaxKey 鉴权根地址(无界 onlineTicket 换 token) +# VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn +# VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer + # VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn # VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1 # VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl diff --git a/frontend-web/.env.network b/frontend-web/.env.network index f8962ea6..4bcd8f15 100644 --- a/frontend-web/.env.network +++ b/frontend-web/.env.network @@ -23,6 +23,8 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red VITE_AIPI_MOPECHAT_URL=http://192.168.1.100/likun_b1_mope VITE_AIPI_BACKENP_URL=http://192.168.1.100 VITE_AIPI_CONSUMER_URL=http://192.168.1.100/consumer +# MaxKey 鉴权(getTokenByOnlineTicket,内网部署请改为实际地址) +VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn VITE_AIPI_PGLINKTO_URL=http://192.168.1.100 VITE_AIPI_XKLINKTO_URL=http://192.168.1.100 diff --git a/frontend-web/.env.production b/frontend-web/.env.production index 0f10b55c..67dabce1 100644 --- a/frontend-web/.env.production +++ b/frontend-web/.env.production @@ -21,6 +21,8 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer +# MaxKey 鉴权(getTokenByOnlineTicket) +VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn diff --git a/frontend-web/.env.sit b/frontend-web/.env.sit index 5ae77457..913fe3a3 100644 --- a/frontend-web/.env.sit +++ b/frontend-web/.env.sit @@ -21,6 +21,8 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red VITE_AIPI_MOPECHAT_URL=https://ch1.b.sit.4.cn/likun_b1_mope VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.sit.4.cn VITE_AIPI_CONSUMER_URL=https://ch1.b.sit.4.cn/consumer +# MaxKey 鉴权(getTokenByOnlineTicket) +VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn VITE_AIPI_PGLINKTO_URL=https://pg.b.sit.4.cn VITE_AIPI_XKLINKTO_URL=https://kz.b.sit.4.cn diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index 33463bf9..d5149b45 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -60,6 +60,8 @@ export function MessageList({ onClarificationSubmit, onSetupAgentApprove, onSetupAgentReject, + onSetupWritingApprove, + onSetupWritingReject, }: { className?: string; threadId: string; @@ -70,6 +72,8 @@ export function MessageList({ onClarificationSubmit?: (answer: string) => void | Promise; onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise; onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise; + onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise; + onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise; }) { const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const rehypePlugins = useMemo( @@ -77,6 +81,7 @@ export function MessageList({ [baseRehypePlugins, extraRehypePlugins], ); const updateSubtask = useUpdateSubtask(); + const { t } = useI18n(); const messages = thread.messages; // Stamp a wall-clock time onto every question/answer the first time it is @@ -307,6 +312,23 @@ export function MessageList({ /> ) : null; + } else if (group.type === "assistant:setup-writing-approval") { + const message = group.messages[0]; + return message ? ( +
+ + +
+ ) : null; } else if (group.type === "assistant:present-files") { const files: string[] = []; for (const message of group.messages) { @@ -485,6 +507,16 @@ export type SetupAgentApprovalPayload = { skills?: string[]; }; +export type SetupWritingApprovalPayload = { + user_intent?: string; + article_type?: string; + word_count_target?: number | null; + target_audience?: string; + writing_mode?: string; + material_source?: string; + outline?: string; +}; + function findPreviousHumanMessageId( messages: Message[], beforeMessageId?: string, @@ -635,6 +667,155 @@ function SetupAgentApprovalCard({ ); } +function getSetupWritingApprovalPayload( + message: unknown, +): SetupWritingApprovalPayload | null { + if (typeof message !== "object" || message === null) { + return null; + } + const additionalKwargs = Reflect.get(message, "additional_kwargs"); + if (typeof additionalKwargs !== "object" || additionalKwargs === null) { + return null; + } + const approval = Reflect.get(additionalKwargs, "setup_writing_approval"); + if (typeof approval !== "object" || approval === null) { + return null; + } + return approval as SetupWritingApprovalPayload; +} + +const WRITING_MODE_LABELS: Record = { + strict: "严格依据素材", + loose: "可结合通用知识", +}; +const MATERIAL_SOURCE_LABELS: Record = { + knowledge_base: "知识库", + skill: "技能检索", +}; + +function SetupWritingApprovalCard({ + disabled, + message, + onApprove, + onReject, +}: { + disabled: boolean; + message: unknown; + onApprove?: (payload: SetupWritingApprovalPayload) => void | Promise; + onReject?: (payload: SetupWritingApprovalPayload) => void | Promise; +}) { + const payload = getSetupWritingApprovalPayload(message); + const [submitting, setSubmitting] = useState<"approve" | "reject" | null>( + null, + ); + + if (!payload) { + return null; + } + + const approvalPayload = payload; + const busy = disabled || submitting !== null; + + async function run(action: "approve" | "reject") { + if (busy) { + return; + } + setSubmitting(action); + try { + if (action === "approve") { + await onApprove?.(approvalPayload); + } else { + await onReject?.(approvalPayload); + } + } finally { + setSubmitting(null); + } + } + + const rows: Array<{ label: string; value: string }> = []; + if (approvalPayload.article_type) { + rows.push({ label: "文章类型", value: approvalPayload.article_type }); + } + if (typeof approvalPayload.word_count_target === "number") { + rows.push({ label: "目标字数", value: `${approvalPayload.word_count_target} 字` }); + } + if (approvalPayload.target_audience) { + rows.push({ label: "目标读者", value: approvalPayload.target_audience }); + } + if (approvalPayload.writing_mode) { + rows.push({ + label: "写作模式", + value: WRITING_MODE_LABELS[approvalPayload.writing_mode] ?? approvalPayload.writing_mode, + }); + } + if (approvalPayload.material_source) { + rows.push({ + label: "素材来源", + value: MATERIAL_SOURCE_LABELS[approvalPayload.material_source] ?? approvalPayload.material_source, + }); + } + + return ( +
+
+
+
写作配置建议
+
确认后将应用到左侧写作表单。
+
+ + {approvalPayload.user_intent ? ( +
+
写作意图
+

{approvalPayload.user_intent}

+
+ ) : null} + + {rows.length > 0 ? ( +
+ {rows.map((row) => ( +
+
{row.label}
+

{row.value}

+
+ ))} +
+ ) : null} + + {approvalPayload.outline ? ( +
+
已有大纲
+
+              {approvalPayload.outline}
+            
+
+ ) : null} +
+ +
+ + +
+
+ ); +} + function getClarificationPayload(message: unknown): ClarificationPayload | null { if (typeof message !== "object" || message === null) { return null; diff --git a/frontend-web/src/core/auth/external-login.ts b/frontend-web/src/core/auth/external-login.ts index bd80d522..37c865a6 100644 --- a/frontend-web/src/core/auth/external-login.ts +++ b/frontend-web/src/core/auth/external-login.ts @@ -13,6 +13,10 @@ export const EXTERNAL_LOGIN_STORAGE_KEYS = { ticket: "login.ticket", userId: "login.userId", env: "login.env", + /** MaxKey getTokenByOnlineTicket 返回的 token */ + maxkeyOnlineToken: "login.maxkeyOnlineToken", + /** consumer /cop/userDetailToken 返回的 data 整包(JSON) */ + userDetailToken: "login.userDetailToken", } as const; export type ExternalLoginParams = { diff --git a/frontend-web/src/core/auth/wujie-ticket-login.ts b/frontend-web/src/core/auth/wujie-ticket-login.ts new file mode 100644 index 00000000..9bccd610 --- /dev/null +++ b/frontend-web/src/core/auth/wujie-ticket-login.ts @@ -0,0 +1,142 @@ +import { requestCrudService } from "@/service/request-axios-service"; + +import { + EXTERNAL_LOGIN_STORAGE_KEYS, + persistExternalLoginParams, +} from "./external-login"; + +const AUTH_BASE_URL = (import.meta.env.VITE_AUTH_BASE_URL || "").replace( + /\/$/, + "", +); +const CONSUMER_URL = (import.meta.env.VITE_AIPI_CONSUMER_URL || "").replace( + /\/$/, + "", +); + +/** MaxKey onlineTicket 换发的 token(后续请求可能用到) */ +export const MAXKEY_ONLINE_TOKEN_STORAGE_KEY = + EXTERNAL_LOGIN_STORAGE_KEYS.maxkeyOnlineToken; + +/** `/cop/userDetailToken` 返回的 data 整包(JWT 解析后的用户信息) */ +export const USER_DETAIL_TOKEN_STORAGE_KEY = + EXTERNAL_LOGIN_STORAGE_KEYS.userDetailToken; + +export type MaxkeyTokenByTicketResponse = { + code: number; + data: string; + message?: string; +}; + +export type UserDetailTokenPayload = { + sub?: string; + user_id?: string; + exp?: number; + expired?: boolean; + iat?: number; + institution?: string; + iss?: string; + locale?: string; + kid?: string; + jti?: string; + [key: string]: unknown; +}; + +type UserDetailTokenResponse = { + code?: number; + data?: UserDetailTokenPayload; + message?: string; +}; + +function persistMaxkeyOnlineToken(token: string): void { + try { + localStorage.setItem(MAXKEY_ONLINE_TOKEN_STORAGE_KEY, token); + } catch { + // 静默失败,不阻断登录 + } +} + +function persistUserDetailToken(data: UserDetailTokenPayload): void { + try { + localStorage.setItem(USER_DETAIL_TOKEN_STORAGE_KEY, JSON.stringify(data)); + if (data.user_id) { + localStorage.setItem(EXTERNAL_LOGIN_STORAGE_KEYS.userId, String(data.user_id)); + } + } catch { + // 静默失败 + } +} + +/** + * 无界 / 外部跳转:用 onlineTicket 换 MaxKey token,并拉取用户详情。 + * - token 存入 localStorage(`login.maxkeyOnlineToken`) + * - userDetailToken 的 data 存入 localStorage(`login.userDetailToken`) + * @returns 用于 DeerFlow `loginByUsername` 的用户名(取自 data.sub) + */ +export async function resolveUsernameByOnlineTicket( + ticket: string, +): Promise { + const onlineTicket = ticket.trim(); + if (!onlineTicket) { + throw new Error("缺少 onlineTicket,无法完成无界登录。"); + } + if (!AUTH_BASE_URL) { + throw new Error("未配置 VITE_AUTH_BASE_URL,无法换取 MaxKey token。"); + } + if (!CONSUMER_URL) { + throw new Error("未配置 VITE_AIPI_CONSUMER_URL,无法获取用户详情。"); + } + + persistExternalLoginParams({ ticket: onlineTicket }); + + const tokenRes = (await requestCrudService.get( + `${AUTH_BASE_URL}/maxkey-mgt-api/sys/getTokenByOnlineTicket`, + { onlineTicket }, + )) as unknown as MaxkeyTokenByTicketResponse; + + console.log("[wujie] getTokenByOnlineTicket:", tokenRes); + + if (tokenRes.code !== 200 || !tokenRes.data) { + throw new Error( + tokenRes.message || "getTokenByOnlineTicket 未返回有效 token。", + ); + } + persistMaxkeyOnlineToken(tokenRes.data); + + const detailRes = (await requestCrudService.get( + `${CONSUMER_URL}/cop/userDetailToken`, + { ticket: onlineTicket }, + )) as unknown as UserDetailTokenResponse; + + console.log("[wujie] userDetailToken:", detailRes); + + const userData = detailRes.data; + if (!userData) { + throw new Error("userDetailToken 未返回用户 data。"); + } + persistUserDetailToken(userData); + + const username = (userData.sub ?? "").trim(); + if (!username) { + throw new Error("userDetailToken 中缺少 sub(用户名)。"); + } + return username; +} + +export function readMaxkeyOnlineToken(): string { + try { + return localStorage.getItem(MAXKEY_ONLINE_TOKEN_STORAGE_KEY) ?? ""; + } catch { + return ""; + } +} + +export function readUserDetailToken(): UserDetailTokenPayload | null { + try { + const raw = localStorage.getItem(USER_DETAIL_TOKEN_STORAGE_KEY); + if (!raw) return null; + return JSON.parse(raw) as UserDetailTokenPayload; + } catch { + return null; + } +} diff --git a/frontend-web/src/core/embed/wujie.ts b/frontend-web/src/core/embed/wujie.ts index cd98e224..e725cba2 100644 --- a/frontend-web/src/core/embed/wujie.ts +++ b/frontend-web/src/core/embed/wujie.ts @@ -6,7 +6,7 @@ * 注入参数,子应用在 `window.$wujie.props` 上拿到**同一个对象**(无界在子应用 * bootstrap 之前就已注入,因此首帧即可读)。 * - * 本期范围:只「读出来 + 存起来 + 打印」,**不接入对方鉴权**。读出的 + * ticket 在 LoginPage 会走 MaxKey + consumer 双接口换用户名并登录;读出的 * ticket/userId/env 复用 external-login 的 localStorage 存储(与 URL 跳转登录 * 同一套 key),theme 交给调用方(LoginPage)映射到 ThemeContext。 */ @@ -16,7 +16,7 @@ import { persistExternalLoginParams } from "@/core/auth/external-login"; /** 无界父级注入的 props 形状。除已知字段外允许透传其余未知字段便于排查。 */ export interface WujieProps { - /** 第三方鉴权 ticket(本期仅存储待用,不消费)。 */ + /** 第三方 onlineTicket(LoginPage 用于 MaxKey + userDetailToken 登录)。 */ ticket?: string; /** 外部用户 id。 */ userId?: string; diff --git a/frontend-web/src/core/messages/utils.ts b/frontend-web/src/core/messages/utils.ts index f13c5747..fbc038a7 100644 --- a/frontend-web/src/core/messages/utils.ts +++ b/frontend-web/src/core/messages/utils.ts @@ -20,6 +20,8 @@ interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:cla interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {} +interface AssistantSetupWritingApprovalGroup extends GenericMessageGroup<"assistant:setup-writing-approval"> {} + interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {} type MessageGroup = @@ -29,6 +31,7 @@ type MessageGroup = | AssistantPresentFilesGroup | AssistantClarificationGroup | AssistantSetupAgentApprovalGroup + | AssistantSetupWritingApprovalGroup | AssistantSubagentGroup; export function groupMessages( @@ -50,7 +53,8 @@ export function groupMessages( last.type !== "human" && last.type !== "assistant" && last.type !== "assistant:clarification" && - last.type !== "assistant:setup-agent-approval" + last.type !== "assistant:setup-agent-approval" && + last.type !== "assistant:setup-writing-approval" ) { return last; } @@ -88,6 +92,13 @@ export function groupMessages( type: "assistant:setup-agent-approval", messages: [message], }); + } else if (isSetupWritingApprovalToolMessage(message)) { + lastOpenGroup()?.messages.push(message); + groups.push({ + id: message.id, + type: "assistant:setup-writing-approval", + messages: [message], + }); } else { const open = lastOpenGroup(); if (open) { @@ -354,6 +365,15 @@ export function isSetupAgentApprovalToolMessage(message: Message) { ); } +export function isSetupWritingApprovalToolMessage(message: Message) { + return ( + message.type === "tool" && + message.name === "setup_writing" && + typeof message.additional_kwargs?.setup_writing_approval === "object" && + message.additional_kwargs.setup_writing_approval !== null + ); +} + export function extractPresentFilesFromMessage(message: Message) { if (message.type !== "ai" || !hasPresentFiles(message)) { return []; 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 246a4b09..bfa8dfd6 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo } from 'react'; import { PenLine } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -14,6 +14,7 @@ import { useModels } from '@/core/models/hooks'; import type { AIWritingRequest } from '../../types/ai-writing'; import { useArticleTypes } from '../../hooks/useArticleTypes'; +import { useWritingForm } from '../../contexts/WritingFormContext'; interface AIWritingFormProps { onSubmit: (req: AIWritingRequest) => void; @@ -31,7 +32,6 @@ const INPUT_CLASS = const DEFAULT_KEYWORD_COUNT = 1; const DEFAULT_AUDIENCE = '普通读者'; -const DEFAULT_MATERIAL_SOURCE: 'knowledge_base' | 'skill' = 'knowledge_base'; const MAX_INTENT_LENGTH = 500; // 素材来源选项:知识库=素材专家默认 web/intranet 检索;技能检索=用技能编排去检索 @@ -49,16 +49,17 @@ export function AIWritingForm({ disabled, hideAdvancedFields = false, }: AIWritingFormProps) { - const [intent, setIntent] = useState(''); - const [showOutline, setShowOutline] = useState(false); - const [outlineText, setOutlineText] = useState(''); - const [articleType, setArticleType] = useState('analysis'); - const [audience, setAudience] = useState('普通读者'); - const [wordCountInput, setWordCountInput] = useState('800'); - const [strictMode, setStrictMode] = useState(false); - const [materialSource, setMaterialSource] = - useState<'knowledge_base' | 'skill'>(DEFAULT_MATERIAL_SOURCE); - const [modelName, setModelName] = useState(''); + const { + intent, setIntent, + showOutline, setShowOutline, + outlineText, setOutlineText, + articleType, setArticleType, + audience, setAudience, + wordCountInput, setWordCountInput, + strictMode, setStrictMode, + materialSource, setMaterialSource, + modelName, setModelName, + } = useWritingForm(); const { models, isLoading: modelsLoading } = useModels(); const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes(); @@ -161,7 +162,7 @@ export function AIWritingForm({