Merge branch 'dhk-1' of http://192.168.0.200/knowledge-base-bj/deerflow-server into dhk-1
This commit is contained in:
commit
5026bd1887
19 files changed
+327
-110
No files matched your search
@@ -26,7 +26,7 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
|
|||||||
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
|
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_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||||
# 我的笔记前端
|
# 我的笔记前端
|
||||||
VITE_AIPI_FRONTEND_URL= xxx
|
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||||
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
||||||
# MaxKey 鉴权(getTokenByOnlineTicket)
|
# MaxKey 鉴权(getTokenByOnlineTicket)
|
||||||
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
|
|||||||
VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
|
VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
|
||||||
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
|
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
|
||||||
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
|
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
|
||||||
|
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
|
||||||
|
|
||||||
# 可选:Canvas 独立 LangGraph 地址
|
# 可选:Canvas 独立 LangGraph 地址
|
||||||
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
# VITE_CANVAS_LANGGRAPH_BASE_URL=
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ export function RecentChatList() {
|
|||||||
className="block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
className="block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||||
href={pathOfThread(thread)}
|
href={pathOfThread(thread)}
|
||||||
>
|
>
|
||||||
{desensitize(titleOfThread(thread))}
|
{desensitize(titleOfThread(thread, t.pages.newChat))}
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
||||||
@@ -236,7 +236,7 @@ export function RecentChatList() {
|
|||||||
onSelect={() =>
|
onSelect={() =>
|
||||||
handleRenameClick(
|
handleRenameClick(
|
||||||
thread.thread_id,
|
thread.thread_id,
|
||||||
titleOfThread(thread),
|
titleOfThread(thread, t.pages.newChat),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -64,24 +64,50 @@ export async function loginByUsername(
|
|||||||
return response.json() as Promise<LoginByUsernameResponse>;
|
return response.json() as Promise<LoginByUsernameResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The upstream token issuer and the verifier (token_info_url) run on separate
|
||||||
|
// servers whose clocks can drift ~3-4s apart. A token verified right after it
|
||||||
|
// is issued can look "not yet valid / expired" to the verifier, which surfaces
|
||||||
|
// as a 401 here. That window closes on its own once real time advances past the
|
||||||
|
// skew, so we silently retry the exchange a couple of times before giving up —
|
||||||
|
// the LoginPage keeps showing its loading state, so the user never notices.
|
||||||
|
const TOKEN_LOGIN_MAX_ATTEMPTS = 3; // initial try + 2 retries → covers up to ~6s skew
|
||||||
|
const TOKEN_LOGIN_RETRY_DELAY_MS = 3000;
|
||||||
|
|
||||||
|
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
export async function loginByToken(
|
export async function loginByToken(
|
||||||
token: string,
|
token: string,
|
||||||
): Promise<LoginByUsernameResponse> {
|
): Promise<LoginByUsernameResponse> {
|
||||||
// Token-exchange login: the gateway resolves the upstream token to a username
|
// Token-exchange login: the gateway resolves the upstream token to a username
|
||||||
// and signs the matching local account in.
|
// and signs the matching local account in.
|
||||||
const response = await apiFetch(
|
const url = `${getBackendBaseURL()}/api/v1/auth/login/token?token=${encodeURIComponent(token)}`;
|
||||||
`${getBackendBaseURL()}/api/v1/auth/login/token?token=${encodeURIComponent(token)}`,
|
let lastError = "Login failed.";
|
||||||
{
|
|
||||||
|
for (let attempt = 1; attempt <= TOKEN_LOGIN_MAX_ATTEMPTS; attempt += 1) {
|
||||||
|
const response = await apiFetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
);
|
if (response.ok) {
|
||||||
if (!response.ok) {
|
return response.json() as Promise<LoginByUsernameResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
const error = await response.json().catch(() => null);
|
const error = await response.json().catch(() => null);
|
||||||
throw new Error(extractErrorMessage(error));
|
lastError = extractErrorMessage(error);
|
||||||
|
|
||||||
|
// Only 401 is plausibly the transient clock-skew case and worth waiting on.
|
||||||
|
// 403 (disabled), 429 (rate-limited), 500 (misconfigured) won't self-heal,
|
||||||
|
// so fail fast instead of stalling the user.
|
||||||
|
const isTransient = response.status === 401;
|
||||||
|
if (!isTransient || attempt === TOKEN_LOGIN_MAX_ATTEMPTS) {
|
||||||
|
throw new Error(lastError);
|
||||||
|
}
|
||||||
|
await wait(TOKEN_LOGIN_RETRY_DELAY_MS);
|
||||||
}
|
}
|
||||||
return response.json() as Promise<LoginByUsernameResponse>;
|
|
||||||
|
// Unreachable: the loop either returns or throws above.
|
||||||
|
throw new Error(lastError);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,8 +62,23 @@ export function textOfMessage(message: Message) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function titleOfThread(thread: AgentThread) {
|
export function titleOfThread(thread: AgentThread, fallback = "Untitled") {
|
||||||
return thread.values?.title ?? "Untitled";
|
const title = thread.values?.title;
|
||||||
|
if (title) return title;
|
||||||
|
|
||||||
|
// No title yet (e.g. the very first turn is still streaming, or the backend
|
||||||
|
// title pass hasn't finished). Derive a sensible display title from the first
|
||||||
|
// user message so the user never sees a bare "Untitled" placeholder.
|
||||||
|
const messages = thread.values?.messages;
|
||||||
|
if (Array.isArray(messages)) {
|
||||||
|
const firstHuman = messages.find((m) => m.type === "human");
|
||||||
|
const text = firstHuman ? textOfMessage(firstHuman)?.trim() : null;
|
||||||
|
if (text) {
|
||||||
|
return text.length > 50 ? `${text.slice(0, 50).trimEnd()}…` : text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sanitizeThreadTitleForDisplay(title: string | null | undefined) {
|
export function sanitizeThreadTitleForDisplay(title: string | null | undefined) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export default function ChatsPage() {
|
|||||||
<Link key={thread.thread_id} href={pathOfThread(thread)}>
|
<Link key={thread.thread_id} href={pathOfThread(thread)}>
|
||||||
<div className="flex flex-col gap-2 border-b p-4">
|
<div className="flex flex-col gap-2 border-b p-4">
|
||||||
<div>
|
<div>
|
||||||
<div>{desensitize(titleOfThread(thread))}</div>
|
<div>{desensitize(titleOfThread(thread, t.pages.newChat))}</div>
|
||||||
</div>
|
</div>
|
||||||
{thread.updated_at && (
|
{thread.updated_at && (
|
||||||
<div className="text-muted-foreground text-sm">
|
<div className="text-muted-foreground text-sm">
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ Lead-agent middlewares are assembled in strict append order across `packages/har
|
|||||||
9. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
|
9. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
|
||||||
10. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
10. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
||||||
11. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
|
11. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
|
||||||
12. **TitleMiddleware** - Auto-generates thread title after first complete exchange and normalizes structured message content before prompting the title model
|
12. **TitleMiddleware** - Two-phase auto-title (never shows "untitled"): `before_model` sets an instant question-derived *provisional* title the moment the first user message arrives (no LLM, zero latency), then `after_model` asynchronously generates the final LLM title and replaces it (tracked via the `title_provisional` state flag). Normalizes structured message content before prompting the title model
|
||||||
13. **MemoryMiddleware** - Memory V2: `wrap_model_call` injects Hindsight recall transiently; `after_agent` auto-retains the turn to Hindsight
|
13. **MemoryMiddleware** - Memory V2: `wrap_model_call` injects Hindsight recall transiently; `after_agent` auto-retains the turn to Hindsight
|
||||||
14. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
|
14. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
|
||||||
15. **DeferredToolFilterMiddleware** - Hides deferred tool schemas from the bound model until tool search is enabled (optional)
|
15. **DeferredToolFilterMiddleware** - Hides deferred tool schemas from the bound model until tool search is enabled (optional)
|
||||||
@@ -178,7 +178,7 @@ Lead-agent middlewares are assembled in strict append order across `packages/har
|
|||||||
10. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
|
10. **SummarizationMiddleware** - Context reduction when approaching token limits (optional, if enabled)
|
||||||
11. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
11. **TodoListMiddleware** - Task tracking with `write_todos` tool (optional, if plan_mode)
|
||||||
12. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
|
12. **TokenUsageMiddleware** - Records token usage metrics when token tracking is enabled (optional)
|
||||||
13. **TitleMiddleware** - Auto-generates thread title after first complete exchange and normalizes structured message content before prompting the title model
|
13. **TitleMiddleware** - Two-phase auto-title (never shows "untitled"): `before_model` sets an instant question-derived *provisional* title the moment the first user message arrives (no LLM, zero latency), then `after_model` asynchronously generates the final LLM title and replaces it (tracked via the `title_provisional` state flag). Normalizes structured message content before prompting the title model
|
||||||
14. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses)
|
14. **MemoryMiddleware** - Queues conversations for async memory update (filters to user + final AI responses)
|
||||||
15. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
|
15. **ViewImageMiddleware** - Injects base64 image data before LLM call (conditional on vision support)
|
||||||
16. **DeferredToolFilterMiddleware** - Hides deferred tool schemas from the bound model until tool search is enabled (optional)
|
16. **DeferredToolFilterMiddleware** - Hides deferred tool schemas from the bound model until tool search is enabled (optional)
|
||||||
|
|||||||
+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` 前,用**一句话**说明你将提交的配置要点;调用之后不要再输出任何文章内容。
|
||||||
|
|
||||||
|
|||||||
+4
@@ -67,6 +67,10 @@ class SetupAgentApprovalMiddleware(AgentMiddleware[SetupAgentApprovalMiddlewareS
|
|||||||
|
|
||||||
args = setup_call.get("args") or {}
|
args = setup_call.get("args") or {}
|
||||||
tool_call_id = setup_call.get("id") or ""
|
tool_call_id = setup_call.get("id") or ""
|
||||||
|
|
||||||
|
if isinstance(args.get("skills"), list) and len(args["skills"]) > 5:
|
||||||
|
args = {**args, "skills": args["skills"][:5]}
|
||||||
|
|
||||||
payload = self._build_payload(args)
|
payload = self._build_payload(args)
|
||||||
logger.info("Setup agent approval requested before writing agent files")
|
logger.info("Setup agent approval requested before writing agent files")
|
||||||
|
|
||||||
|
|||||||
+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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+50
-6
@@ -23,6 +23,7 @@ class TitleMiddlewareState(AgentState):
|
|||||||
"""Compatible with the `ThreadState` schema."""
|
"""Compatible with the `ThreadState` schema."""
|
||||||
|
|
||||||
title: NotRequired[str | None]
|
title: NotRequired[str | None]
|
||||||
|
title_provisional: NotRequired[bool]
|
||||||
|
|
||||||
|
|
||||||
class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
||||||
@@ -61,16 +62,37 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
|||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def _should_generate_title(self, state: TitleMiddlewareState) -> bool:
|
def _should_set_provisional_title(self, state: TitleMiddlewareState) -> bool:
|
||||||
"""Check if we should generate a title for this thread."""
|
"""Check if we should set an instant, question-derived placeholder title.
|
||||||
|
|
||||||
|
Runs in ``before_model`` so the thread shows a sensible title the moment
|
||||||
|
the user sends their first message — eliminating the "untitled" window
|
||||||
|
while the (possibly long) first answer streams. No LLM call, no latency.
|
||||||
|
"""
|
||||||
config = self._get_title_config()
|
config = self._get_title_config()
|
||||||
if not config.enabled:
|
if not config.enabled:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Check if thread already has a title in state
|
# Already has a title (provisional or final) — nothing to do.
|
||||||
if state.get("title"):
|
if state.get("title"):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Only on the very first turn, as soon as the user message arrives.
|
||||||
|
messages = state.get("messages", [])
|
||||||
|
user_messages = [m for m in messages if m.type == "human"]
|
||||||
|
return len(user_messages) == 1
|
||||||
|
|
||||||
|
def _should_generate_title(self, state: TitleMiddlewareState) -> bool:
|
||||||
|
"""Check if we should generate the final LLM title for this thread."""
|
||||||
|
config = self._get_title_config()
|
||||||
|
if not config.enabled:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Skip only if we already have a *finalized* title. A provisional
|
||||||
|
# placeholder set in before_model should still be upgraded by the LLM.
|
||||||
|
if state.get("title") and not state.get("title_provisional"):
|
||||||
|
return False
|
||||||
|
|
||||||
# Check if this is the first turn (has at least one user message and one assistant response)
|
# Check if this is the first turn (has at least one user message and one assistant response)
|
||||||
messages = state.get("messages", [])
|
messages = state.get("messages", [])
|
||||||
if len(messages) < 2:
|
if len(messages) < 2:
|
||||||
@@ -146,13 +168,27 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
|||||||
config["tags"] = [*(config.get("tags") or []), "middleware:title"]
|
config["tags"] = [*(config.get("tags") or []), "middleware:title"]
|
||||||
return config
|
return config
|
||||||
|
|
||||||
|
def _provisional_title_result(self, state: TitleMiddlewareState) -> dict | None:
|
||||||
|
"""Set an instant placeholder title from the first user message (no LLM)."""
|
||||||
|
if not self._should_set_provisional_title(state):
|
||||||
|
return None
|
||||||
|
|
||||||
|
messages = state.get("messages", [])
|
||||||
|
user_msg_content = next((m.content for m in messages if m.type == "human"), "")
|
||||||
|
user_msg = self._normalize_content(user_msg_content)
|
||||||
|
if not user_msg.strip():
|
||||||
|
return None
|
||||||
|
title = self._fallback_title(user_msg)
|
||||||
|
logger.debug("TitleMiddleware: set provisional title %r before first model call", title)
|
||||||
|
return {"title": title, "title_provisional": True}
|
||||||
|
|
||||||
def _generate_title_result(self, state: TitleMiddlewareState) -> dict | None:
|
def _generate_title_result(self, state: TitleMiddlewareState) -> dict | None:
|
||||||
"""Generate a local fallback title without blocking on an LLM call."""
|
"""Generate a local fallback title without blocking on an LLM call."""
|
||||||
if not self._should_generate_title(state):
|
if not self._should_generate_title(state):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
_, user_msg = self._build_title_prompt(state)
|
_, user_msg = self._build_title_prompt(state)
|
||||||
return {"title": self._fallback_title(user_msg)}
|
return {"title": self._fallback_title(user_msg), "title_provisional": False}
|
||||||
|
|
||||||
def _force_disable_thinking(self, model: object) -> None:
|
def _force_disable_thinking(self, model: object) -> None:
|
||||||
"""Force-disable thinking on the title model via `extra_body` overrides.
|
"""Force-disable thinking on the title model via `extra_body` overrides.
|
||||||
@@ -204,10 +240,18 @@ class TitleMiddleware(AgentMiddleware[TitleMiddlewareState]):
|
|||||||
response = await model.ainvoke(prompt, config=self._get_runnable_config())
|
response = await model.ainvoke(prompt, config=self._get_runnable_config())
|
||||||
title = self._parse_title(response.content)
|
title = self._parse_title(response.content)
|
||||||
if title:
|
if title:
|
||||||
return {"title": title}
|
return {"title": title, "title_provisional": False}
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to generate async title; falling back to local title", exc_info=True)
|
logger.debug("Failed to generate async title; falling back to local title", exc_info=True)
|
||||||
return {"title": self._fallback_title(user_msg)}
|
return {"title": self._fallback_title(user_msg), "title_provisional": False}
|
||||||
|
|
||||||
|
@override
|
||||||
|
def before_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None:
|
||||||
|
return self._provisional_title_result(state)
|
||||||
|
|
||||||
|
@override
|
||||||
|
async def abefore_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None:
|
||||||
|
return self._provisional_title_result(state)
|
||||||
|
|
||||||
@override
|
@override
|
||||||
def after_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None:
|
def after_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None:
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ class ThreadState(AgentState):
|
|||||||
sandbox: NotRequired[SandboxState | None]
|
sandbox: NotRequired[SandboxState | None]
|
||||||
thread_data: NotRequired[ThreadDataState | None]
|
thread_data: NotRequired[ThreadDataState | None]
|
||||||
title: NotRequired[str | None]
|
title: NotRequired[str | None]
|
||||||
|
# True while `title` is a provisional, question-derived placeholder that the
|
||||||
|
# async LLM pass has not yet finalized. Lets TitleMiddleware show a title
|
||||||
|
# instantly (no "untitled" window) and upgrade it once the answer is ready.
|
||||||
|
title_provisional: NotRequired[bool]
|
||||||
artifacts: Annotated[list[str], merge_artifacts]
|
artifacts: Annotated[list[str], merge_artifacts]
|
||||||
todos: NotRequired[list | None]
|
todos: NotRequired[list | None]
|
||||||
uploaded_files: NotRequired[list[dict] | None]
|
uploaded_files: NotRequired[list[dict] | None]
|
||||||
|
|||||||
+61
-12
@@ -128,6 +128,45 @@ def extract_json_object(text: str | None) -> dict[str, Any] | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def recover_html_document(text: str | None) -> str | None:
|
||||||
|
"""Slice a complete HTML document out of a model reply.
|
||||||
|
|
||||||
|
Used as a fallback when the JSON wrapper around the ``html`` field can't be
|
||||||
|
parsed — the model commonly emits *literal* newlines and *unescaped* quotes
|
||||||
|
(``"zh-CN"``) inside the value, which is invalid JSON, so
|
||||||
|
:func:`extract_json_object` returns ``None``. Without this, the caller would
|
||||||
|
archive the whole ``{"title": ..., "html": "<!doctype html>…"}`` blob as the
|
||||||
|
page. We locate the ``<!doctype html>`` / ``<html …>`` start and the final
|
||||||
|
``</html>`` and return just that span, dropping the surrounding JSON
|
||||||
|
scaffolding. Also accepts a reply that is already bare HTML. Returns
|
||||||
|
``None`` when no HTML document can be found.
|
||||||
|
"""
|
||||||
|
if not text or not isinstance(text, str):
|
||||||
|
return None
|
||||||
|
lowered = text.lower()
|
||||||
|
start = lowered.find("<!doctype html")
|
||||||
|
if start == -1:
|
||||||
|
start = lowered.find("<html")
|
||||||
|
if start == -1:
|
||||||
|
return None
|
||||||
|
end = lowered.rfind("</html>")
|
||||||
|
candidate = text[start:] if end == -1 else text[start : end + len("</html>")]
|
||||||
|
candidate = candidate.strip()
|
||||||
|
# If the html had been a properly-escaped JSON string value (the JSON failed
|
||||||
|
# to parse for some *other* reason), unescape the common sequences so they
|
||||||
|
# don't survive into the rendered page. Only triggers when escapes exist, so
|
||||||
|
# genuinely-literal HTML (the usual broken-JSON case) is left untouched.
|
||||||
|
if "\\n" in candidate or '\\"' in candidate or "\\/" in candidate or "\\t" in candidate:
|
||||||
|
candidate = (
|
||||||
|
candidate.replace('\\"', '"')
|
||||||
|
.replace("\\n", "\n")
|
||||||
|
.replace("\\r", "\r")
|
||||||
|
.replace("\\t", "\t")
|
||||||
|
.replace("\\/", "/")
|
||||||
|
)
|
||||||
|
return candidate or None
|
||||||
|
|
||||||
|
|
||||||
def sanitize_html(html: str) -> tuple[str, list[str]]:
|
def sanitize_html(html: str) -> tuple[str, list[str]]:
|
||||||
"""Remove script-execution vectors from ``html``.
|
"""Remove script-execution vectors from ``html``.
|
||||||
|
|
||||||
@@ -242,11 +281,7 @@ _GENERATION_TEMPLATE = """你是一个内网页面生成助手。请根据输入
|
|||||||
|
|
||||||
用户页面要求:
|
用户页面要求:
|
||||||
{page_prompt}
|
{page_prompt}
|
||||||
|
{constraint_block}本次数据:
|
||||||
限制条件:
|
|
||||||
{negative_prompt}
|
|
||||||
|
|
||||||
本次数据:
|
|
||||||
{data}
|
{data}
|
||||||
|
|
||||||
参考页面风格:
|
参考页面风格:
|
||||||
@@ -258,11 +293,12 @@ _GENERATION_TEMPLATE = """你是一个内网页面生成助手。请根据输入
|
|||||||
要求:
|
要求:
|
||||||
1. 只输出 JSON,不要输出 Markdown,不要输出多余解释。
|
1. 只输出 JSON,不要输出 Markdown,不要输出多余解释。
|
||||||
2. JSON 字段必须包含 title、html、style_summary、layout_summary。
|
2. JSON 字段必须包含 title、html、style_summary、layout_summary。
|
||||||
3. html 必须是完整 HTML 文档,包含 <!doctype html>、html、head、body。
|
3. html 字段的值必须是合法 JSON 字符串:其中所有换行必须写成 \\n、所有双引号必须转义为 \\",不要在字符串里直接换行或使用未转义的引号。
|
||||||
4. 所有 CSS 写在 <style> 标签内,不要引用外部 CDN 或外链样式表。
|
4. html 必须是完整 HTML 文档,包含 <!doctype html>、html、head、body。
|
||||||
5. 不要包含 <script>、外链脚本、iframe、表单提交、on* 事件属性。
|
5. 所有 CSS 写在 <style> 标签内,不要引用外部 CDN 或外链样式表。
|
||||||
6. 参考页面只用于风格和布局,页面内容必须来自“本次数据”,不要复用参考页旧数据。
|
6. 不要包含 <script>、外链脚本、iframe、表单提交、on* 事件属性。
|
||||||
7. 页面使用中文,排版适合在浏览器中直接预览。
|
7. 参考页面只用于风格和布局,页面内容必须来自“本次数据”,不要复用参考页旧数据。
|
||||||
|
8. 页面使用中文,排版适合在浏览器中直接预览。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_REPAIR_TEMPLATE = """你是一个 HTML 修复助手。下面的 HTML 存在问题,请修复后重新输出完整页面。
|
_REPAIR_TEMPLATE = """你是一个 HTML 修复助手。下面的 HTML 存在问题,请修复后重新输出完整页面。
|
||||||
@@ -312,14 +348,27 @@ def build_generation_prompt(
|
|||||||
reference_layout: str | None,
|
reference_layout: str | None,
|
||||||
skill_instructions: str | None = None,
|
skill_instructions: str | None = None,
|
||||||
data_limit: int = 16000,
|
data_limit: int = 16000,
|
||||||
skill_limit: int = 16000,
|
skill_limit: int = 30000,
|
||||||
) -> str:
|
) -> str:
|
||||||
skill_block = ""
|
skill_block = ""
|
||||||
if skill_instructions and skill_instructions.strip():
|
if skill_instructions and skill_instructions.strip():
|
||||||
skill_block = "\n可用页面生成技能规范(请严格遵循):\n" + _clip(skill_instructions.strip(), skill_limit) + "\n"
|
skill_block = "\n可用页面生成技能规范(请严格遵循):\n" + _clip(skill_instructions.strip(), skill_limit) + "\n"
|
||||||
|
# The constraint block is only emitted when the user actually supplied one,
|
||||||
|
# and is framed as a top-priority hard rule that overrides the page
|
||||||
|
# requirements / reference page / data on conflict — otherwise the model
|
||||||
|
# tends to treat "限制条件" as a soft hint and ignore it.
|
||||||
|
constraint_block = ""
|
||||||
|
neg = (negative_prompt or "").strip()
|
||||||
|
if neg:
|
||||||
|
constraint_block = (
|
||||||
|
"\n限制条件(最高优先级的硬性约束,必须逐条严格遵守;"
|
||||||
|
"若与上面的页面要求、参考页面或本次数据发生冲突,一律以这里的限制条件为准):\n"
|
||||||
|
+ neg
|
||||||
|
+ "\n\n"
|
||||||
|
)
|
||||||
return _GENERATION_TEMPLATE.format(
|
return _GENERATION_TEMPLATE.format(
|
||||||
page_prompt=(page_prompt or "(未提供,使用通用看板风格)").strip(),
|
page_prompt=(page_prompt or "(未提供,使用通用看板风格)").strip(),
|
||||||
negative_prompt=(negative_prompt or "无").strip(),
|
constraint_block=constraint_block,
|
||||||
data=_clip(data, data_limit),
|
data=_clip(data, data_limit),
|
||||||
reference_style=(reference_style or "无").strip(),
|
reference_style=(reference_style or "无").strip(),
|
||||||
reference_layout=(reference_layout or "无").strip(),
|
reference_layout=(reference_layout or "无").strip(),
|
||||||
|
|||||||
+14
-5
@@ -966,6 +966,7 @@ class ScheduledTaskService:
|
|||||||
"""
|
"""
|
||||||
cfg = self._html_config(task)
|
cfg = self._html_config(task)
|
||||||
page_prompt = str(cfg.get("page_prompt") or "").strip()
|
page_prompt = str(cfg.get("page_prompt") or "").strip()
|
||||||
|
negative_prompt = str(cfg.get("negative_prompt") or "").strip()
|
||||||
gather_instruction = (
|
gather_instruction = (
|
||||||
"请运用你自身的检索/搜索等技能,围绕下述页面需求获取最新、真实的资料,"
|
"请运用你自身的检索/搜索等技能,围绕下述页面需求获取最新、真实的资料,"
|
||||||
"并整理成结构化的中文要点(可用列表/小标题),供后续生成 HTML 页面使用,本步骤不要生成 HTML:\n"
|
"并整理成结构化的中文要点(可用列表/小标题),供后续生成 HTML 页面使用,本步骤不要生成 HTML:\n"
|
||||||
@@ -976,6 +977,11 @@ class ScheduledTaskService:
|
|||||||
base_prompt = str(task.get("prompt") or "").strip()
|
base_prompt = str(task.get("prompt") or "").strip()
|
||||||
if base_prompt:
|
if base_prompt:
|
||||||
parts.append(f"补充说明:{base_prompt}")
|
parts.append(f"补充说明:{base_prompt}")
|
||||||
|
# Apply the user's constraints to data gathering too, not just rendering
|
||||||
|
# — a "限制条件" like "只要最近 24 小时的新闻" or "排除某主题" must shape
|
||||||
|
# which sources/data the agent collects, otherwise it silently leaks in.
|
||||||
|
if negative_prompt:
|
||||||
|
parts.append(f"限制条件(最高优先级,必须严格遵守):{negative_prompt}")
|
||||||
gather_task = {**task, "prompt": "\n".join(parts)}
|
gather_task = {**task, "prompt": "\n".join(parts)}
|
||||||
# Never apply the markdown gate to the gather pass.
|
# Never apply the markdown gate to the gather pass.
|
||||||
gather_ctx = dict(task.get("execution_context") or {})
|
gather_ctx = dict(task.get("execution_context") or {})
|
||||||
@@ -1100,11 +1106,14 @@ class ScheduledTaskService:
|
|||||||
title = str(parsed.get("title") or task.get("name") or "生成页面").strip()
|
title = str(parsed.get("title") or task.get("name") or "生成页面").strip()
|
||||||
html = parsed.get("html")
|
html = parsed.get("html")
|
||||||
if not isinstance(html, str) or "<" not in html:
|
if not isinstance(html, str) or "<" not in html:
|
||||||
# GLM5 may have replied with bare HTML instead of JSON — accept it.
|
# The JSON wrapper couldn't be parsed (the model emitted literal
|
||||||
if isinstance(raw, str) and ("<html" in raw.lower() or "<!doctype" in raw.lower()):
|
# newlines / unescaped quotes inside the "html" value), or it replied
|
||||||
html = raw
|
# with bare HTML. Either way, recover just the HTML document so we
|
||||||
else:
|
# never archive the {"title":...,"html":"..."} JSON blob as the page.
|
||||||
raise ValueError("GLM5 未返回有效的 HTML(JSON 解析失败)")
|
recovered = html_page_utils.recover_html_document(raw)
|
||||||
|
if recovered is None:
|
||||||
|
raise ValueError("模型未返回有效的 HTML(JSON 解析失败)")
|
||||||
|
html = recovered
|
||||||
style_summary = str(parsed.get("style_summary") or "").strip() or None
|
style_summary = str(parsed.get("style_summary") or "").strip() or None
|
||||||
layout_summary = str(parsed.get("layout_summary") or "").strip() or None
|
layout_summary = str(parsed.get("layout_summary") or "").strip() or None
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -27,7 +27,7 @@ def setup_agent(
|
|||||||
description: One-line description of what the agent does.
|
description: One-line description of what the agent does.
|
||||||
name: Optional human-readable display name for the agent. If omitted,
|
name: Optional human-readable display name for the agent. If omitted,
|
||||||
the agent's stable id is used as the display name.
|
the agent's stable id is used as the display name.
|
||||||
skills: Optional list of skill names this agent should use. None or empty means no skills.
|
skills: Optional list of skill names this agent should use. None or empty means no skills. Maximum 5 skills allowed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
agent_name: str | None = (runtime.context.get("agent_id") or runtime.context.get("agent_name")) if runtime.context else None
|
agent_name: str | None = (runtime.context.get("agent_id") or runtime.context.get("agent_name")) if runtime.context else None
|
||||||
@@ -48,7 +48,7 @@ def setup_agent(
|
|||||||
if description:
|
if description:
|
||||||
config_data["description"] = description
|
config_data["description"] = description
|
||||||
if skills is not None:
|
if skills is not None:
|
||||||
config_data["skills"] = skills
|
config_data["skills"] = skills[:5]
|
||||||
|
|
||||||
config_file = agent_dir / "config.yaml"
|
config_file = agent_dir / "config.yaml"
|
||||||
with open(config_file, "w", encoding="utf-8") as f:
|
with open(config_file, "w", encoding="utf-8") as f:
|
||||||
|
|||||||
Reference in new issue
Block a user