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