feat: 无界 ticket 登录、AI 写作配置确认与定时任务弹窗修复

无界 onlineTicket 对接 MaxKey 与 consumer 接口并用 sub 登录;新增 setup_writing 写作配置确认流;定时任务创建弹窗限制多行高度并支持滚动。
This commit is contained in:
fengxiangdebaozi committed 2026-06-03 16:20:45 +08:00
1 parent ab876fc7d8
commit ad4b27b7ca
25 files changed
+980 -54

No files matched your search

+2
View File
@@ -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)
@@ -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
+4
View File
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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
@@ -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<void>;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise<void>;
onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
}) {
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({
/>
</div>
) : null;
} else if (group.type === "assistant:setup-writing-approval") {
const message = group.messages[0];
return message ? (
<div key={group.id} className="w-full">
<SetupWritingApprovalCard
disabled={thread.isLoading}
message={message}
onApprove={onSetupWritingApprove}
onReject={onSetupWritingReject}
/>
<MessageTokenUsageList
enabled={tokenUsageEnabled}
isLoading={thread.isLoading}
messages={group.messages}
/>
</div>
) : 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<string, string> = {
strict: "严格依据素材",
loose: "可结合通用知识",
};
const MATERIAL_SOURCE_LABELS: Record<string, string> = {
knowledge_base: "知识库",
skill: "技能检索",
};
function SetupWritingApprovalCard({
disabled,
message,
onApprove,
onReject,
}: {
disabled: boolean;
message: unknown;
onApprove?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
onReject?: (payload: SetupWritingApprovalPayload) => void | Promise<void>;
}) {
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 (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-3">
<div>
<div className="text-muted-foreground text-xs font-medium">写作配置建议</div>
<div className="text-sm font-medium">确认后将应用到左侧写作表单。</div>
</div>
{approvalPayload.user_intent ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">写作意图</div>
<p className="text-sm leading-6">{approvalPayload.user_intent}</p>
</div>
) : null}
{rows.length > 0 ? (
<div className="flex flex-wrap gap-x-6 gap-y-1.5">
{rows.map((row) => (
<div key={row.label} className="space-y-0.5">
<div className="text-muted-foreground text-xs">{row.label}</div>
<p className="text-sm font-medium">{row.value}</p>
</div>
))}
</div>
) : null}
{approvalPayload.outline ? (
<div className="space-y-1">
<div className="text-muted-foreground text-xs">已有大纲</div>
<pre className="bg-muted max-h-52 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap">
{approvalPayload.outline}
</pre>
</div>
) : null}
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
type="button"
variant="outline"
size="sm"
disabled={busy || !onReject}
onClick={() => void run("reject")}
>
<XIcon className="h-4 w-4" />
忽略
</Button>
<Button
type="button"
size="sm"
disabled={busy || !onApprove}
onClick={() => void run("approve")}
>
<CheckIcon className="h-4 w-4" />
应用到表单
</Button>
</div>
</div>
);
}
function getClarificationPayload(message: unknown): ClarificationPayload | null {
if (typeof message !== "object" || message === null) {
return null;
@@ -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 = {
@@ -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<string> {
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;
}
}
+2 -2
View File
@@ -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;
+21 -1
View File
@@ -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<T>(
@@ -50,7 +53,8 @@ export function groupMessages<T>(
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<T>(
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 [];
@@ -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({
<div className="mt-4 space-y-2">
<button
type="button"
onClick={() => setShowOutline((v) => !v)}
onClick={() => setShowOutline(!showOutline)}
disabled={disabled}
className="flex w-full items-center justify-between text-left"
>
@@ -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 (
<ThreadContext.Provider value={{ thread }}>
<ArtifactsProvider>
<div className="flex size-full flex-col bg-background">
<header className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
<Sparkles className="size-4 text-primary" />
<h2 className="text-sm font-semibold">写作助手</h2>
<span className="text-muted-foreground text-xs">描述你想写什么,我来帮你填好左侧配置</span>
</header>
<div className="min-h-0 flex-1">
<MessageList
className="size-full"
threadId={threadId}
thread={thread}
onClarificationSubmit={handleChatSubmit}
onSetupWritingApprove={handleApprove}
onSetupWritingReject={handleReject}
/>
</div>
<div className="bg-background shrink-0 space-y-2 border-t px-4 py-4">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">当前对话模型</span>
{modelsLoading || models.length === 0 ? (
<div className="border-input bg-background text-muted-foreground flex h-7 min-w-[10rem] items-center rounded-md border px-2 text-xs shadow-xs dark:border-[#1B477E] dark:bg-[#022248]">
{modelsLoading ? '加载中…' : '暂无模型'}
</div>
) : (
<Select value={chatModel} onValueChange={setChatModel} disabled={thread.isLoading}>
<SelectTrigger
className={cn(
'border-input bg-background h-7 min-w-[10rem] px-2 text-xs shadow-xs outline-none',
'dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]',
)}
>
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent position="popper" className="w-[var(--radix-select-trigger-width)] dark:border-[#1B477E] dark:bg-[#022248]">
{models.map((model) => (
<SelectItem key={model.name} value={model.name} className="dark:text-[#AACEFF]">
{model.display_name || model.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<PromptInput onSubmit={({ text }) => void handleChatSubmit(text)}>
<PromptInputTextarea
placeholder="例如:帮我写一篇 2000 字、面向行业专家的新能源汽车出口分析"
disabled={thread.isLoading}
/>
<PromptInputFooter className="justify-end">
<PromptInputSubmit disabled={thread.isLoading} />
</PromptInputFooter>
</PromptInput>
</div>
</div>
</ArtifactsProvider>
</ThreadContext.Provider>
);
}
@@ -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() {
<div className="flex-1 min-h-0 overflow-hidden">
{leftMode === "chat" ? <CanvasChatPanel /> : (
<AIWritingProvider>
<AIWritingPanel />
<WritingFormProvider>
<AIWritingPanel />
</WritingFormProvider>
</AIWritingProvider>
)}
</div>
@@ -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<WritingFormValue | null>(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<WritingFormValue>(
() => ({
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 <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>;
}
@@ -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' ? <WritingSetupChat /> : <AIWritingDraftPanel />;
}
export default function AIWritingPage({
hideAdvancedFields = false,
}: {
@@ -116,6 +127,7 @@ export default function AIWritingPage({
<CanvasGraphProvider>
<CanvasUIProvider>
<AIWritingProvider>
<WritingFormProvider>
<TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background">
<div className="flex-1 min-h-0 overflow-hidden">
@@ -147,12 +159,13 @@ export default function AIWritingPage({
minSize={40}
className="overflow-hidden"
>
<AIWritingDraftPanel />
<AIWritingRightPanel />
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
</TooltipProvider>
</WritingFormProvider>
</AIWritingProvider>
</CanvasUIProvider>
</CanvasGraphProvider>
@@ -445,10 +445,11 @@ export default function AdminScheduledTasksPage() {
</WorkspaceBody>
<Dialog open={!!editing} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogContent className="flex max-h-[85vh] max-w-2xl flex-col gap-4 overflow-hidden sm:max-w-2xl">
<DialogHeader className="shrink-0">
<DialogTitle>编辑定时任务</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1">
<div className="flex flex-col gap-3">
<Input
value={form.name}
@@ -475,7 +476,8 @@ export default function AdminScheduledTasksPage() {
placeholder="时区,例如:Asia/Shanghai"
/>
<Textarea
className="min-h-40"
rows={10}
className="field-sizing-fixed min-h-40 max-h-48 resize-y overflow-y-auto"
value={form.prompt}
onChange={(event) =>
setForm((value) => ({ ...value, prompt: event.target.value }))
@@ -483,7 +485,8 @@ export default function AdminScheduledTasksPage() {
placeholder="任务 Prompt"
/>
</div>
<DialogFooter>
</div>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={closeDialog}>
取消
</Button>
+21 -5
View File
@@ -8,8 +8,10 @@ import { loginByUsername } from "@/core/auth/api";
import { setStoredAuth } from "@/core/auth";
import {
persistExternalLoginParams,
readExternalLoginParams,
resolveThemeFromQuery,
} from "@/core/auth/external-login";
import { resolveUsernameByOnlineTicket } from "@/core/auth/wujie-ticket-login";
import { isWujieEnv, useWujieProps } from "@/core/embed/wujie";
import {
GENERAL_QA_ROUTE,
@@ -54,6 +56,16 @@ export default function LoginPage() {
// hook 内部已把 ticket/userId/env 持久化到 localStorage(复用 external-login 存储)。
const wujieProps = useWujieProps();
/** onlineTicket:无界 props 优先,其次 URL query,最后读 localStorage */
const onlineTicket = useMemo(() => {
const fromWujie = wujieProps.ticket?.trim() ?? "";
const fromQuery = externalParams.ticket.trim();
if (fromWujie || fromQuery) {
return fromWujie || fromQuery;
}
return readExternalLoginParams().ticket.trim();
}, [wujieProps.ticket, externalParams.ticket]);
// 调试:把无界注入的 props 打印出来(仅在无界子应用环境下,避免独立访问时刷日志)。
useEffect(() => {
if (!isWujieEnv()) return;
@@ -71,11 +83,11 @@ export default function LoginPage() {
// ticket / userId / env 持久化(与 login 请求并行做,互不阻塞)
useEffect(() => {
persistExternalLoginParams({
ticket: externalParams.ticket,
ticket: onlineTicket || externalParams.ticket,
userId: externalParams.userId,
env: externalParams.env,
});
}, [externalParams.ticket, externalParams.userId, externalParams.env]);
}, [onlineTicket, externalParams.ticket, externalParams.userId, externalParams.env]);
useEffect(() => {
let cancelled = false;
@@ -84,13 +96,17 @@ export default function LoginPage() {
setStatus("loading");
setErrorMessage("");
try {
if (yUserId) {
let deerflowUsername = resolvedUsername;
if (onlineTicket) {
deerflowUsername = await resolveUsernameByOnlineTicket(onlineTicket);
} else if (yUserId) {
await loginByYUserId(yUserId, toolToken || null);
} else if (toolToken) {
persistToolsToken(toolToken);
}
const auth = await loginByUsername(resolvedUsername);
const auth = await loginByUsername(deerflowUsername);
if (cancelled) {
return;
}
@@ -113,7 +129,7 @@ export default function LoginPage() {
return () => {
cancelled = true;
};
}, [resolvedUsername, router, sidebarEnabled, yUserId, toolToken]);
}, [resolvedUsername, router, sidebarEnabled, yUserId, toolToken, onlineTicket]);
return (
<div className="flex min-h-screen items-center justify-center bg-background px-6">
+22 -5
View File
@@ -112,6 +112,13 @@ const emptyForm = {
require_markdown: false,
};
/** 覆盖 Textarea 默认 field-sizing-content,避免输入过多时撑破弹窗 */
const SCHEDULED_TASK_TEXTAREA_CLASS =
"field-sizing-fixed resize-y overflow-y-auto";
const DESCRIPTION_TEXTAREA_ROWS = 4;
const PROMPT_TEXTAREA_ROWS = 10;
export default function ScheduledTasksPage() {
const { theme } = useTheme();
const darkToastOpts =
@@ -824,10 +831,11 @@ export default function ScheduledTasksPage() {
</Dialog>
<Dialog open={creating || !!editing} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent className="max-w-2xl overflow-hidden">
<DialogHeader className="min-w-0">
<DialogContent className="flex max-h-[85vh] max-w-2xl flex-col gap-4 overflow-hidden sm:max-w-2xl">
<DialogHeader className="min-w-0 shrink-0">
<DialogTitle>{creating ? "新建定时任务" : "编辑定时任务"}</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1">
<div className="flex min-w-0 flex-col gap-3">
<Input
value={form.name}
@@ -837,7 +845,11 @@ export default function ScheduledTasksPage() {
placeholder="任务名称"
/>
<Textarea
className="min-h-16"
rows={DESCRIPTION_TEXTAREA_ROWS}
className={cn(
SCHEDULED_TASK_TEXTAREA_CLASS,
"min-h-[4.5rem] max-h-28",
)}
value={form.description}
onChange={(event) =>
setForm((value) => ({ ...value, description: event.target.value }))
@@ -883,7 +895,11 @@ export default function ScheduledTasksPage() {
</SelectContent>
</Select>
<Textarea
className="min-h-40"
rows={PROMPT_TEXTAREA_ROWS}
className={cn(
SCHEDULED_TASK_TEXTAREA_CLASS,
"min-h-40 max-h-48",
)}
value={form.prompt}
onChange={(event) =>
setForm((value) => ({ ...value, prompt: event.target.value }))
@@ -905,7 +921,8 @@ export default function ScheduledTasksPage() {
/>
</div>
</div>
<DialogFooter>
</div>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={closeDialog}>
取消
</Button>
@@ -209,6 +209,8 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
"max_concurrent_subagents",
"agent_name",
"is_bootstrap",
"is_writing_setup",
"valid_article_types",
"excluded_tools",
"skill_stop_names",
"skill_stop_message",
@@ -11,6 +11,7 @@ from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
from deerflow.agents.middlewares.prompt_prefix_middleware import PromptPrefixMiddleware
from deerflow.agents.middlewares.rag_citation_middleware import RagCitationMiddleware
from deerflow.agents.middlewares.setup_agent_approval_middleware import SetupAgentApprovalMiddleware
from deerflow.agents.middlewares.setup_writing_approval_middleware import SetupWritingApprovalMiddleware
from deerflow.agents.middlewares.subagent_limit_middleware import SubagentLimitMiddleware
from deerflow.agents.middlewares.summarization_middleware import BeforeSummarizationHook, DeerFlowSummarizationMiddleware
from deerflow.agents.middlewares.title_middleware import TitleMiddleware
@@ -272,6 +273,7 @@ def _build_middlewares(
# Add TodoList middleware if plan mode is enabled
is_plan_mode = cfg.get("is_plan_mode", False)
is_bootstrap = cfg.get("is_bootstrap", False)
is_writing_setup = cfg.get("is_writing_setup", False)
todo_list_middleware = _create_todo_list_middleware(is_plan_mode)
if todo_list_middleware is not None:
middlewares.append(todo_list_middleware)
@@ -285,7 +287,7 @@ def _build_middlewares(
# Add MemoryMiddleware (after TitleMiddleware) — skip in bootstrap mode (agent creation flow)
memory_injection_enabled = cfg.get("memory_injection_enabled", True)
if not is_bootstrap:
if not is_bootstrap and not is_writing_setup:
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory, injection_enabled=memory_injection_enabled))
# Add ViewImageMiddleware only if the current model supports vision.
@@ -314,6 +316,7 @@ def _build_middlewares(
middlewares.extend(custom_middlewares)
middlewares.append(SetupAgentApprovalMiddleware())
middlewares.append(SetupWritingApprovalMiddleware())
# SkillStopMiddleware — intercepts skill invocations and halts execution
# when a configured skill is about to run. Request-level options
@@ -361,7 +364,7 @@ def make_lead_agent(config: RunnableConfig):
def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
# Lazy import to avoid circular dependency
from deerflow.tools import get_available_tools
from deerflow.tools.builtins import setup_agent
from deerflow.tools.builtins import setup_agent, setup_writing
cfg = _get_runtime_config(config)
resolved_app_config = app_config
@@ -373,6 +376,10 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
subagent_enabled = cfg.get("subagent_enabled", False)
max_concurrent_subagents = cfg.get("max_concurrent_subagents", 3)
is_bootstrap = cfg.get("is_bootstrap", False)
# 写作配置 Q&A:与 is_bootstrap 同构的另一种「最小化 bootstrap」——挂 setup_writing 工具、
# 走写作配置专用 prompt,只负责帮用户把写作表单聊清并提交,不真正开始写作。
is_writing_setup = cfg.get("is_writing_setup", False)
writing_setup_article_types = cfg.get("valid_article_types") if is_writing_setup else None
memory_injection_enabled = bool(cfg.get("memory_injection_enabled", True))
rag_mode_enabled = bool(cfg.get("rag_mode_enabled", False))
writing_mode = bool(cfg.get("writing_mode", False))
@@ -383,7 +390,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
requested_excluded_tools = cfg.get("excluded_tools") or None
try:
agent_config = load_agent_config(agent_id) if not is_bootstrap else None
agent_config = load_agent_config(agent_id) if not is_bootstrap and not is_writing_setup else None
except FileNotFoundError:
logger.warning("Agent directory not found for '%s'; running with default config", agent_id)
agent_config = None
@@ -443,6 +450,20 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
}
)
if is_writing_setup:
# 写作配置 Q&A:最小化 agent —— **只挂 setup_writing 一个工具** + 一份职责单一的独立提示。
# 关键:绝不给全套工具、绝不复用 lead 超级体提示,否则模型会把「新能源汽车市场分析」之类
# 长得像任务的输入直接做掉,而不是转成写作配置提交。
from deerflow.agents.lead_agent.prompt import build_writing_setup_prompt
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config),
tools=[setup_writing],
middleware=_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
system_prompt=build_writing_setup_prompt(writing_setup_article_types),
state_schema=ThreadState,
)
if is_bootstrap:
# Special bootstrap agent with minimal prompt for initial custom agent creation flow
return create_agent(
@@ -703,14 +703,14 @@ def _get_cached_skills_prompt_section(
display_pattern = (
"""
**Skill Result Display Pattern:**
- If RAG/reference display mode is enabled, do not number each skill result independently. Treat all retrieval results from the same user turn as one combined `referenceBatch` and cite with `[1]`, `[2]`, etc. from the system-provided reference prompt.
- In RAG/reference display mode, do not output `<skill-display .../>`; the frontend renders cards, tables, lists, and citation details in the right reference panel.
- If RAG/reference display mode is not enabled and a skill has `<display mode="citation">`, use the skill's returned result order as reference numbers. Cite facts from the first result as `[1]`, the second as `[2]`, and so on. Use only numbers that correspond to returned results.
- If a skill has `<display mode="list">`, summarize the returned items as a concise list and keep item order stable.
- If a skill has `<display mode="table">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="table" />` where the frontend should render the real table. Do not hand-write the table rows yourself.
- If a skill has `<display mode="card">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="card" />` where the frontend should render the cards.
- If a skill has `<display mode="custom">`, follow the custom display instruction in the `<display>` tag when presenting the skill result.
**Skill Result Display Pattern:**
- If RAG/reference display mode is enabled, do not number each skill result independently. Treat all retrieval results from the same user turn as one combined `referenceBatch` and cite with `[1]`, `[2]`, etc. from the system-provided reference prompt.
- In RAG/reference display mode, do not output `<skill-display .../>`; the frontend renders cards, tables, lists, and citation details in the right reference panel.
- If RAG/reference display mode is not enabled and a skill has `<display mode="citation">`, use the skill's returned result order as reference numbers. Cite facts from the first result as `[1]`, the second as `[2]`, and so on. Use only numbers that correspond to returned results.
- If a skill has `<display mode="list">`, summarize the returned items as a concise list and keep item order stable.
- If a skill has `<display mode="table">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="table" />` where the frontend should render the real table. Do not hand-write the table rows yourself.
- If a skill has `<display mode="card">`, summarize the result and output `<skill-display tool="SKILL_OR_TOOL_NAME" mode="card" />` where the frontend should render the cards.
- If a skill has `<display mode="custom">`, follow the custom display instruction in the `<display>` tag when presenting the skill result.
"""
if display_signature
else ""
@@ -904,6 +904,46 @@ def _build_bootstrap_section() -> str:
"""
def build_writing_setup_prompt(article_types: list[dict] | None = None) -> str:
"""写作配置 Q&A 的**独立**系统提示(不复用 lead-agent 超级体提示)。
关键:写作配置助手绝不能像 lead agent 那样「自己把任务做掉」——用户输入常常长得像一个
可执行任务(如「新能源汽车市场分析」),若复用 lead 提示 + 全套工具,模型会直接去研究/写
分析。这里用一份职责单一、口吻强硬的独立提示,配合「只挂 setup_writing 一个工具」,把模型
死死框在「聊清需求 → 调 setup_writing」上。
"""
if article_types:
type_lines = "\n".join(
f" - `{t.get('key', '')}`:{t.get('label', '')}" for t in article_types if t.get("key")
)
type_block = f"【可选文章类型】article_type 只能填下列 key 之一:\n{type_lines}"
else:
type_block = "【文章类型】若不确定 article_type,可留空,交给用户在表单里选。"
return f"""你是「AI 写作」功能的**配置助手**。你的唯一职责:通过简短对话把用户的写作需求聊清楚,然后调用 `setup_writing` 工具把写作配置提交给用户确认。提交后由用户在表单里应用、再点「开始写作」。
【铁律——最高优先级】
- 你**绝对不撰写**文章正文、分析内容、大纲内容或任何成稿文字。即使用户的输入看起来就是一个写作任务(例如「新能源汽车市场分析」「帮我写一篇……」),你也**只把它当作"要配置的写作目标"**,绝不替用户动笔、绝不直接完成这篇写作。
- 你能做的动作只有两个:① 用自然语言向用户提问或确认;② 调用 `setup_writing` 工具。**除此之外你没有任何其它工具**,也不要假装调用别的工具、不要做检索、不要做研究。
- 不要在回复里输出文章段落、要点清单或大纲(用户主动粘贴的大纲除外——原样放进 `outline` 参数)。
【工作流程】
1. 读用户输入。如果「写什么」已经明确,关键信息(文章类型、字数、读者)即使缺失也可以用合理默认补全——**信息基本够了就直接进入第 3 步,不要为了凑齐而反复追问**。
2. 只有当「写什么」本身都不清楚时,才用一句话问 1-2 个最关键的问题(最多一轮)。
3. 调用 `setup_writing` 提交配置:
- `user_intent`:精炼成一句话的写作目标(必填)。
- `article_type`:见下方可选类型;不确定就留空。
- `word_count_target`:正整数;用户没明说就给合理默认(如 800)。
- `target_audience`:如「普通读者」「行业专家」;没提就填「普通读者」。
- `writing_mode`:用户强调「严格依据素材/不要编造」→ `strict`,否则 `loose`。
- `material_source`:用户提到用「技能/技能检索」→ `skill`,否则 `knowledge_base`。
- `outline`:用户提供的大纲原文(保留其层级与标题),否则留空。
4. 调用 `setup_writing` 前,用**一句话**说明你将提交的配置要点;调用之后不要再输出任何文章内容。
{type_block}
"""
def apply_prompt_template(
subagent_enabled: bool = False,
max_concurrent_subagents: int = 3,
@@ -995,16 +1035,16 @@ Writing mode is enabled for this run.
network_policy_section = _build_network_policy_section()
rag_mode_section = (
"""
<rag_citation_mode>
RAG/reference display mode is enabled.
When a search, knowledge, skill, or MCP tool returns a result list:
- If multiple search terms are needed, prefer calling the retrieval tools for all known terms in the same step, then answer after the tool results are returned.
- Treat every retrieval result from the same user turn as one combined `referenceBatch`; do not restart numbering for each search term, tool call, skill, or display adapter.
- Use only the system-provided merged reference numbers in the final answer, formatted as `[1]`, `[2]`, `[1][3][5]`.
- Do not output `<skill-display .../>` in RAG/reference mode. The frontend renders configured cards, tables, lists, and citation details in the right reference panel.
- If no display adapter exists, the frontend defaults the right reference panel to numbered citations.
</rag_citation_mode>
"""
<rag_citation_mode>
RAG/reference display mode is enabled.
When a search, knowledge, skill, or MCP tool returns a result list:
- If multiple search terms are needed, prefer calling the retrieval tools for all known terms in the same step, then answer after the tool results are returned.
- Treat every retrieval result from the same user turn as one combined `referenceBatch`; do not restart numbering for each search term, tool call, skill, or display adapter.
- Use only the system-provided merged reference numbers in the final answer, formatted as `[1]`, `[2]`, `[1][3][5]`.
- Do not output `<skill-display .../>` in RAG/reference mode. The frontend renders configured cards, tables, lists, and citation details in the right reference panel.
- If no display adapter exists, the frontend defaults the right reference panel to numbered citations.
</rag_citation_mode>
"""
if rag_mode_enabled
else ""
)
@@ -0,0 +1,129 @@
"""Approval middleware for the AI-writing config setup tool.
与 ``SetupAgentApprovalMiddleware`` 同构:模型在「写作配置 Q&A」里调用 ``setup_writing``
时,于 ``after_model`` 拦截,附上一条结构化审批 ToolMessage(建议的写作配置打包在
``additional_kwargs["setup_writing_approval"]``)并 ``jump_to="end"`` 立即结束本轮运行——
前端据此渲染审批卡,用户点「应用到表单」后把这些字段回填到左侧写作表单。
"""
from __future__ import annotations
import logging
from hashlib import sha256
from typing import Any, override
from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware, hook_config
from langchain_core.messages import AIMessage, ToolMessage
from langgraph.runtime import Runtime
logger = logging.getLogger(__name__)
class SetupWritingApprovalMiddlewareState(AgentState):
"""Compatible with the DeerFlow thread state schema."""
pass
class SetupWritingApprovalMiddleware(AgentMiddleware[SetupWritingApprovalMiddlewareState]):
"""Stop before ``setup_writing`` and ask the user to apply the proposed config.
See ``SetupAgentApprovalMiddleware`` for the rationale behind intercepting in
``after_model`` + ``jump_to="end"`` (robust against parallel tool calls).
"""
state_schema = SetupWritingApprovalMiddlewareState
def _is_approved(self, runtime: Runtime) -> bool:
context = getattr(runtime, "context", None) or {}
if not isinstance(context, dict):
return False
return context.get("setup_writing_approved") is True
def _stable_message_id(self, tool_call_id: str, args: dict[str, Any]) -> str:
if tool_call_id:
return f"setup-writing-approval:{tool_call_id}"
digest = sha256(repr(sorted(args.items())).encode("utf-8")).hexdigest()[:16]
return f"setup-writing-approval:{digest}"
def _coerce_word_count(self, raw: Any) -> int | None:
if isinstance(raw, bool): # bool 是 int 子类,先排除
return None
if isinstance(raw, int):
return raw if raw > 0 else None
if isinstance(raw, float):
return int(raw) if raw > 0 else None
if isinstance(raw, str):
import re
m = re.search(r"\d[\d,]*", raw)
if m:
try:
value = int(m.group().replace(",", ""))
return value if value > 0 else None
except ValueError:
return None
return None
def _build_payload(self, args: dict[str, Any]) -> dict[str, Any]:
return {
"user_intent": str(args.get("user_intent") or "").strip(),
"article_type": str(args.get("article_type") or "").strip(),
"word_count_target": self._coerce_word_count(args.get("word_count_target")),
"target_audience": str(args.get("target_audience") or "").strip(),
"writing_mode": str(args.get("writing_mode") or "").strip(),
"material_source": str(args.get("material_source") or "").strip(),
"outline": str(args.get("outline") or "").strip(),
}
def _intercept(self, last: AIMessage) -> dict[str, Any] | None:
"""Return a state update if setup_writing must be gated; None to passthrough."""
tool_calls = list(last.tool_calls or [])
setup_call = next((tc for tc in tool_calls if tc.get("name") == "setup_writing"), None)
if setup_call is None:
return None
args = setup_call.get("args") or {}
tool_call_id = setup_call.get("id") or ""
payload = self._build_payload(args)
logger.info("Setup writing approval requested before applying writing config")
approval_msg = ToolMessage(
id=self._stable_message_id(tool_call_id, args),
content="请确认是否把这份写作配置应用到表单。",
tool_call_id=tool_call_id,
name="setup_writing",
additional_kwargs={"setup_writing_approval": payload},
)
return {
"messages": [approval_msg],
"jump_to": "end",
}
@hook_config(can_jump_to=["end"])
@override
def after_model(
self,
state: SetupWritingApprovalMiddlewareState,
runtime: Runtime,
) -> dict[str, Any] | None:
if self._is_approved(runtime):
return None
messages = state.get("messages") or []
if not messages:
return None
last = messages[-1]
if not isinstance(last, AIMessage):
return None
return self._intercept(last)
@hook_config(can_jump_to=["end"])
@override
async def aafter_model(
self,
state: SetupWritingApprovalMiddlewareState,
runtime: Runtime,
) -> dict[str, Any] | None:
return self.after_model(state, runtime)
@@ -4,11 +4,13 @@ from .memory_tool import memory_tool
from .present_file_tool import present_file_tool
from .scheduled_task_tool import scheduled_task_tool
from .setup_agent_tool import setup_agent
from .setup_writing_tool import setup_writing
from .task_tool import task_tool
from .view_image_tool import view_image_tool
__all__ = [
"setup_agent",
"setup_writing",
"present_file_tool",
"ask_clarification_tool",
"agent_orchestration_tool",
@@ -0,0 +1,53 @@
"""写作配置助手用的提交工具。
与 ``setup_agent`` 同构:模型在「写作配置 Q&A」里与用户聊清需求后,调用本工具把建议的
写作配置「提交」出来。实际它总是被 ``SetupWritingApprovalMiddleware`` 在 ``after_model``
拦截(不真正执行)——拦截后把建议字段打包进审批 ToolMessage,前端据此回填左侧写作表单。
因此本工具的函数体基本是兜底:万一未被拦截(如审批被预先放行),也只回一句确认、不落任何盘。
"""
from __future__ import annotations
import logging
from langchain_core.messages import ToolMessage
from langchain_core.tools import tool
from langgraph.prebuilt import ToolRuntime
from langgraph.types import Command
logger = logging.getLogger(__name__)
@tool
def setup_writing(
user_intent: str,
runtime: ToolRuntime,
article_type: str | None = None,
word_count_target: int | None = None,
target_audience: str | None = None,
writing_mode: str | None = None,
material_source: str | None = None,
outline: str | None = None,
) -> Command:
"""把与用户聊清的写作配置提交给前端表单(不直接开始写作)。
Args:
user_intent: 用户的写作意图(精炼成一句话的写作目标)。
article_type: 文章类型的 key(须是系统给定的合法类型之一;不确定可留空)。
word_count_target: 目标字数(正整数)。
target_audience: 目标读者(如「行业专家」「普通读者」)。
writing_mode: 写作模式,'strict'(严格依据素材)或 'loose'(可结合通用知识)。
material_source: 素材来源,'knowledge_base'(知识库)或 'skill'(技能检索)。
outline: 若用户已提供大纲,原样填入其大纲文本(保留其层级与标题),否则留空。
"""
logger.info("[setup_writing] proposed writing config submitted (intercepted by approval middleware)")
return Command(
update={
"messages": [
ToolMessage(
content="写作配置已生成,请在右侧确认后应用到表单。",
tool_call_id=runtime.tool_call_id,
)
]
}
)