Merge branch 'dhk-1' into 'master'

代码修改

See merge request knowledge-base-bj/deerflow-server!84
This commit is contained in:
董宏凯 committed 2026-06-03 10:57:34 +00:00
commit dbf1f46fa1
6 files changed
+134 -69

No files matched your search

@@ -400,6 +400,47 @@ export function ArtifactFileDetail({
}
}, [displayContent, filepath]);
const handleOpenWriteFileInNewTab = useCallback(() => {
const source = displayContent ?? "";
if (!source.trim()) {
toast.error("文件内容为空,暂时无法打开");
return;
}
const blobType =
language === "html"
? "text/html;charset=utf-8"
: language === "markdown"
? "text/markdown;charset=utf-8"
: "text/plain;charset=utf-8";
const blob = new Blob([source], { type: blobType });
const url = URL.createObjectURL(blob);
window.open(url, "_blank", "noopener,noreferrer");
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
}, [displayContent, language]);
const handleDownloadWriteFile = useCallback(() => {
const source = displayContent ?? "";
if (!source.trim()) {
toast.error("文件内容为空,暂时无法下载");
return;
}
const blobType =
language === "html"
? "text/html;charset=utf-8"
: language === "markdown"
? "text/markdown;charset=utf-8"
: "text/plain;charset=utf-8";
const blob = new Blob([source], { type: blobType });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = getFileName(filepath);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, [displayContent, filepath, language]);
return (
<Artifact className={cn(className)}>
<ArtifactHeader className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-3 px-4 py-3">
@@ -500,30 +541,32 @@ export function ArtifactFileDetail({
/>
</Tooltip>
)}
{!isWriteFile && (
<ArtifactAction
icon={SquareArrowOutUpRightIcon}
label={t.common.openInNewWindow}
tooltip={t.common.openInNewWindow}
onClick={async () => {
try {
await openArtifactInNewTab({
filepath,
threadId,
isMock,
publicShareCode,
});
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Failed to open artifact",
);
}
}}
/>
)}
<ArtifactAction
icon={SquareArrowOutUpRightIcon}
label={t.common.openInNewWindow}
tooltip={t.common.openInNewWindow}
onClick={async () => {
if (isWriteFile) {
handleOpenWriteFileInNewTab();
return;
}
try {
await openArtifactInNewTab({
filepath,
threadId,
isMock,
publicShareCode,
});
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Failed to open artifact",
);
}
}}
/>
{isCodeFile && (
<ArtifactAction
icon={CopyIcon}
@@ -550,30 +593,32 @@ export function ArtifactFileDetail({
onClick={handleDownloadAsWord}
/>
)}
{!isWriteFile && (
<ArtifactAction
icon={DownloadIcon}
label={t.common.download}
tooltip={t.common.download}
onClick={async () => {
try {
await downloadArtifactFile({
filepath,
threadId,
isMock,
publicShareCode,
});
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Download failed",
);
}
}}
/>
)}
<ArtifactAction
icon={DownloadIcon}
label={t.common.download}
tooltip={t.common.download}
onClick={async () => {
if (isWriteFile) {
handleDownloadWriteFile();
return;
}
try {
await downloadArtifactFile({
filepath,
threadId,
isMock,
publicShareCode,
});
} catch (error) {
console.error(error);
toast.error(
error instanceof Error
? error.message
: "Download failed",
);
}
}}
/>
<ArtifactAction
className="hover:bg-background"
icon={XIcon}
@@ -905,9 +905,14 @@ function ClarificationCard({
return;
}
if (!allowMultiple) {
const next = { selectedIds: [optionId], custom: "" };
// 单选:仅当「只有这一张待答澄清卡」时才点击即发送。多张澄清卡同时出现时,
// 点击只记录本卡选择,待最后一张的「全部发送」统一提交——否则点了第一张就立刻
// 提交整批,后面的卡片再也无法选择(见 bug:选了第一个,第二个不让选)。
const next = { selectedIds: [optionId], custom: selection.custom };
onSelectionChange(next);
void onSubmitAll(next);
if (!multiPending) {
void onSubmitAll(next);
}
return;
}
const next = selection.selectedIds.includes(optionId)
@@ -947,7 +952,11 @@ function ClarificationCard({
{options.length > 0 ? (
<div className="mt-4 grid gap-2">
<div className="text-muted-foreground text-xs">
{allowMultiple ? "可多选(再次点击取消选择)" : "请选择一项(点击即发送)"}
{allowMultiple
? "可多选(再次点击取消选择)"
: multiPending
? "请选择一项"
: "请选择一项(点击即发送)"}
</div>
{options.map((option) => {
const optionId = option.id || option.label;
@@ -956,7 +965,7 @@ function ClarificationCard({
<Button
key={optionId}
type="button"
variant={isSelected && allowMultiple ? "default" : "outline"}
variant={isSelected && (allowMultiple || multiPending) ? "default" : "outline"}
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
disabled={disabled}
onClick={() => handleOptionClick(optionId)}
@@ -976,7 +985,7 @@ function ClarificationCard({
</span>
) : null}
</span>
{isSelected && allowMultiple ? (
{isSelected && (allowMultiple || multiPending) ? (
<CheckIcon className="ml-2 h-4 w-4 shrink-0" />
) : null}
</Button>
@@ -1001,10 +1010,10 @@ function ClarificationCard({
</div>
) : null}
{isPending && (allowMultiple || payload.allow_custom !== false) ? (
{isPending && (allowMultiple || multiPending || payload.allow_custom !== false) ? (
isLastPending ? (
<div className="mt-3 flex items-center justify-end gap-2">
{multiPending && allowMultiple ? (
{multiPending ? (
<span className="text-muted-foreground text-xs">
将一起提交以上 {pendingCount} 个问题的回答
</span>
@@ -1016,10 +1025,10 @@ function ClarificationCard({
onClick={() => void onSubmitAll()}
>
<SendIcon className="h-4 w-4" />
{submitting ? "发送中…" : multiPending && allowMultiple ? "全部发送" : "发送回答"}
{submitting ? "发送中…" : multiPending ? "全部发送" : "发送回答"}
</Button>
</div>
) : allowMultiple ? (
) : allowMultiple || multiPending ? (
<div className="text-muted-foreground mt-3 text-right text-xs">
选择后等待最后一题统一提交
</div>
@@ -77,16 +77,24 @@ export function WritingSetupChat() {
setThreadPrepared(true);
}, [threadId, threadPrepared]);
// 生成中点提交按钮 = 暂停:取消当前 SSE 流(与主聊天页一致)。
const handleChatSubmit = useCallback(
async (text: string) => {
if (thread.isLoading) {
void thread.stop();
return;
}
const trimmed = text.trim();
if (!trimmed || thread.isLoading) return;
if (!trimmed) return;
await ensureThreadExists();
await sendMessage(threadId, { text: trimmed, files: [] });
},
[ensureThreadExists, sendMessage, thread.isLoading, threadId],
[thread, ensureThreadExists, sendMessage, threadId],
);
// 提交按钮状态:生成中显示「停止」方块,可点击暂停对话
const submitStatus = thread.error ? 'error' : thread.isLoading ? 'streaming' : 'ready';
const handleApprove = useCallback(
async (payload: SetupWritingApprovalPayload) => {
applyProposal(payload);
@@ -150,10 +158,9 @@ export function WritingSetupChat() {
<PromptInput onSubmit={({ text }) => void handleChatSubmit(text)}>
<PromptInputTextarea
placeholder="例如:帮我写一篇 2000 字、面向行业专家的新能源汽车出口分析"
disabled={thread.isLoading}
/>
<PromptInputFooter className="justify-end">
<PromptInputSubmit disabled={thread.isLoading} />
<PromptInputSubmit status={submitStatus} />
</PromptInputFooter>
</PromptInput>
</div>
@@ -377,7 +377,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, setup_writing
from deerflow.tools.builtins import ask_clarification_tool, setup_agent, setup_writing
cfg = _get_runtime_config(config)
resolved_app_config = app_config
@@ -464,16 +464,16 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
)
if is_writing_setup:
# 写作配置 Q&A:最小化 agent —— **只挂 setup_writing 一个工具** + 一份职责单一的独立提示。
# 写作配置 Q&A:最小化 agent —— 只挂 setup_writing(提交配置)+ ask_clarification(弹澄清卡)。
# 关键:绝不给全套工具、绝不复用 lead 超级体提示,否则模型会把「新能源汽车市场分析」之类
# 长得像任务的输入直接做掉,而不是转成写作配置提交。
# 末尾追加 WritingSetupRoundCapMiddleware:澄清最多 3 轮,到顶强制提交配置卡。
# 末尾追加 WritingSetupRoundCapMiddleware:澄清最多 3 轮,到顶用 tool_choice 强制提交配置卡。
from deerflow.agents.lead_agent.prompt import build_writing_setup_prompt
from deerflow.agents.middlewares.writing_setup_round_cap_middleware import WritingSetupRoundCapMiddleware
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config),
tools=[setup_writing],
tools=[setup_writing, ask_clarification_tool],
middleware=[
*_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
WritingSetupRoundCapMiddleware(max_rounds=WRITING_SETUP_MAX_ROUNDS),
@@ -924,11 +924,11 @@ def build_writing_setup_prompt(article_types: list[dict] | None = None) -> str:
【铁律——最高优先级】
- 你**绝对不撰写**文章正文、分析内容、大纲内容或任何成稿文字。即使用户的输入看起来就是一个写作任务(例如「新能源汽车市场分析」「帮我写一篇……」),你也**只把它当作"要配置的写作目标"**,绝不替用户动笔、绝不直接完成这篇写作。
- 你能做的动作只有两个:① 用自然语言向用户提问或确认;② 调用 `setup_writing` 工具。**除此之外你没有任何其它工具**,也不要假装调用别的工具、不要做检索、不要做研究。
- 你只有两个工具:① `ask_clarification`(向用户提问澄清,需要时用);② `setup_writing`(提交写作配置)。**没有任何其它工具**,不要做检索、不要做研究、不要假装调用别的工具。
- 不要在回复里输出文章段落、要点清单或大纲(用户主动粘贴的大纲除外——原样放进 `outline` 参数)。
【工作流程】(与用户交互确认,但最多 3 轮内必须提交)
1. 读用户输入,先用一两句话回应并和用户**确认写作意图**;就缺失或模糊的关键项(文章类型、目标字数、目标读者)提问澄清,一次最多问 1-2 个最关键的问题。
1. 读用户输入。当关键项(文章类型、目标字数、目标读者)缺失或模糊时,调用 `ask_clarification` 向用户提问——**优先给出 `options` 选项**让用户点选(例如目标字数给「800 / 1500 / 3000」,读者给「普通读者 / 行业专家 / 政策决策者」)。一次可就 1-2 个独立的关键项各发一个 ask_clarification。
2. 与用户的澄清来回**最多 2 次**。一旦关键信息明确、或已经问过 2 次,就**立即进入第 3 步提交**,缺失字段用合理默认值,绝不无休止追问。
3. 调用 `setup_writing` 提交配置:
- `user_intent`:精炼成一句话的写作目标(必填)。
@@ -936,7 +936,7 @@ def build_writing_setup_prompt(article_types: list[dict] | None = None) -> str:
- `word_count_target`:正整数;用户没明说就给合理默认(如 800)。
- `target_audience`:如「普通读者」「行业专家」;没提就填「普通读者」。
- `writing_mode`:用户强调「严格依据素材/不要编造」→ `strict`,否则 `loose`。
- `material_source`:用户提到用「技能/技能检索」→ `skill`,否则 `knowledge_base`。
- `material_source`:**默认 `knowledge_base`(知识库)**;只有当用户明确要求用「技能/技能检索」时才填 `skill`,否则一律 `knowledge_base`。
- `outline`:用户提供的大纲原文(保留其层级与标题),否则留空。
4. 调用 `setup_writing` 前,用**一句话**说明你将提交的配置要点;调用之后不要再输出任何文章内容。
@@ -67,13 +67,17 @@ class SetupWritingApprovalMiddleware(AgentMiddleware[SetupWritingApprovalMiddlew
return None
def _build_payload(self, args: dict[str, Any]) -> dict[str, Any]:
# 素材来源:仅当模型明确给出 "skill" 时才用技能检索;用户没明确要求就一律默认知识库。
material_source = (
"skill" if str(args.get("material_source") or "").strip() == "skill" else "knowledge_base"
)
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(),
"material_source": material_source,
"outline": str(args.get("outline") or "").strip(),
}