From e27321cb2369f8709110a7e4fa6b5b445a1ae1fc Mon Sep 17 00:00:00 2001 From: Administrator Date: Mon, 29 Jun 2026 14:40:26 +0800 Subject: [PATCH 01/16] feat: enhance public task markdown preview --- .../src/pages/PublicScheduledTaskPage.tsx | 164 +++++++++++++++++- 1 file changed, 161 insertions(+), 3 deletions(-) diff --git a/frontend-web/src/pages/PublicScheduledTaskPage.tsx b/frontend-web/src/pages/PublicScheduledTaskPage.tsx index 76ec60ec..79244de9 100644 --- a/frontend-web/src/pages/PublicScheduledTaskPage.tsx +++ b/frontend-web/src/pages/PublicScheduledTaskPage.tsx @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + BookOpen, Check, ChevronLeft, ChevronRight, @@ -46,7 +47,9 @@ import { getBackendBaseURL } from "@/core/config"; import { usePublicBackground } from "@/core/embed/background"; import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; import { reasoningPlugins } from "@/core/streamdown"; +import { createMarkdownAnchorResolver } from "@/core/utils/markdown"; import { cn } from "@/lib/utils"; +import "@/components/wiki/wiki.css"; type PublicTask = { task_id: string; @@ -264,6 +267,153 @@ function stripTimestampSuffix(title: string): string { return cleaned.trim() || title; } +function stripMarkdownToText(markdown: string): string { + return markdown + .replace(/```[\s\S]*?```/g, " ") + .replace(/~~~[\s\S]*?~~~/g, " ") + .replace(/!\[[^\]]*\]\([^)]+\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/^#{1,6}\s+/gm, "") + .replace(/^[>\-*+\d.)\s]+/gm, "") + .replace(/[`*_~|]/g, "") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function getMarkdownExcerpt(markdown: string): string { + const text = stripMarkdownToText(markdown); + if (!text) return ""; + return text.length > 180 ? `${text.slice(0, 180)}...` : text; +} + +function countReadableWords(markdown: string): number { + const text = stripMarkdownToText(markdown); + if (!text) return 0; + const cjk = text.match(/[\u4e00-\u9fff]/g)?.length ?? 0; + const words = text.replace(/[\u4e00-\u9fff]/g, " ").match(/[A-Za-z0-9]+/g)?.length ?? 0; + return cjk + words; +} + +function selectorForId(id: string): string { + const css = window.CSS; + if (css?.escape) return `#${css.escape(id)}`; + return `[id="${id.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`; +} + +function KnowledgeMarkdownView({ + content, + title, + subtitle, + className, +}: { + content: string; + title?: string; + subtitle?: string; + className?: string; + isLoading?: boolean; + remarkPlugins?: unknown; + rehypePlugins?: unknown; +}) { + const rootRef = useRef(null); + const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]); + const headings = useMemo( + () => anchorResolver.headings.filter((item) => item.level >= 1 && item.level <= 3), + [anchorResolver], + ); + const excerpt = useMemo(() => getMarkdownExcerpt(content), [content]); + const sectionCount = headings.length; + const wordCount = useMemo(() => countReadableWords(content), [content]); + + const scrollToHeading = useCallback((id: string) => { + const root = rootRef.current; + const target = + root?.querySelector(selectorForId(id)) ?? document.getElementById(id); + target?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, []); + + return ( +
+
+
+
+ + + 知识梳理 + + {subtitle && {subtitle}} +
+ {title && ( +

+ {title} +

+ )} + {excerpt && ( +

+ {excerpt} +

+ )} +
+ + {sectionCount || 1} 个章节 + + + 约 {wordCount} 字 + +
+
+
+ +
+
+ + +
+ ); +} + const BODY_TAB_VALUE = "__body__"; const HTML_TAB_VALUE = "__html__"; const RUN_LIST_COLLAPSED_WIDTH = 56; @@ -688,9 +838,10 @@ function PublicFullscreenPreviewContent({ preview }: { preview: PublicFullscreen } if (preview.mode === "markdown") { return ( - ) : ( - Date: Mon, 29 Jun 2026 14:40:26 +0800 Subject: [PATCH 02/16] feat: enhance public task markdown preview --- .../src/pages/PublicScheduledTaskPage.tsx | 164 +++++++++++++++++- 1 file changed, 161 insertions(+), 3 deletions(-) diff --git a/frontend-web/src/pages/PublicScheduledTaskPage.tsx b/frontend-web/src/pages/PublicScheduledTaskPage.tsx index 76ec60ec..79244de9 100644 --- a/frontend-web/src/pages/PublicScheduledTaskPage.tsx +++ b/frontend-web/src/pages/PublicScheduledTaskPage.tsx @@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { + BookOpen, Check, ChevronLeft, ChevronRight, @@ -46,7 +47,9 @@ import { getBackendBaseURL } from "@/core/config"; import { usePublicBackground } from "@/core/embed/background"; import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; import { reasoningPlugins } from "@/core/streamdown"; +import { createMarkdownAnchorResolver } from "@/core/utils/markdown"; import { cn } from "@/lib/utils"; +import "@/components/wiki/wiki.css"; type PublicTask = { task_id: string; @@ -264,6 +267,153 @@ function stripTimestampSuffix(title: string): string { return cleaned.trim() || title; } +function stripMarkdownToText(markdown: string): string { + return markdown + .replace(/```[\s\S]*?```/g, " ") + .replace(/~~~[\s\S]*?~~~/g, " ") + .replace(/!\[[^\]]*\]\([^)]+\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/^#{1,6}\s+/gm, "") + .replace(/^[>\-*+\d.)\s]+/gm, "") + .replace(/[`*_~|]/g, "") + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function getMarkdownExcerpt(markdown: string): string { + const text = stripMarkdownToText(markdown); + if (!text) return ""; + return text.length > 180 ? `${text.slice(0, 180)}...` : text; +} + +function countReadableWords(markdown: string): number { + const text = stripMarkdownToText(markdown); + if (!text) return 0; + const cjk = text.match(/[\u4e00-\u9fff]/g)?.length ?? 0; + const words = text.replace(/[\u4e00-\u9fff]/g, " ").match(/[A-Za-z0-9]+/g)?.length ?? 0; + return cjk + words; +} + +function selectorForId(id: string): string { + const css = window.CSS; + if (css?.escape) return `#${css.escape(id)}`; + return `[id="${id.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"]`; +} + +function KnowledgeMarkdownView({ + content, + title, + subtitle, + className, +}: { + content: string; + title?: string; + subtitle?: string; + className?: string; + isLoading?: boolean; + remarkPlugins?: unknown; + rehypePlugins?: unknown; +}) { + const rootRef = useRef(null); + const anchorResolver = useMemo(() => createMarkdownAnchorResolver(content), [content]); + const headings = useMemo( + () => anchorResolver.headings.filter((item) => item.level >= 1 && item.level <= 3), + [anchorResolver], + ); + const excerpt = useMemo(() => getMarkdownExcerpt(content), [content]); + const sectionCount = headings.length; + const wordCount = useMemo(() => countReadableWords(content), [content]); + + const scrollToHeading = useCallback((id: string) => { + const root = rootRef.current; + const target = + root?.querySelector(selectorForId(id)) ?? document.getElementById(id); + target?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, []); + + return ( +
+
+
+
+ + + 知识梳理 + + {subtitle && {subtitle}} +
+ {title && ( +

+ {title} +

+ )} + {excerpt && ( +

+ {excerpt} +

+ )} +
+ + {sectionCount || 1} 个章节 + + + 约 {wordCount} 字 + +
+
+
+ +
+
+ + +
+ ); +} + const BODY_TAB_VALUE = "__body__"; const HTML_TAB_VALUE = "__html__"; const RUN_LIST_COLLAPSED_WIDTH = 56; @@ -688,9 +838,10 @@ function PublicFullscreenPreviewContent({ preview }: { preview: PublicFullscreen } if (preview.mode === "markdown") { return ( - ) : ( - Date: Mon, 29 Jun 2026 15:09:27 +0800 Subject: [PATCH 03/16] feat: improve agent skill selection --- .../agents/skill-transfer-selector.tsx | 41 ++++++-- .../settings/skill-settings-page.tsx | 25 +++-- frontend-web/src/pages/NewAgentPage.tsx | 94 +++---------------- 3 files changed, 57 insertions(+), 103 deletions(-) diff --git a/frontend-web/src/components/workspace/agents/skill-transfer-selector.tsx b/frontend-web/src/components/workspace/agents/skill-transfer-selector.tsx index c9ba7372..f1ec5e02 100644 --- a/frontend-web/src/components/workspace/agents/skill-transfer-selector.tsx +++ b/frontend-web/src/components/workspace/agents/skill-transfer-selector.tsx @@ -45,11 +45,32 @@ function skillLabel(skill: Skill) { function matchesQuery(skill: Skill, q: string) { if (!q) return true; - return ( - skill.name.toLowerCase().includes(q) || - (skill.name_zh ?? "").toLowerCase().includes(q) || - (skill.description ?? "").toLowerCase().includes(q) - ); + const haystack = [ + skill.name, + skill.name_zh, + skill.description, + skill.detail, + skill.category, + skill.license, + skill.source, + skill.state, + skill.owner_username, + skill.created_at, + skill.last_activity_at, + skill.use_count != null ? `使用 ${skill.use_count}` : null, + skill.view_count != null ? `查看 ${skill.view_count}` : null, + skill.patch_count != null ? `变迁 ${skill.patch_count}` : null, + ...(skill.tags ?? []).flatMap((tag) => [ + tag.name, + tag.description, + tag.color, + tag.usage_count != null ? `标签使用 ${tag.usage_count}` : null, + ]), + ] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join(" ") + .toLowerCase(); + return haystack.includes(q); } export function SkillTransferSelector({ @@ -159,7 +180,7 @@ export function SkillTransferSelector({
{ setSearch(e.currentTarget.value); @@ -239,7 +260,7 @@ export function SkillTransferSelector({ {/* Two-column transfer panes */}
diff --git a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx index 67db57a4..4f6a3e7d 100644 --- a/frontend-web/src/components/workspace/settings/skill-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/skill-settings-page.tsx @@ -470,11 +470,11 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) { - @@ -497,11 +497,16 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) { 名称 Z-A - - } - /> - {publisher && ( -
+
+ } + /> + {!isLoading && !error && ( +
+ 共 {orderedSkills.length} 个技能 +
+ )} + {publisher && ( +
筛选发布者:
- {enabledSkills.length > 0 && ( -
- - setSkillSearch(e.currentTarget.value)} - className="h-8 pl-7 text-xs" - /> -
- )} -
- {isSkillsLoading ? ( -

技能加载中...

- ) : enabledSkills.length === 0 ? ( -

- 当前没有可用技能。 -

- ) : filteredSkills.length === 0 ? ( -

- 没有匹配的技能。 -

- ) : ( -
- {filteredSkills.map((skill) => { - const checked = selectedSkills.includes(skill.name); - return ( -
- - -
- ); - })} -
- )} -
+ Date: Mon, 29 Jun 2026 16:09:51 +0800 Subject: [PATCH 04/16] =?UTF-8?q?=E5=B9=B6=E5=8F=91=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/src/components/wiki/wiki.css | 109 ++++++++++++ frontend-web/src/core/system-settings/api.ts | 4 + frontend-web/src/core/threads/hooks.ts | 56 +++++-- .../pages/ModelConcurrencySettingsPage.tsx | 157 ++++++++++++------ .../src/pages/PublicScheduledTaskPage.tsx | 51 +++++- .../app/gateway/routers/system_settings.py | 35 +++- .../backend/app/gateway/services.py | 73 +++++++- .../deerflow/config/system_settings.py | 10 ++ .../harness/deerflow/runtime/runs/manager.py | 19 ++- .../runtime/runs/model_concurrency.py | 39 +++++ .../backend/tests/test_model_concurrency.py | 50 ++++++ 11 files changed, 530 insertions(+), 73 deletions(-) create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/model_concurrency.py diff --git a/frontend-web/src/components/wiki/wiki.css b/frontend-web/src/components/wiki/wiki.css index 1abca062..fe3b3965 100644 --- a/frontend-web/src/components/wiki/wiki.css +++ b/frontend-web/src/components/wiki/wiki.css @@ -111,6 +111,115 @@ border-color: #eef1f5; } +.public-report-markdown { + color: #334155; + font-feature-settings: "kern"; + text-rendering: optimizeLegibility; +} + +.public-report-markdown h1 { + font-size: 30px; + letter-spacing: 0; + margin-bottom: 24px; +} + +.public-report-markdown h2 { + font-size: 22px; + margin-top: 42px; + margin-bottom: 16px; + padding-bottom: 10px; +} + +.public-report-markdown h3 { + font-size: 18px; + margin-top: 30px; + margin-bottom: 12px; +} + +.public-report-markdown p, +.public-report-markdown li { + font-size: 16px; + line-height: 1.9; +} + +.public-report-markdown p { + margin-bottom: 16px; +} + +.public-report-markdown ul, +.public-report-markdown ol { + margin: 14px 0 18px; + padding-left: 26px; +} + +.public-report-markdown ul { + list-style-type: disc; +} + +.public-report-markdown ol { + list-style-type: decimal; +} + +.public-report-markdown li { + padding-left: 2px; +} + +.public-report-markdown code { + border-radius: 6px; + background: #f1f5f9; + padding: 2px 6px; + color: #be123c; + font-size: 0.92em; +} + +.public-report-markdown pre { + margin: 20px 0; + border-radius: 8px; + background: #0f172a; + padding: 16px; + overflow-x: auto; +} + +.public-report-markdown pre code { + background: transparent; + padding: 0; + color: inherit; +} + +.public-report-markdown table { + display: block; + overflow-x: auto; + border-radius: 8px; + border: 1px solid #e8ecf1; +} + +.public-report-markdown th, +.public-report-markdown td { + min-width: 120px; + vertical-align: top; +} + +.public-report-markdown blockquote { + margin: 20px 0; +} + +.dark .public-report-markdown { + color: #cbd5e1; +} + +.dark .public-report-markdown code { + background: #1e293b; + color: #f9a8d4; +} + +.dark .public-report-markdown pre { + background: #020617; +} + +.dark .public-report-markdown table { + border-color: #334155; +} + /* Anchor target offset so headings clear the floating "目录" pill. */ .wiki-markdown h1, .wiki-markdown h2, diff --git a/frontend-web/src/core/system-settings/api.ts b/frontend-web/src/core/system-settings/api.ts index dacfa808..cb3ebfcc 100644 --- a/frontend-web/src/core/system-settings/api.ts +++ b/frontend-web/src/core/system-settings/api.ts @@ -449,12 +449,16 @@ export interface ModelConcurrencySettings { enabled: boolean; default_limit: number; model_limits: Record; + user_default_limit: number; + user_model_limits: Record; } export interface ModelConcurrencyUpdate { enabled?: boolean; default_limit?: number; model_limits?: Record; + user_default_limit?: number; + user_model_limits?: Record; } export async function loadSkillCompressionSettings(): Promise { diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index 14c9a942..9ef72004 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -263,11 +263,24 @@ function isModelConcurrencyLimitError(message: string): boolean { return ( /\b429\b/.test(message) || /并发已达到上限/.test(message) || + /并发调用已达到上限/.test(message) || /使用人数较多/.test(message) || /too many requests/i.test(message) ); } +function friendlySendLimitMessage(message: string): string | null { + if (isActiveRunConflictError(message)) { + return "当前对话还在生成回答,请等待本轮回复结束,或先点“停止”再发送新的问题。"; + } + if (isModelConcurrencyLimitError(message)) { + return /当前账号对该模型/.test(message) + ? "当前账号对该模型的并发调用已达到上限,请等待已有回答结束后再试。" + : "当前使用人数较多,请稍等一下再试。"; + } + return null; +} + function buildCurrentUserLabel(): string { const auth = getStoredAuth(); if (!auth) { @@ -604,14 +617,11 @@ export function useThreadStream({ } return; } - // 上一轮还没结束又发了新问题(后端 409):给一句明确的中文提示,而不是 - // 把原始的英文/HTTP 错误抛给用户。 - if (isActiveRunConflictError(message)) { - toast.info("当前对话还在生成回答,请等待本轮回复结束,或先点「停止」再发送新的问题。"); - return; - } - if (isModelConcurrencyLimitError(message)) { - toast.info("当前使用人数较多,请稍等一下再试。"); + // 上一轮还没结束又发了新问题(409)或并发达到上限(429): + // 给一句明确的中文提示,而不是把原始 HTTP 错误抛给用户。 + const friendly = friendlySendLimitMessage(message); + if (friendly) { + toast.info(friendly); return; } toast.error(message); @@ -1008,11 +1018,31 @@ export function useThreadStream({ }, ); void queryClient.invalidateQueries({ queryKey: ["threads", "search"] }); - } catch (error) { - setOptimisticMessages([]); - setIsUploading(false); - throw error; - } finally { + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + const friendly = friendlySendLimitMessage(message); + if (friendly) { + setOptimisticMessages((messages) => { + const human = messages.find((item) => item.type === "human"); + const kept = human ? [human] : []; + return [ + ...kept, + { + type: "ai", + id: `opt-ai-limit-${Date.now()}`, + content: friendly, + additional_kwargs: { element: "task" }, + } as Message, + ]; + }); + setIsUploading(false); + return; + } + setOptimisticMessages([]); + setIsUploading(false); + throw error; + } finally { sendInFlightRef.current = false; } }, diff --git a/frontend-web/src/pages/ModelConcurrencySettingsPage.tsx b/frontend-web/src/pages/ModelConcurrencySettingsPage.tsx index d8c59569..ed1a5d88 100644 --- a/frontend-web/src/pages/ModelConcurrencySettingsPage.tsx +++ b/frontend-web/src/pages/ModelConcurrencySettingsPage.tsx @@ -13,6 +13,21 @@ import { useUpdateModelConcurrencySettings, } from "@/core/system-settings/hooks"; +function cleanLimitMap(values: Record, label: string): Record | null { + const cleaned: Record = {}; + for (const [name, rawValue] of Object.entries(values)) { + const trimmed = rawValue.trim(); + if (!trimmed) continue; + const parsed = Math.round(Number(trimmed)); + if (!Number.isFinite(parsed) || parsed < 1) { + toast.error(`${label}「${name}」必须是大于 0 的整数`); + return null; + } + cleaned[name] = Math.min(parsed, 10000); + } + return cleaned; +} + export default function ModelConcurrencySettingsPage({ embedded = false, }: { @@ -24,13 +39,16 @@ export default function ModelConcurrencySettingsPage({ const [enabled, setEnabled] = useState(false); const [defaultLimit, setDefaultLimit] = useState(10); + const [userDefaultLimit, setUserDefaultLimit] = useState(0); const [modelLimits, setModelLimits] = useState>({}); + const [userModelLimits, setUserModelLimits] = useState>({}); const initializedRef = useRef(false); useEffect(() => { if (!data || initializedRef.current) return; setEnabled(data.enabled); setDefaultLimit(data.default_limit); + setUserDefaultLimit(data.user_default_limit ?? 0); setModelLimits( Object.fromEntries( Object.entries(data.model_limits ?? {}).map(([name, limit]) => [ @@ -39,39 +57,45 @@ export default function ModelConcurrencySettingsPage({ ]), ), ); + setUserModelLimits( + Object.fromEntries( + Object.entries(data.user_model_limits ?? {}).map(([name, limit]) => [ + name, + String(limit), + ]), + ), + ); initializedRef.current = true; }, [data]); const modelNames = useMemo(() => models.map((model) => model.name), [models]); - const handleModelLimitChange = (modelName: string, value: string) => { - setModelLimits((prev) => ({ ...prev, [modelName]: value })); - }; - const handleSave = async () => { const nextDefault = Math.max(1, Math.min(Math.round(defaultLimit) || 10, 10000)); - const cleaned: Record = {}; - for (const [name, rawValue] of Object.entries(modelLimits)) { - const trimmed = rawValue.trim(); - if (!trimmed) continue; - const parsed = Math.round(Number(trimmed)); - if (!Number.isFinite(parsed) || parsed < 1) { - toast.error(`模型 ${name} 的并发上限必须是大于 0 的整数`); - return; - } - cleaned[name] = Math.min(parsed, 10000); - } + const nextUserDefault = Math.max(0, Math.min(Math.round(userDefaultLimit) || 0, 10000)); + const cleanedModelLimits = cleanLimitMap(modelLimits, "模型总上限"); + if (!cleanedModelLimits) return; + const cleanedUserModelLimits = cleanLimitMap(userModelLimits, "单用户上限"); + if (!cleanedUserModelLimits) return; try { await update.mutateAsync({ enabled, default_limit: nextDefault, - model_limits: cleaned, + model_limits: cleanedModelLimits, + user_default_limit: nextUserDefault, + user_model_limits: cleanedUserModelLimits, }); setDefaultLimit(nextDefault); + setUserDefaultLimit(nextUserDefault); setModelLimits( Object.fromEntries( - Object.entries(cleaned).map(([name, limit]) => [name, String(limit)]), + Object.entries(cleanedModelLimits).map(([name, limit]) => [name, String(limit)]), + ), + ); + setUserModelLimits( + Object.fromEntries( + Object.entries(cleanedUserModelLimits).map(([name, limit]) => [name, String(limit)]), ), ); toast.success("已保存"); @@ -85,7 +109,7 @@ export default function ModelConcurrencySettingsPage({ className={ embedded ? "flex w-full flex-col gap-6" - : "mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col gap-6 overflow-y-auto px-6 py-10" + : "mx-auto flex h-full min-h-0 w-full max-w-4xl flex-col gap-6 overflow-y-auto px-6 py-10" } >
@@ -93,7 +117,7 @@ export default function ModelConcurrencySettingsPage({ 模型并发限制

- 按模型限制同时进行中的问答数。达到上限后,新请求会收到“当前使用人数较多,请稍等一下再试”的提示。 + 按模型控制系统总并发,也可以限制单个用户对某个模型同时发起的调用数。达到上限时会在发送问题后立即提示,问题会保留在对话中。

@@ -102,42 +126,59 @@ export default function ModelConcurrencySettingsPage({ ) : error ? ( -
+
加载失败:{error.message}
) : ( -
+
开启限制
- 关闭后,不再按模型并发数拦截问答请求。 + 关闭后,不再按模型或用户并发数拦截问答请求。
-
-

默认上限

-

- 未单独配置的模型,使用这个并发上限。 -

- setDefaultLimit(Number(e.target.value))} - className="w-32" - /> +
+
+

模型默认总上限

+

+ 未单独配置的模型,使用这个系统总并发上限。 +

+ setDefaultLimit(Number(e.target.value))} + className="w-32" + /> +
+
+

单用户默认上限

+

+ 同一用户对同一模型的默认并发上限。填 0 表示不启用默认用户限制。 +

+ setUserDefaultLimit(Number(e.target.value))} + className="w-32" + /> +

按模型单独设置

- 留空表示使用默认上限。 + 留空表示使用对应默认值;单用户上限填空且默认值为 0 时,不限制该维度。

@@ -150,24 +191,42 @@ export default function ModelConcurrencySettingsPage({ modelNames.map((modelName) => (
{modelName}
- 留空时使用默认上限 {defaultLimit || 10} + 默认总上限 {defaultLimit || 10};默认单用户上限 {userDefaultLimit || "不限制"}
- handleModelLimitChange(modelName, e.target.value)} - className="w-32" - placeholder={String(defaultLimit || 10)} - /> + +
)) )} diff --git a/frontend-web/src/pages/PublicScheduledTaskPage.tsx b/frontend-web/src/pages/PublicScheduledTaskPage.tsx index 79244de9..190493e1 100644 --- a/frontend-web/src/pages/PublicScheduledTaskPage.tsx +++ b/frontend-web/src/pages/PublicScheduledTaskPage.tsx @@ -321,14 +321,49 @@ function KnowledgeMarkdownView({ () => anchorResolver.headings.filter((item) => item.level >= 1 && item.level <= 3), [anchorResolver], ); + const [activeHeadingId, setActiveHeadingId] = useState(null); const excerpt = useMemo(() => getMarkdownExcerpt(content), [content]); const sectionCount = headings.length; const wordCount = useMemo(() => countReadableWords(content), [content]); + useEffect(() => { + setActiveHeadingId(headings[0]?.id ?? null); + }, [headings]); + + useEffect(() => { + const root = rootRef.current; + if (!root || headings.length === 0) return; + + const targets = headings + .map((item) => root.querySelector(selectorForId(item.id))) + .filter((item): item is HTMLElement => !!item); + if (targets.length === 0) return; + + const observer = new IntersectionObserver( + (entries) => { + const visible = entries + .filter((entry) => entry.isIntersecting) + .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]; + if (visible?.target.id) { + setActiveHeadingId(visible.target.id); + } + }, + { + root: root.closest("[data-radix-scroll-area-viewport]") ?? null, + rootMargin: "-96px 0px -65% 0px", + threshold: [0, 1], + }, + ); + + targets.forEach((target) => observer.observe(target)); + return () => observer.disconnect(); + }, [headings, content]); + const scrollToHeading = useCallback((id: string) => { const root = rootRef.current; const target = root?.querySelector(selectorForId(id)) ?? document.getElementById(id); + setActiveHeadingId(id); target?.scrollIntoView({ behavior: "smooth", block: "start" }); }, []); @@ -336,12 +371,12 @@ function KnowledgeMarkdownView({
-
-
+
+
@@ -368,7 +403,7 @@ function KnowledgeMarkdownView({
-
+
+
+ ); +} diff --git a/frontend-web/src/components/wiki/wiki.css b/frontend-web/src/components/wiki/wiki.css index fe3b3965..5c510c4e 100644 --- a/frontend-web/src/components/wiki/wiki.css +++ b/frontend-web/src/components/wiki/wiki.css @@ -220,6 +220,231 @@ border-color: #334155; } +.report-markdown-shell { + align-items: start; + padding: 2px; +} + +.report-markdown-paper { + border: 1px solid rgba(203, 213, 225, 0.85); + border-radius: 8px; + background: + linear-gradient(180deg, rgba(248, 250, 252, 0.92), rgba(255, 255, 255, 0) 220px), + #ffffff; + box-shadow: + 0 1px 2px rgba(15, 23, 42, 0.04), + 0 18px 44px rgba(15, 23, 42, 0.07); +} + +.report-markdown-header { + border-bottom: 1px solid #e8edf4; + padding: 26px 34px 22px; +} + +.report-markdown-header-compact { + padding-bottom: 18px; +} + +.report-markdown-badge { + gap: 6px; + border: 1px solid #dbe3ef; + background: #ffffff; + color: #475569; +} + +.report-markdown-title { + margin: 14px 0 0; + color: #0f172a; + font-size: 26px; + font-weight: 700; + line-height: 1.35; + letter-spacing: 0; +} + +.report-markdown-excerpt { + margin: 14px 0 0; + max-width: 840px; + color: #64748b; + font-size: 14px; + line-height: 1.8; +} + +.report-markdown-metrics { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 16px; + color: #64748b; + font-size: 12px; +} + +.report-markdown-metrics span { + border: 1px solid #e2e8f0; + border-radius: 6px; + background: rgba(255, 255, 255, 0.78); + padding: 4px 8px; +} + +.report-markdown-body { + max-width: 920px; + margin: 0 auto; + padding: 32px 42px 44px; +} + +.report-markdown-body h1 { + font-size: 28px; +} + +.report-markdown-body h2 { + position: relative; + margin-top: 44px; + border-bottom-color: #e5edf7; + font-size: 22px; +} + +.report-markdown-body h2::before { + content: ""; + position: absolute; + left: -16px; + top: 0.34em; + width: 4px; + height: 1.05em; + border-radius: 999px; + background: #2563eb; +} + +.report-markdown-body h3 { + color: #1e293b; + font-size: 18px; +} + +.report-markdown-body p, +.report-markdown-body li { + font-size: 16px; + line-height: 1.92; +} + +.report-markdown-body blockquote { + border-left-color: #2563eb; + background: #f8fbff; +} + +.report-markdown-body table { + box-shadow: 0 1px 0 rgba(15, 23, 42, 0.04); +} + +.report-markdown-toc { + position: sticky; + top: 16px; + max-height: calc(100vh - 32px); + overflow: hidden; + border: 1px solid rgba(203, 213, 225, 0.82); + border-radius: 8px; + background: rgba(255, 255, 255, 0.92); + padding: 14px; + box-shadow: 0 12px 34px rgba(15, 23, 42, 0.06); + backdrop-filter: blur(10px); +} + +.report-markdown-toc-list { + max-height: calc(100vh - 128px); + overflow: auto; + padding-right: 4px; +} + +.report-markdown-toc-item { + display: block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + border-left: 2px solid transparent; + border-radius: 6px; + padding: 7px 8px; + color: #64748b; + font-size: 12px; + line-height: 1.4; + text-align: left; + transition: background 160ms ease, border-color 160ms ease, color 160ms ease; +} + +.report-markdown-toc-item:hover { + background: #f1f5f9; + color: #0f172a; +} + +.report-markdown-toc-item-active { + border-left-color: #2563eb; + background: #eff6ff; + color: #1d4ed8; + font-weight: 600; +} + +.report-markdown-toc-level-2 { + padding-left: 18px; +} + +.report-markdown-toc-level-3 { + padding-left: 30px; + color: #94a3b8; +} + +.dark .report-markdown-paper { + border-color: #1f2937; + background: + linear-gradient(180deg, rgba(15, 23, 42, 0.9), rgba(2, 6, 23, 0) 220px), + #020617; + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.24); +} + +.dark .report-markdown-header { + border-bottom-color: #1e293b; +} + +.dark .report-markdown-badge, +.dark .report-markdown-metrics span { + border-color: #334155; + background: #0f172a; + color: #cbd5e1; +} + +.dark .report-markdown-title { + color: #f8fafc; +} + +.dark .report-markdown-excerpt, +.dark .report-markdown-metrics { + color: #94a3b8; +} + +.dark .report-markdown-body h3 { + color: #e2e8f0; +} + +.dark .report-markdown-body h2::before { + background: #60a5fa; +} + +.dark .report-markdown-body blockquote { + background: #0f172a; + border-left-color: #60a5fa; +} + +.dark .report-markdown-toc { + border-color: #1f2937; + background: rgba(2, 6, 23, 0.88); +} + +.dark .report-markdown-toc-item:hover { + background: #0f172a; + color: #f8fafc; +} + +.dark .report-markdown-toc-item-active { + background: rgba(37, 99, 235, 0.16); + color: #93c5fd; +} + /* Anchor target offset so headings clear the floating "目录" pill. */ .wiki-markdown h1, .wiki-markdown h2, diff --git a/frontend-web/src/pages/PublicScheduledTaskPage.tsx b/frontend-web/src/pages/PublicScheduledTaskPage.tsx index 190493e1..fdd5de51 100644 --- a/frontend-web/src/pages/PublicScheduledTaskPage.tsx +++ b/frontend-web/src/pages/PublicScheduledTaskPage.tsx @@ -24,6 +24,11 @@ import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; import { Dialog, DialogContent, @@ -43,13 +48,13 @@ import { } from "@/components/ui/tooltip"; import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; import { HtmlPreviewFrame } from "@/components/html-preview-frame"; +import { ReportMarkdownView } from "@/components/wiki/ReportMarkdownView"; import { getBackendBaseURL } from "@/core/config"; import { usePublicBackground } from "@/core/embed/background"; import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; import { reasoningPlugins } from "@/core/streamdown"; import { createMarkdownAnchorResolver } from "@/core/utils/markdown"; import { cn } from "@/lib/utils"; -import "@/components/wiki/wiki.css"; type PublicTask = { task_id: string; @@ -875,13 +880,10 @@ function PublicFullscreenPreviewContent({ preview }: { preview: PublicFullscreen } if (preview.mode === "markdown") { return ( - ); } @@ -889,13 +891,10 @@ function PublicFullscreenPreviewContent({ preview }: { preview: PublicFullscreen const content = preview.file.content || "无文件内容"; if (isMarkdownFile(preview.file)) { return ( - ); } @@ -920,6 +919,7 @@ function publicFullscreenHref(taskId: string, runId: string, tab: ContentTab): s function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) { const queryClient = useQueryClient(); const [fullscreenPreview, setFullscreenPreview] = useState(null); + const [runMetaOpen, setRunMetaOpen] = useState(false); // HTML page run: render a sandboxed iframe preview instead of the MD tabs. const isHtmlRun = @@ -1159,7 +1159,29 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) { )} -

+ + + + + +

+
计划:{formatDate(run.scheduled_for)}
+
开始:{formatDate(run.started_at)}
+
完成:{formatDate(run.finished_at)}
+
+ + +

开始:{formatDate(run.started_at)} · 完成:{formatDate(run.finished_at)}

@@ -1311,7 +1333,7 @@ function RunDetailPane({ run, taskId }: { run: PublicRun; taskId: string }) { 无内容。点击「编辑」开始添加。
) : ( - )} diff --git a/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx b/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx index 9002fbcb..125c2252 100644 --- a/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx +++ b/frontend-web/src/pages/ScheduledTaskRunDetailPage.tsx @@ -20,6 +20,7 @@ import { } from "@/components/workspace/workspace-container"; import { MarkdownContent } from "@/components/workspace/messages/markdown-content"; import { HtmlPreviewFrame } from "@/components/html-preview-frame"; +import { ReportMarkdownView } from "@/components/wiki/ReportMarkdownView"; import { downloadScheduledTaskRun, downloadScheduledTaskRunFile, @@ -91,11 +92,12 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) { } if (isMarkdownFile(file)) { return ( - ); } @@ -114,11 +116,11 @@ function FullscreenPreviewContent({ preview }: { preview: FullscreenPreview }) { } if (preview.mode === "markdown") { return ( - ); } @@ -653,11 +655,12 @@ export default function ScheduledTaskRunDetailPage() {
{result.format === "markdown" || result.format === "mixed" ? ( - ) : (

From be06cac2235dc610540d7819951c478b5277273b Mon Sep 17 00:00:00 2001
From: Rocky Kwok 
Date: Mon, 29 Jun 2026 17:12:18 +0800
Subject: [PATCH 07/16] fix: reset code

---
 .../workspace/agents/agent-selector.tsx       | 68 +++++++++----------
 .../plugin-management/domain-agent.test.mjs   | 22 ------
 frontend-web/src/pages/IframeChatPage.tsx     | 61 +++++------------
 3 files changed, 50 insertions(+), 101 deletions(-)

diff --git a/frontend-web/src/components/workspace/agents/agent-selector.tsx b/frontend-web/src/components/workspace/agents/agent-selector.tsx
index 9c031ac2..67afc60c 100644
--- a/frontend-web/src/components/workspace/agents/agent-selector.tsx
+++ b/frontend-web/src/components/workspace/agents/agent-selector.tsx
@@ -20,7 +20,6 @@ import {
 } from "@/components/ui/dialog";
 import { PromptInputButton } from "@/components/ai-elements/prompt-input";
 import { AgentManageDialog } from "@/components/workspace/agents/agent-manage-dialog";
-import { Tooltip } from "@/components/workspace/tooltip";
 import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
 import type { Agent } from "@/core/agents";
 import { getStoredAuth } from "@/core/auth";
@@ -87,7 +86,6 @@ export function AgentSelector({
   };
 
   const triggerLabel = selectedAgent ? desensitize(selectedAgent.name) : "选择 Agent";
-  const iconTooltipLabel = selectedAgent ? triggerLabel : "未选择";
   const iconMode = triggerVariant === "icon";
 
   return (
@@ -95,40 +93,38 @@ export function AgentSelector({
       
         
           {iconMode ? (
-            
-              
-            
+            
           ) : (
              {
-  assert.deepEqual(
-    resolveIncomingIframeSelection({
-      pageUrl: "https://another-unknown.example.com/article",
-      mappings: [
-        {
-          id: "m1",
-          domain: "foo.example.com",
-          agentId: "agent-1",
-          enabled: true,
-        },
-      ],
-      availableAgentIds: ["agent-1", "agent-2"],
-      manualSelectedAgentId: null,
-    }),
-    {
-      selectedAgentId: null,
-      forceDefaultAssistant: true,
-    },
-  );
 });
\ No newline at end of file
diff --git a/frontend-web/src/pages/IframeChatPage.tsx b/frontend-web/src/pages/IframeChatPage.tsx
index 6a6aadae..e1088dfe 100644
--- a/frontend-web/src/pages/IframeChatPage.tsx
+++ b/frontend-web/src/pages/IframeChatPage.tsx
@@ -83,13 +83,6 @@ type BrainyChatInterruptMessage = {
   };
   requestId?: string;
 };
-type BrainyUrlContextMessage = {
-  source?: string;
-  type?: string;
-  payload?: {
-    url?: string;
-  };
-};
 type PendingIframeSubmit = {
   message: PromptInputMessage;
   externalContext?: Record;
@@ -436,33 +429,9 @@ export default function IframeChatPage() {
     [thread],
   );
 
-  const applyIncomingUrlSelection = useCallback(
-    (pageUrl: string | null | undefined) => {
-      const nextSelection = resolveIncomingIframeSelection({
-        pageUrl,
-        mappings: pluginManagementSettings?.domainAgentMappings ?? [],
-        availableAgentIds: selectorLoading
-          ? undefined
-          : selectorAgents.map((agent) => agent.id),
-        manualSelectedAgentId,
-      });
-      setSelectedAgentId(nextSelection.selectedAgentId);
-      setForceDefaultAssistant(nextSelection.forceDefaultAssistant);
-      return nextSelection;
-    },
-    [
-      manualSelectedAgentId,
-      pluginManagementSettings?.domainAgentMappings,
-      selectorAgents,
-      selectorLoading,
-    ],
-  );
-
   useEffect(() => {
     const handleMessage = (
-      event: MessageEvent<
-        BrainyChatSubmitMessage | BrainyChatInterruptMessage | BrainyUrlContextMessage
-      >,
+      event: MessageEvent,
     ) => {
       const data = event.data;
       if (data?.source !== "brainy-extension") return;
@@ -470,12 +439,6 @@ export default function IframeChatPage() {
         void handleInterrupt(data as BrainyChatInterruptMessage);
         return;
       }
-      if (data?.type === "brainy:context-url") {
-        applyIncomingUrlSelection(
-          (data as BrainyUrlContextMessage).payload?.url ?? null,
-        );
-        return;
-      }
       if (data?.type !== "brainy:chat-submit") return;
       const submitMessage = data as BrainyChatSubmitMessage;
       const { submitText, files, iframeContext, additionalKwargs } =
@@ -483,6 +446,17 @@ export default function IframeChatPage() {
 
       if (!submitText && files.length === 0) return;
 
+      const nextSelection = resolveIncomingIframeSelection({
+        pageUrl: submitMessage.payload?.pageContext?.url,
+        mappings: pluginManagementSettings?.domainAgentMappings ?? [],
+        availableAgentIds: selectorLoading
+          ? undefined
+          : selectorAgents.map((agent) => agent.id),
+        manualSelectedAgentId,
+      });
+      setSelectedAgentId(nextSelection.selectedAgentId);
+      setForceDefaultAssistant(nextSelection.forceDefaultAssistant);
+
       console.log("收到插件消息", {
         text: submitMessage.payload?.text,
         prompt: submitMessage.payload?.prompt,
@@ -499,8 +473,8 @@ export default function IframeChatPage() {
         message: { text: submitText, files },
         externalContext: iframeContext,
         options: additionalKwargs ? { additionalKwargs } : undefined,
-        targetSelectedAgentId: selectedAgentId,
-        targetForceDefaultAssistant: forceDefaultAssistant,
+        targetSelectedAgentId: nextSelection.selectedAgentId,
+        targetForceDefaultAssistant: nextSelection.forceDefaultAssistant,
       });
     };
 
@@ -509,10 +483,11 @@ export default function IframeChatPage() {
       window.removeEventListener("message", handleMessage);
     };
   }, [
-    applyIncomingUrlSelection,
-    forceDefaultAssistant,
     handleInterrupt,
-    selectedAgentId,
+    manualSelectedAgentId,
+    pluginManagementSettings?.domainAgentMappings,
+    selectorAgents,
+    selectorLoading,
   ]);
 
   const isLandingState = isNewThread && !hasStartedConversation;

From 794aab10078b7ab35bee25d193353c73c304562d Mon Sep 17 00:00:00 2001
From: Rocky Kwok 
Date: Mon, 29 Jun 2026 17:14:01 +0800
Subject: [PATCH 08/16] fix: recode

---
 .../workspace/agents/agent-selector.tsx       | 68 +++++++++--------
 .../plugin-management/domain-agent.test.mjs   | 22 ++++++
 frontend-web/src/pages/IframeChatPage.tsx     | 73 +++++++++++++------
 3 files changed, 107 insertions(+), 56 deletions(-)

diff --git a/frontend-web/src/components/workspace/agents/agent-selector.tsx b/frontend-web/src/components/workspace/agents/agent-selector.tsx
index 67afc60c..9c031ac2 100644
--- a/frontend-web/src/components/workspace/agents/agent-selector.tsx
+++ b/frontend-web/src/components/workspace/agents/agent-selector.tsx
@@ -20,6 +20,7 @@ import {
 } from "@/components/ui/dialog";
 import { PromptInputButton } from "@/components/ai-elements/prompt-input";
 import { AgentManageDialog } from "@/components/workspace/agents/agent-manage-dialog";
+import { Tooltip } from "@/components/workspace/tooltip";
 import { useAgentSelector, useAgentsPaginated } from "@/core/agents";
 import type { Agent } from "@/core/agents";
 import { getStoredAuth } from "@/core/auth";
@@ -86,6 +87,7 @@ export function AgentSelector({
   };
 
   const triggerLabel = selectedAgent ? desensitize(selectedAgent.name) : "选择 Agent";
+  const iconTooltipLabel = selectedAgent ? triggerLabel : "未选择";
   const iconMode = triggerVariant === "icon";
 
   return (
@@ -93,38 +95,40 @@ export function AgentSelector({
       
         
           {iconMode ? (
-            
+            
+              
+            
           ) : (
              {
+  assert.deepEqual(
+    resolveIncomingIframeSelection({
+      pageUrl: "https://another-unknown.example.com/article",
+      mappings: [
+        {
+          id: "m1",
+          domain: "foo.example.com",
+          agentId: "agent-1",
+          enabled: true,
+        },
+      ],
+      availableAgentIds: ["agent-1", "agent-2"],
+      manualSelectedAgentId: null,
+    }),
+    {
+      selectedAgentId: null,
+      forceDefaultAssistant: true,
+    },
+  );
 });
\ No newline at end of file
diff --git a/frontend-web/src/pages/IframeChatPage.tsx b/frontend-web/src/pages/IframeChatPage.tsx
index e1088dfe..6a6aadae 100644
--- a/frontend-web/src/pages/IframeChatPage.tsx
+++ b/frontend-web/src/pages/IframeChatPage.tsx
@@ -83,6 +83,13 @@ type BrainyChatInterruptMessage = {
   };
   requestId?: string;
 };
+type BrainyUrlContextMessage = {
+  source?: string;
+  type?: string;
+  payload?: {
+    url?: string;
+  };
+};
 type PendingIframeSubmit = {
   message: PromptInputMessage;
   externalContext?: Record;
@@ -429,25 +436,10 @@ export default function IframeChatPage() {
     [thread],
   );
 
-  useEffect(() => {
-    const handleMessage = (
-      event: MessageEvent,
-    ) => {
-      const data = event.data;
-      if (data?.source !== "brainy-extension") return;
-      if (data?.type === "brainy:chat-interrupt") {
-        void handleInterrupt(data as BrainyChatInterruptMessage);
-        return;
-      }
-      if (data?.type !== "brainy:chat-submit") return;
-      const submitMessage = data as BrainyChatSubmitMessage;
-      const { submitText, files, iframeContext, additionalKwargs } =
-        parseBrainyChatSubmitMessage(submitMessage);
-
-      if (!submitText && files.length === 0) return;
-
+  const applyIncomingUrlSelection = useCallback(
+    (pageUrl: string | null | undefined) => {
       const nextSelection = resolveIncomingIframeSelection({
-        pageUrl: submitMessage.payload?.pageContext?.url,
+        pageUrl,
         mappings: pluginManagementSettings?.domainAgentMappings ?? [],
         availableAgentIds: selectorLoading
           ? undefined
@@ -456,6 +448,40 @@ export default function IframeChatPage() {
       });
       setSelectedAgentId(nextSelection.selectedAgentId);
       setForceDefaultAssistant(nextSelection.forceDefaultAssistant);
+      return nextSelection;
+    },
+    [
+      manualSelectedAgentId,
+      pluginManagementSettings?.domainAgentMappings,
+      selectorAgents,
+      selectorLoading,
+    ],
+  );
+
+  useEffect(() => {
+    const handleMessage = (
+      event: MessageEvent<
+        BrainyChatSubmitMessage | BrainyChatInterruptMessage | BrainyUrlContextMessage
+      >,
+    ) => {
+      const data = event.data;
+      if (data?.source !== "brainy-extension") return;
+      if (data?.type === "brainy:chat-interrupt") {
+        void handleInterrupt(data as BrainyChatInterruptMessage);
+        return;
+      }
+      if (data?.type === "brainy:context-url") {
+        applyIncomingUrlSelection(
+          (data as BrainyUrlContextMessage).payload?.url ?? null,
+        );
+        return;
+      }
+      if (data?.type !== "brainy:chat-submit") return;
+      const submitMessage = data as BrainyChatSubmitMessage;
+      const { submitText, files, iframeContext, additionalKwargs } =
+        parseBrainyChatSubmitMessage(submitMessage);
+
+      if (!submitText && files.length === 0) return;
 
       console.log("收到插件消息", {
         text: submitMessage.payload?.text,
@@ -473,8 +499,8 @@ export default function IframeChatPage() {
         message: { text: submitText, files },
         externalContext: iframeContext,
         options: additionalKwargs ? { additionalKwargs } : undefined,
-        targetSelectedAgentId: nextSelection.selectedAgentId,
-        targetForceDefaultAssistant: nextSelection.forceDefaultAssistant,
+        targetSelectedAgentId: selectedAgentId,
+        targetForceDefaultAssistant: forceDefaultAssistant,
       });
     };
 
@@ -483,11 +509,10 @@ export default function IframeChatPage() {
       window.removeEventListener("message", handleMessage);
     };
   }, [
+    applyIncomingUrlSelection,
+    forceDefaultAssistant,
     handleInterrupt,
-    manualSelectedAgentId,
-    pluginManagementSettings?.domainAgentMappings,
-    selectorAgents,
-    selectorLoading,
+    selectedAgentId,
   ]);
 
   const isLandingState = isNewThread && !hasStartedConversation;

From 5a420189b1e5c2ea187eb577c5e3d72ca72f002a Mon Sep 17 00:00:00 2001
From: Rocky Kwok 
Date: Mon, 29 Jun 2026 17:35:36 +0800
Subject: [PATCH 09/16] =?UTF-8?q?fix:=20iframe=E5=BA=95=E9=83=A8=E9=80=89?=
 =?UTF-8?q?=E6=8B=A9=E6=99=BA=E8=83=BD=E4=BD=93?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../workspace/agents/agent-selector.tsx       | 84 ++++++++++---------
 1 file changed, 44 insertions(+), 40 deletions(-)

diff --git a/frontend-web/src/components/workspace/agents/agent-selector.tsx b/frontend-web/src/components/workspace/agents/agent-selector.tsx
index 9c031ac2..0f954f10 100644
--- a/frontend-web/src/components/workspace/agents/agent-selector.tsx
+++ b/frontend-web/src/components/workspace/agents/agent-selector.tsx
@@ -93,43 +93,47 @@ export function AgentSelector({
   return (
     <>
       
-        
-          {iconMode ? (
-            
-              
-            
-          ) : (
+        {iconMode ? (
+          
+            
+ + + +
+
+ ) : ( + )} - )} - +
+ )} 选择 Agent @@ -235,4 +239,4 @@ export function AgentSelector({ )} ); -} \ No newline at end of file +} From b4206d6421e3088746224e03026560a973a40258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Tue, 30 Jun 2026 11:25:44 +0800 Subject: [PATCH 10/16] Add Redis-backed concurrency gate --- .../backend/app/gateway/app.py | 20 +- .../backend/app/gateway/deps.py | 19 +- .../app/gateway/routers/admin_active_runs.py | 47 ++- .../backend/app/gateway/services.py | 154 ++++++-- offline-backend-20260512/backend/config.yaml | 20 + .../harness/deerflow/config/app_config.py | 10 +- .../deerflow/config/concurrency_config.py | 35 ++ .../harness/deerflow/runtime/__init__.py | 10 + .../harness/deerflow/runtime/runs/__init__.py | 6 + .../deerflow/runtime/runs/concurrency_gate.py | 369 ++++++++++++++++++ .../harness/deerflow/runtime/runs/manager.py | 3 +- .../backend/packages/harness/pyproject.toml | 7 +- 12 files changed, 654 insertions(+), 46 deletions(-) create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/config/concurrency_config.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/concurrency_gate.py diff --git a/offline-backend-20260512/backend/app/gateway/app.py b/offline-backend-20260512/backend/app/gateway/app.py index fe20b1ce..ff972426 100644 --- a/offline-backend-20260512/backend/app/gateway/app.py +++ b/offline-backend-20260512/backend/app/gateway/app.py @@ -853,8 +853,9 @@ async def _concurrency_sampler_loop(app: FastAPI) -> None: logger.info("Concurrency sampler disabled by CONCURRENCY_SAMPLE_ENABLED") return - store = getattr(app.state, "concurrency_sample_store", None) - run_mgr = getattr(app.state, "run_manager", None) + store = getattr(app.state, "concurrency_sample_store", None) + run_mgr = getattr(app.state, "run_manager", None) + concurrency_gate = getattr(app.state, "concurrency_gate", None) if store is None or run_mgr is None: logger.info("Concurrency sampler not started (no store / run manager — memory backend?)") return @@ -875,11 +876,16 @@ async def _concurrency_sampler_loop(app: FastAPI) -> None: try: # Runtime toggle (admin can turn sampling off without a restart). # mtime-cached read, so this is cheap every tick. - from deerflow.config.system_settings import get_concurrency_monitor_settings - - if get_concurrency_monitor_settings().enabled: - active = await run_mgr.list_active() - await store.record(len(active)) + from deerflow.config.system_settings import get_concurrency_monitor_settings + + if get_concurrency_monitor_settings().enabled: + active_count = None + if concurrency_gate is not None: + active_count = await concurrency_gate.count_active() + if active_count is None: + active = await run_mgr.list_active() + active_count = len(active) + await store.record(active_count) ticks += 1 if ticks % purge_every_ticks == 0: from datetime import UTC, datetime, timedelta diff --git a/offline-backend-20260512/backend/app/gateway/deps.py b/offline-backend-20260512/backend/app/gateway/deps.py index 49db1366..fd158208 100644 --- a/offline-backend-20260512/backend/app/gateway/deps.py +++ b/offline-backend-20260512/backend/app/gateway/deps.py @@ -367,8 +367,11 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: run_events_config = getattr(config, "run_events", None) app.state.run_event_store = make_run_event_store(run_events_config) - # RunManager with store backing for persistence - app.state.run_manager = RunManager(store=app.state.run_store) + # RunManager with store backing for persistence + app.state.run_manager = RunManager(store=app.state.run_store) + from deerflow.runtime import make_redis_concurrency_gate + + app.state.concurrency_gate = make_redis_concurrency_gate(config) try: yield @@ -382,7 +385,10 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]: await queue.stop() set_auto_ingest_queue(None) - await close_engine() + await close_engine() + gate = getattr(app.state, "concurrency_gate", None) + if gate is not None: + await gate.aclose() # --------------------------------------------------------------------------- @@ -408,7 +414,12 @@ get_run_manager: Callable[[Request], RunManager] = _require("run_manager", "Run get_checkpointer: Callable[[Request], Checkpointer] = _require("checkpointer", "Checkpointer") get_run_event_store: Callable[[Request], RunEventStore] = _require("run_event_store", "Run event store") get_feedback_repo: Callable[[Request], FeedbackRepository] = _require("feedback_repo", "Feedback") -get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store") +get_run_store: Callable[[Request], RunStore] = _require("run_store", "Run store") + + +def get_concurrency_gate(request: Request): + """Return the optional cross-worker concurrency gate.""" + return getattr(request.app.state, "concurrency_gate", None) def get_store(request: Request): diff --git a/offline-backend-20260512/backend/app/gateway/routers/admin_active_runs.py b/offline-backend-20260512/backend/app/gateway/routers/admin_active_runs.py index 352f0af9..a602a398 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/admin_active_runs.py +++ b/offline-backend-20260512/backend/app/gateway/routers/admin_active_runs.py @@ -22,7 +22,7 @@ from fastapi import APIRouter, Query, Request from pydantic import BaseModel from sqlalchemy import select -from app.gateway.deps import get_concurrency_sample_store, get_run_manager +from app.gateway.deps import get_concurrency_gate, get_concurrency_sample_store, get_run_manager from app.gateway.routers.admin_users import _require_admin from deerflow.config.system_settings import load_system_settings, save_system_settings from deerflow.persistence.agents.model import AgentRow @@ -149,6 +149,15 @@ def _elapsed_seconds(created_at: str, now: datetime) -> int: return max(0, int(delta)) +def _elapsed_seconds_from_epoch(created_at: float, now: datetime) -> int: + try: + if created_at <= 0: + return 0 + return max(0, int(now.timestamp() - float(created_at))) + except Exception: + return 0 + + def _kind_for(metadata: dict | None) -> str: """Coarse human label for the run's origin, from its thread metadata.""" meta = metadata or {} @@ -218,14 +227,46 @@ async def list_active_runs(request: Request) -> ActiveRunsResponse: """List every in-flight run with who/what is driving it (admin only).""" await _require_admin(request) run_mgr = get_run_manager(request) - records = await run_mgr.list_active() + gate = get_concurrency_gate(request) + leases = await gate.list_active() if gate is not None else None + records = [] if leases is not None else await run_mgr.list_active() now = datetime.now(UTC) - maps = await _enrich(records) + enrich_source = leases if leases is not None else records + maps = await _enrich(enrich_source) thread_meta = maps["thread_meta"] user_emails = maps["user_emails"] agent_names = maps["agent_names"] items: list[ActiveRunItem] = [] + if leases is not None: + for r in leases: + meta_row = thread_meta.get(r.thread_id) + user_id = r.user_id or (meta_row.user_id if meta_row else "") or "" + metadata = (meta_row.metadata_json if meta_row else None) or {} + assistant_id = r.assistant_id or "" + if assistant_id in _DEFAULT_ASSISTANT_IDS: + agent_name = "榛樿鍔╂墜" + else: + agent_name = agent_names.get(assistant_id) or assistant_id + items.append( + ActiveRunItem( + run_id=r.run_id, + thread_id=r.thread_id, + status="running", + created_at=datetime.fromtimestamp(r.created_at, tz=UTC).isoformat() if r.created_at else "", + elapsed_seconds=_elapsed_seconds_from_epoch(r.created_at, now), + user_id=user_id, + user_name=user_emails.get(user_id) or user_id or "鏈煡鐢ㄦ埛", + agent_id=assistant_id, + agent_name=agent_name, + model_name=r.model_name, + thread_title=(meta_row.display_name if meta_row else "") or "", + kind=_kind_for(metadata), + multitask_strategy="reject", + ) + ) + return ActiveRunsResponse(count=len(items), server_time=now.isoformat(), runs=items) + for r in records: meta_row = thread_meta.get(r.thread_id) user_id = (meta_row.user_id if meta_row else "") or "" diff --git a/offline-backend-20260512/backend/app/gateway/services.py b/offline-backend-20260512/backend/app/gateway/services.py index 090cb654..8331a565 100644 --- a/offline-backend-20260512/backend/app/gateway/services.py +++ b/offline-backend-20260512/backend/app/gateway/services.py @@ -12,13 +12,14 @@ import json import logging import re import time +import uuid from collections.abc import Mapping from typing import Any from fastapi import HTTPException, Request from langchain_core.messages import HumanMessage -from app.gateway.deps import get_run_context, get_run_manager, get_stream_bridge +from app.gateway.deps import get_concurrency_gate, get_run_context, get_run_manager, get_stream_bridge from app.gateway.utils import sanitize_log_param from deerflow.config import get_app_config from deerflow.config.agents_config import load_agent_config, validate_agent_id @@ -41,6 +42,45 @@ from deerflow.runtime.user_context import get_effective_user_id logger = logging.getLogger(__name__) +async def _run_agent_with_concurrency_lease( + *, + concurrency_gate: Any | None, + lease_acquired: bool, + lease_metadata: dict[str, str], + heartbeat_interval_seconds: int, + run_agent_kwargs: dict[str, Any], +) -> None: + """Run the agent while refreshing and finally releasing an optional lease.""" + + heartbeat_task: asyncio.Task | None = None + + async def _heartbeat_loop() -> None: + if concurrency_gate is None or not lease_acquired: + return + interval = max(5, int(heartbeat_interval_seconds or 20)) + while True: + await asyncio.sleep(interval) + await concurrency_gate.heartbeat(**lease_metadata) + + if concurrency_gate is not None and lease_acquired: + heartbeat_task = asyncio.create_task( + _heartbeat_loop(), + name=f"concurrency-heartbeat:{lease_metadata.get('run_id')}", + ) + + try: + await run_agent(**run_agent_kwargs) + finally: + if heartbeat_task is not None: + heartbeat_task.cancel() + try: + await heartbeat_task + except asyncio.CancelledError: + pass + if concurrency_gate is not None and lease_acquired: + await concurrency_gate.release(**lease_metadata) + + # --------------------------------------------------------------------------- # SSE formatting # --------------------------------------------------------------------------- @@ -737,6 +777,7 @@ async def start_run( bridge = get_stream_bridge(request) run_mgr = get_run_manager(request) run_ctx = get_run_context(request) + concurrency_gate = get_concurrency_gate(request) disconnect = DisconnectMode.cancel if body.on_disconnect == "cancel" else DisconnectMode.continue_ @@ -754,11 +795,46 @@ async def start_run( effective_user_id = get_effective_user_id() model_concurrency_limit = _get_model_concurrency_limit(effective_model_name) user_model_concurrency_limit = _get_user_model_concurrency_limit(effective_model_name) + run_id = str(uuid.uuid4()) run_metadata = dict(body.metadata or {}) run_metadata["model_name"] = effective_model_name run_metadata["user_id"] = effective_user_id + redis_lease_acquired = False + redis_gate_degraded = True + lease_metadata = { + "run_id": run_id, + "thread_id": thread_id, + "model_name": effective_model_name, + "user_id": effective_user_id, + } + if concurrency_gate is not None: + acquire = await concurrency_gate.try_acquire( + run_id=run_id, + thread_id=thread_id, + model_name=effective_model_name, + user_id=effective_user_id, + assistant_id=body.assistant_id or "", + model_limit=model_concurrency_limit, + user_model_limit=user_model_concurrency_limit, + reject_thread=body.multitask_strategy == "reject", + ) + redis_lease_acquired = acquire.acquired + redis_gate_degraded = acquire.degraded + if acquire.rejected: + if acquire.reason == "thread": + raise HTTPException( + status_code=409, + detail="Current thread already has an active run. Please wait or stop it before sending another message.", + ) + if acquire.reason == "user_model": + raise HTTPException( + status_code=429, + detail="This account has reached the concurrency limit for the selected model. Please try again later.", + ) + raise HTTPException(status_code=429, detail="The selected model is busy. Please try again later.") + try: record = await run_mgr.create_or_reject( thread_id, @@ -767,24 +843,35 @@ async def start_run( metadata=run_metadata, kwargs={"input": body.input, "config": body.config}, multitask_strategy=body.multitask_strategy, - model_concurrency_limit=(effective_model_name, model_concurrency_limit) if model_concurrency_limit is not None else None, - user_model_concurrency_limit=(effective_model_name, effective_user_id, user_model_concurrency_limit) if user_model_concurrency_limit is not None else None, + model_concurrency_limit=(effective_model_name, model_concurrency_limit) if redis_gate_degraded and model_concurrency_limit is not None else None, + user_model_concurrency_limit=(effective_model_name, effective_user_id, user_model_concurrency_limit) if redis_gate_degraded and user_model_concurrency_limit is not None else None, + run_id=run_id, ) except ModelConcurrencyLimitError as exc: + if concurrency_gate is not None and redis_lease_acquired: + await concurrency_gate.release(**lease_metadata) if getattr(exc, "scope", "") == "user_model": - detail = "当前账号对该模型的并发调用已达到上限,请等待已有回答结束后再试。" + detail = "This account has reached the concurrency limit for the selected model. Please try again later." else: - detail = "当前使用人数较多,请稍等一下再试。" + detail = "The selected model is busy. Please try again later." raise HTTPException(status_code=429, detail=detail) from exc except ConflictError as exc: + if concurrency_gate is not None and redis_lease_acquired: + await concurrency_gate.release(**lease_metadata) # 同一会话上一轮回答还没结束,又提交了新问题 → 拒绝。给用户中文提示, # 而不是把内部英文「Thread ... already has an active run」直接抛到前端。 raise HTTPException( status_code=409, - detail="当前对话还在生成回答,请等待本轮回复结束,或先点「停止」再发送新的问题。", + detail="Current thread already has an active run. Please wait or stop it before sending another message.", ) from exc except UnsupportedStrategyError as exc: + if concurrency_gate is not None and redis_lease_acquired: + await concurrency_gate.release(**lease_metadata) raise HTTPException(status_code=501, detail=str(exc)) from exc + except Exception: + if concurrency_gate is not None and redis_lease_acquired: + await concurrency_gate.release(**lease_metadata) + raise t_post_create = time.perf_counter() # Upsert thread metadata so the thread appears in /threads/search, @@ -822,7 +909,12 @@ async def start_run( logger.warning("Failed to upsert thread_meta for %s (non-fatal)", sanitize_log_param(thread_id)) t_post_thread_upsert = time.perf_counter() - agent_factory = resolve_agent_factory(body.assistant_id) + try: + agent_factory = resolve_agent_factory(body.assistant_id) + except Exception: + if concurrency_gate is not None and redis_lease_acquired: + await concurrency_gate.release(**lease_metadata) + raise graph_input = normalize_input(body.input) # Admin-configurable prompt prefix: when the caller opted in via @@ -860,24 +952,38 @@ async def start_run( stream_modes = normalize_stream_modes(body.stream_mode) t_pre_task = time.perf_counter() - task = asyncio.create_task( - run_agent( - bridge, - run_mgr, - record, - ctx=run_ctx, - agent_factory=agent_factory, - graph_input=graph_input, - config=config, - stream_modes=stream_modes, - stream_subgraphs=body.stream_subgraphs, - interrupt_before=body.interrupt_before, - interrupt_after=body.interrupt_after, - # 透传 SDK ``Command``:resume / update / goto。Pregel 会据此从最近 - # checkpoint 处恢复,而不是把 graph_input 当新一轮输入跑。 - command=getattr(body, "command", None), + heartbeat_interval_seconds = 20 + if concurrency_gate is not None: + heartbeat_interval_seconds = int( + getattr(getattr(concurrency_gate, "config", None), "heartbeat_interval_seconds", 20) or 20 ) - ) + try: + task = asyncio.create_task( + _run_agent_with_concurrency_lease( + concurrency_gate=concurrency_gate, + lease_acquired=redis_lease_acquired, + lease_metadata=lease_metadata, + heartbeat_interval_seconds=heartbeat_interval_seconds, + run_agent_kwargs={ + "bridge": bridge, + "run_manager": run_mgr, + "record": record, + "ctx": run_ctx, + "agent_factory": agent_factory, + "graph_input": graph_input, + "config": config, + "stream_modes": stream_modes, + "stream_subgraphs": body.stream_subgraphs, + "interrupt_before": body.interrupt_before, + "interrupt_after": body.interrupt_after, + "command": getattr(body, "command", None), + }, + ) + ) + except Exception: + if concurrency_gate is not None and redis_lease_acquired: + await concurrency_gate.release(**lease_metadata) + raise record.task = task # Title sync is handled by worker.py's finally block which reads the diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 5d9d9a69..1b01c238 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -9,6 +9,26 @@ log_level: info gateway: port: 8001 +# Optional Redis-backed global concurrency coordination. +# Leave disabled to keep the existing in-process behavior. +# When enabled, Redis operations are short-timeout and fail-open by default: +# if Redis is slow or unavailable, user Q&A continues with local-only limits. +concurrency: + redis: + enabled: false + url: redis://127.0.0.1:6379/0 + password: null + namespace: deerflow:concurrency + socket_connect_timeout_ms: 80 + socket_timeout_ms: 80 + acquire_timeout_ms: 120 + operation_timeout_ms: 80 + circuit_breaker_failures: 3 + circuit_breaker_cooldown_seconds: 30 + lease_ttl_seconds: 90 + heartbeat_interval_seconds: 20 + fail_open: true + models: - name: deepseek-chat display_name: DeepSeek Chat diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py index 58d09b4b..8deaeb3c 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/app_config.py @@ -11,8 +11,9 @@ from pydantic import BaseModel, ConfigDict, Field from deerflow.config.acp_config import ACPAgentConfig, load_acp_config_from_dict from deerflow.config.agents_api_config import AgentsApiConfig, load_agents_api_config_from_dict from deerflow.config.browser_context_config import BrowserContextConfig -from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict -from deerflow.config.database_config import DatabaseConfig +from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict +from deerflow.config.concurrency_config import ConcurrencyConfig +from deerflow.config.database_config import DatabaseConfig from deerflow.config.extensions_config import ExtensionsConfig from deerflow.config.gateway_config import GatewayConfig from deerflow.config.guardrails_config import GuardrailsConfig, load_guardrails_config_from_dict @@ -137,8 +138,9 @@ class AppConfig(BaseModel): gateway: GatewayConfig = Field(default_factory=GatewayConfig, description="Gateway FastAPI listening port (used by intra-process loopback)") run_events: RunEventsConfig = Field(default_factory=RunEventsConfig, description="Run event storage configuration") checkpointer: CheckpointerConfig | None = Field(default=None, description="Checkpointer configuration") - stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration") - browser_context: BrowserContextConfig = Field(default_factory=BrowserContextConfig, description="Optional browser page capture integration") + stream_bridge: StreamBridgeConfig | None = Field(default=None, description="Stream bridge configuration") + concurrency: ConcurrencyConfig = Field(default_factory=ConcurrencyConfig, description="Optional cross-worker concurrency coordination") + browser_context: BrowserContextConfig = Field(default_factory=BrowserContextConfig, description="Optional browser page capture integration") knowledge: KnowledgeConfig = Field(default_factory=KnowledgeConfig, description="Product knowledge base (obsidian-wiki compatible vault) configuration") knowledge_ingest: KnowledgeIngestConfig = Field(default_factory=KnowledgeIngestConfig, description="External knowledge-ingest endpoint configuration") task_deeplink: TaskDeeplinkConfig = Field(default_factory=TaskDeeplinkConfig, description="任务深链(rwfx)目的树闸门与跳转地址配置(前端经 /api/public/config/task-deeplink 读取)") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/concurrency_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/concurrency_config.py new file mode 100644 index 00000000..d741fda1 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/concurrency_config.py @@ -0,0 +1,35 @@ +"""Runtime concurrency coordination configuration.""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class RedisConcurrencyConfig(BaseModel): + """Optional Redis-backed cross-worker concurrency coordination.""" + + enabled: bool = Field(default=False, description="Enable Redis-backed global run concurrency coordination.") + url: str | None = Field(default=None, description="Redis URL, for example redis://127.0.0.1:6379/0.") + password: str | None = Field( + default=None, + description="Optional Redis password. You may also include credentials directly in url.", + ) + namespace: str = Field(default="deerflow:concurrency", description="Redis key prefix for concurrency leases.") + socket_connect_timeout_ms: int = Field(default=80, ge=1, le=10_000) + socket_timeout_ms: int = Field(default=80, ge=1, le=10_000) + acquire_timeout_ms: int = Field(default=120, ge=1, le=10_000) + operation_timeout_ms: int = Field(default=80, ge=1, le=10_000) + circuit_breaker_failures: int = Field(default=3, ge=1, le=100) + circuit_breaker_cooldown_seconds: int = Field(default=30, ge=1, le=3600) + lease_ttl_seconds: int = Field(default=90, ge=10, le=3600) + heartbeat_interval_seconds: int = Field(default=20, ge=5, le=600) + fail_open: bool = Field( + default=True, + description="Allow runs to proceed with local-only limits when Redis is unavailable or slow.", + ) + + +class ConcurrencyConfig(BaseModel): + """Top-level concurrency coordination config.""" + + redis: RedisConcurrencyConfig = Field(default_factory=RedisConcurrencyConfig) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/__init__.py index 63ea0860..d0bfc8e4 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/__init__.py @@ -7,14 +7,19 @@ directly from ``deerflow.runtime``. from .checkpointer import checkpointer_context, get_checkpointer, make_checkpointer, reset_checkpointer from .runs import ( + AcquireDecision, + AcquireResult, + ActiveLease, ConflictError, DisconnectMode, ModelConcurrencyLimitError, + RedisConcurrencyGate, RunContext, RunManager, RunRecord, RunStatus, UnsupportedStrategyError, + make_redis_concurrency_gate, run_agent, ) from .serialization import serialize, serialize_channel_values, serialize_lc_object, serialize_messages_tuple @@ -28,14 +33,19 @@ __all__ = [ "make_checkpointer", "reset_checkpointer", # runs + "AcquireDecision", + "AcquireResult", + "ActiveLease", "ConflictError", "DisconnectMode", "ModelConcurrencyLimitError", + "RedisConcurrencyGate", "RunContext", "RunManager", "RunRecord", "RunStatus", "UnsupportedStrategyError", + "make_redis_concurrency_gate", "run_agent", # serialization "serialize", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/__init__.py b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/__init__.py index 318b58c5..d6382f97 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/__init__.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/__init__.py @@ -2,16 +2,22 @@ from .manager import ConflictError, ModelConcurrencyLimitError, RunManager, RunRecord, UnsupportedStrategyError from .schemas import DisconnectMode, RunStatus +from .concurrency_gate import AcquireDecision, AcquireResult, ActiveLease, RedisConcurrencyGate, make_redis_concurrency_gate from .worker import RunContext, run_agent __all__ = [ + "AcquireDecision", + "AcquireResult", + "ActiveLease", "ConflictError", "DisconnectMode", "ModelConcurrencyLimitError", + "RedisConcurrencyGate", "RunContext", "RunManager", "RunRecord", "RunStatus", "UnsupportedStrategyError", + "make_redis_concurrency_gate", "run_agent", ] diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/concurrency_gate.py b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/concurrency_gate.py new file mode 100644 index 00000000..b2bd9e2f --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/concurrency_gate.py @@ -0,0 +1,369 @@ +"""Optional cross-worker run concurrency gates. + +The Redis gate is deliberately fail-open by default: Redis coordinates global +limits when it is fast and healthy, but it must not block the chat path when it +is slow or unavailable. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from dataclasses import dataclass +from enum import Enum +from typing import Any + +from deerflow.config.concurrency_config import RedisConcurrencyConfig + +logger = logging.getLogger(__name__) + + +class AcquireDecision(str, Enum): + ACQUIRED = "acquired" + REJECTED = "rejected" + DEGRADED = "degraded" + + +@dataclass(frozen=True) +class AcquireResult: + decision: AcquireDecision + reason: str = "" + source: str = "redis" + + @property + def acquired(self) -> bool: + return self.decision == AcquireDecision.ACQUIRED + + @property + def rejected(self) -> bool: + return self.decision == AcquireDecision.REJECTED + + @property + def degraded(self) -> bool: + return self.decision == AcquireDecision.DEGRADED + + +@dataclass(frozen=True) +class ActiveLease: + run_id: str + thread_id: str = "" + model_name: str = "" + user_id: str = "" + assistant_id: str = "" + created_at: float = 0.0 + expires_at: float = 0.0 + + +class CircuitBreaker: + """Tiny in-process circuit breaker for Redis failures.""" + + def __init__(self, *, failure_threshold: int, cooldown_seconds: int) -> None: + self._failure_threshold = max(1, int(failure_threshold)) + self._cooldown_seconds = max(1, int(cooldown_seconds)) + self._failures = 0 + self._opened_until = 0.0 + + def allow(self) -> bool: + return time.monotonic() >= self._opened_until + + def record_success(self) -> None: + self._failures = 0 + self._opened_until = 0.0 + + def record_failure(self) -> None: + self._failures += 1 + if self._failures >= self._failure_threshold: + self._opened_until = time.monotonic() + self._cooldown_seconds + + @property + def open(self) -> bool: + return not self.allow() + + +class RedisConcurrencyGate: + """Redis lease-based global concurrency limiter.""" + + _ACQUIRE_SCRIPT = """ +local now = tonumber(ARGV[1]) +local expires_at = tonumber(ARGV[2]) +local run_id = ARGV[3] +local model_limit = tonumber(ARGV[4]) +local user_model_limit = tonumber(ARGV[5]) +local reject_thread = tonumber(ARGV[6]) +local payload = ARGV[7] +local ttl_ms = tonumber(ARGV[8]) + +for i = 1, 4 do + redis.call('ZREMRANGEBYSCORE', KEYS[i], '-inf', now) +end + +if reject_thread == 1 and redis.call('ZCARD', KEYS[4]) > 0 then + return {'rejected', 'thread'} +end +if model_limit > 0 and redis.call('ZCARD', KEYS[2]) >= model_limit then + return {'rejected', 'model'} +end +if user_model_limit > 0 and redis.call('ZCARD', KEYS[3]) >= user_model_limit then + return {'rejected', 'user_model'} +end + +redis.call('ZADD', KEYS[1], expires_at, run_id) +redis.call('ZADD', KEYS[2], expires_at, run_id) +redis.call('ZADD', KEYS[3], expires_at, run_id) +redis.call('ZADD', KEYS[4], expires_at, run_id) +redis.call('SET', KEYS[5], payload, 'PX', ttl_ms) +return {'acquired', ''} +""" + + def __init__(self, config: RedisConcurrencyConfig) -> None: + self.config = config + self._client: Any | None = None + self._breaker = CircuitBreaker( + failure_threshold=config.circuit_breaker_failures, + cooldown_seconds=config.circuit_breaker_cooldown_seconds, + ) + + @property + def enabled(self) -> bool: + return bool(self.config.enabled and self.config.url) + + def health(self) -> dict[str, Any]: + return { + "enabled": self.enabled, + "source": "redis", + "circuit_open": self._breaker.open, + "fail_open": self.config.fail_open, + } + + async def aclose(self) -> None: + client = self._client + self._client = None + if client is not None: + try: + await client.aclose() + except Exception: + logger.debug("Failed to close Redis concurrency client", exc_info=True) + + def _ns(self) -> str: + return self.config.namespace.strip().rstrip(":") or "deerflow:concurrency" + + def _keys(self, *, run_id: str, model_name: str, user_id: str, thread_id: str) -> list[str]: + ns = self._ns() + return [ + f"{ns}:all", + f"{ns}:model:{model_name}", + f"{ns}:user_model:{model_name}:{user_id}", + f"{ns}:thread:{thread_id}", + f"{ns}:run:{run_id}", + ] + + async def _redis(self) -> Any: + if self._client is not None: + return self._client + try: + from redis.asyncio import Redis + except Exception as exc: # pragma: no cover - exercised when dependency is absent + raise RuntimeError("redis package is not installed") from exc + + timeout = max(0.001, self.config.socket_timeout_ms / 1000) + connect_timeout = max(0.001, self.config.socket_connect_timeout_ms / 1000) + self._client = Redis.from_url( + self.config.url, + password=self.config.password, + socket_timeout=timeout, + socket_connect_timeout=connect_timeout, + decode_responses=True, + ) + return self._client + + async def _with_timeout(self, coro, timeout_ms: int) -> Any: + return await asyncio.wait_for(coro, timeout=max(0.001, timeout_ms / 1000)) + + def _degraded(self, reason: str) -> AcquireResult: + return AcquireResult(AcquireDecision.DEGRADED, reason=reason) + + def _on_failure(self, action: str, exc: BaseException) -> None: + self._breaker.record_failure() + logger.warning( + "Redis concurrency %s failed; fail_open=%s circuit_open=%s: %s", + action, + self.config.fail_open, + self._breaker.open, + exc, + ) + + async def try_acquire( + self, + *, + run_id: str, + thread_id: str, + model_name: str, + user_id: str, + assistant_id: str = "", + model_limit: int | None = None, + user_model_limit: int | None = None, + reject_thread: bool = True, + ) -> AcquireResult: + if not self.enabled: + return self._degraded("disabled") + if not self._breaker.allow(): + return self._degraded("circuit_open") + + now = time.time() + expires_at = now + self.config.lease_ttl_seconds + payload = { + "run_id": run_id, + "thread_id": thread_id, + "model_name": model_name, + "user_id": user_id, + "assistant_id": assistant_id, + "created_at": now, + "expires_at": expires_at, + } + import json + + keys = self._keys(run_id=run_id, model_name=model_name, user_id=user_id, thread_id=thread_id) + ttl_ms = int(self.config.lease_ttl_seconds * 1000) + try: + client = await self._redis() + raw = await self._with_timeout( + client.eval( + self._ACQUIRE_SCRIPT, + len(keys), + *keys, + str(now), + str(expires_at), + run_id, + str(max(0, int(model_limit or 0))), + str(max(0, int(user_model_limit or 0))), + "1" if reject_thread else "0", + json.dumps(payload, ensure_ascii=False), + str(ttl_ms), + ), + self.config.acquire_timeout_ms, + ) + self._breaker.record_success() + except Exception as exc: + self._on_failure("acquire", exc) + if self.config.fail_open: + return self._degraded(type(exc).__name__) + raise + + status = raw[0] if isinstance(raw, list) and raw else "" + reason = raw[1] if isinstance(raw, list) and len(raw) > 1 else "" + if status == "acquired": + return AcquireResult(AcquireDecision.ACQUIRED) + return AcquireResult(AcquireDecision.REJECTED, reason=str(reason or "limit")) + + async def heartbeat( + self, + *, + run_id: str, + thread_id: str, + model_name: str, + user_id: str, + ) -> None: + if not self.enabled or not self._breaker.allow(): + return + now = time.time() + expires_at = now + self.config.lease_ttl_seconds + keys = self._keys(run_id=run_id, model_name=model_name, user_id=user_id, thread_id=thread_id) + try: + client = await self._redis() + pipe = client.pipeline(transaction=False) + for key in keys[:4]: + pipe.zadd(key, {run_id: expires_at}) + pipe.expire(keys[4], self.config.lease_ttl_seconds) + await self._with_timeout(pipe.execute(), self.config.operation_timeout_ms) + self._breaker.record_success() + except Exception as exc: + self._on_failure("heartbeat", exc) + + async def release( + self, + *, + run_id: str, + thread_id: str, + model_name: str, + user_id: str, + ) -> None: + if not self.enabled or not self._breaker.allow(): + return + keys = self._keys(run_id=run_id, model_name=model_name, user_id=user_id, thread_id=thread_id) + try: + client = await self._redis() + pipe = client.pipeline(transaction=False) + for key in keys[:4]: + pipe.zrem(key, run_id) + pipe.delete(keys[4]) + await self._with_timeout(pipe.execute(), self.config.operation_timeout_ms) + self._breaker.record_success() + except Exception as exc: + self._on_failure("release", exc) + + async def count_active(self) -> int | None: + if not self.enabled or not self._breaker.allow(): + return None + key = f"{self._ns()}:all" + now = time.time() + try: + client = await self._redis() + pipe = client.pipeline(transaction=False) + pipe.zremrangebyscore(key, "-inf", now) + pipe.zcard(key) + result = await self._with_timeout(pipe.execute(), self.config.operation_timeout_ms) + self._breaker.record_success() + return int(result[-1] or 0) + except Exception as exc: + self._on_failure("count", exc) + return None + + async def list_active(self, *, limit: int = 1000) -> list[ActiveLease] | None: + if not self.enabled or not self._breaker.allow(): + return None + import json + + all_key = f"{self._ns()}:all" + now = time.time() + try: + client = await self._redis() + await self._with_timeout(client.zremrangebyscore(all_key, "-inf", now), self.config.operation_timeout_ms) + run_ids = await self._with_timeout(client.zrange(all_key, 0, max(0, limit - 1)), self.config.operation_timeout_ms) + if not run_ids: + self._breaker.record_success() + return [] + keys = [f"{self._ns()}:run:{run_id}" for run_id in run_ids] + payloads = await self._with_timeout(client.mget(keys), self.config.operation_timeout_ms) + leases: list[ActiveLease] = [] + for run_id, payload in zip(run_ids, payloads, strict=False): + if not payload: + continue + try: + data = json.loads(payload) + except Exception: + continue + leases.append( + ActiveLease( + run_id=str(data.get("run_id") or run_id), + thread_id=str(data.get("thread_id") or ""), + model_name=str(data.get("model_name") or ""), + user_id=str(data.get("user_id") or ""), + assistant_id=str(data.get("assistant_id") or ""), + created_at=float(data.get("created_at") or 0), + expires_at=float(data.get("expires_at") or 0), + ) + ) + self._breaker.record_success() + return leases + except Exception as exc: + self._on_failure("list", exc) + return None + + +def make_redis_concurrency_gate(config: Any) -> RedisConcurrencyGate | None: + concurrency = getattr(config, "concurrency", None) + redis_config = getattr(concurrency, "redis", None) + if redis_config is None or not getattr(redis_config, "enabled", False) or not getattr(redis_config, "url", None): + return None + return RedisConcurrencyGate(redis_config) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py index d6842657..edef5296 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py @@ -219,6 +219,7 @@ class RunManager: multitask_strategy: str = "reject", model_concurrency_limit: tuple[str, int] | None = None, user_model_concurrency_limit: tuple[str, str, int] | None = None, + run_id: str | None = None, ) -> RunRecord: """Atomically check for inflight runs and create a new one. @@ -229,7 +230,7 @@ class RunManager: This method holds the lock across both the check and the insert, eliminating the TOCTOU race in separate ``has_inflight`` + ``create``. """ - run_id = str(uuid.uuid4()) + run_id = run_id or str(uuid.uuid4()) now = _now_iso() _supported_strategies = ("reject", "interrupt", "rollback") diff --git a/offline-backend-20260512/backend/packages/harness/pyproject.toml b/offline-backend-20260512/backend/packages/harness/pyproject.toml index b8b5ff94..2c47c1a5 100644 --- a/offline-backend-20260512/backend/packages/harness/pyproject.toml +++ b/offline-backend-20260512/backend/packages/harness/pyproject.toml @@ -23,9 +23,10 @@ dependencies = [ "markdownify>=1.2.2", "markitdown[all,xlsx]>=0.0.1a2", "pydantic>=2.12.5", - "pyyaml>=6.0.3", - "readabilipy>=0.3.0", - "tavily-python>=0.7.17", + "pyyaml>=6.0.3", + "readabilipy>=0.3.0", + "redis>=5.0.0", + "tavily-python>=0.7.17", "firecrawl-py>=1.15.0", "tiktoken>=0.8.0", "ddgs>=9.10.0", From c23350f17b4516a85b981980f2c1e4ad3a28b110 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Tue, 30 Jun 2026 14:56:29 +0800 Subject: [PATCH 11/16] fix: isolate user concurrency from internal agent runs --- .../gateway/roundtable_inprocess_gateway.py | 9 ++- .../app/gateway/routers/multi_agent.py | 8 ++- .../backend/app/gateway/services.py | 70 ++++++++++++++++++- offline-backend-20260512/backend/config.yaml | 5 +- .../deerflow/config/concurrency_config.py | 5 +- .../deerflow/runtime/runs/concurrency_gate.py | 18 +++-- .../harness/deerflow/runtime/runs/manager.py | 1 + .../backend/tests/test_model_concurrency.py | 56 +++++++++++++++ 8 files changed, 162 insertions(+), 10 deletions(-) diff --git a/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py b/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py index fadc3093..c34a7867 100644 --- a/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py +++ b/offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py @@ -32,6 +32,7 @@ from app.gateway.roundtable_model_fallback import ( ) from app.gateway.roundtable_run_policy import roundtable_run_policy from app.gateway.roundtable_seat_skills import append_seat_skill_directive +from app.gateway.services import RUN_SCOPE_BACKGROUND_CHILD from app.gateway.routers._roundtable_seed import ( COORDINATOR_AGENT_ID, DASHBOARD_AGENT_ID, @@ -346,6 +347,7 @@ async def _run_agent_turn( body = RunCreateRequest( assistant_id="lead_agent", input={"messages": [{"type": "human", "content": [{"type": "text", "text": message}], "additional_kwargs": {}}]}, + metadata={"run_scope": RUN_SCOPE_BACKGROUND_CHILD}, config={"recursion_limit": 1000}, context={ "agent_name": agent_name, @@ -374,7 +376,12 @@ async def _run_agent_turn( # 落 checkpoint)。额度满时本轮在此 await 排队,等同用户某个在跑轮次释放额度后再开。 async with _user_background_semaphore(user_id): try: - record = await start_run(body, thread_id, request) # type: ignore[arg-type] + record = await start_run( # type: ignore[arg-type] + body, + thread_id, + request, + run_scope=RUN_SCOPE_BACKGROUND_CHILD, + ) except Exception as exc: # noqa: BLE001 — start_run 报错(409 抢占 / DB 抖动等)也要落诊断 if diag is not None: await diag.record( diff --git a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py index 83046e24..c3bcfe0a 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py +++ b/offline-backend-20260512/backend/app/gateway/routers/multi_agent.py @@ -65,6 +65,7 @@ from langgraph.checkpoint.base import empty_checkpoint from app.gateway.roundtable_diag import record_foreground_diag from app.gateway.roundtable_model_fallback import fallback_model_chain, is_llm_error_message, reason_text from app.gateway.roundtable_run_policy import merge_skill_stop_names, roundtable_run_policy +from app.gateway.services import RUN_SCOPE_ORCHESTRATION_CHILD, internal_run_scope_headers from deerflow.agents.roundtable_orchestrator.extraction_spec import build_summary_directive from app.gateway.roundtable_seat_skills import append_seat_skill_directive @@ -325,7 +326,11 @@ def _auth_headers(request: Request) -> dict[str, str]: token = request.headers.get("authorization") if not token: raise HTTPException(status_code=401, detail="Missing Authorization header") - return {"Authorization": token, "Content-Type": "application/json"} + return { + "Authorization": token, + "Content-Type": "application/json", + **internal_run_scope_headers(), + } async def _create_thread( @@ -694,6 +699,7 @@ def _run_payload( } ] }, + "metadata": {"run_scope": RUN_SCOPE_ORCHESTRATION_CHILD}, "config": {"recursion_limit": 1000}, "context": { "agent_name": agent_name, diff --git a/offline-backend-20260512/backend/app/gateway/services.py b/offline-backend-20260512/backend/app/gateway/services.py index 8331a565..22cdd9e9 100644 --- a/offline-backend-20260512/backend/app/gateway/services.py +++ b/offline-backend-20260512/backend/app/gateway/services.py @@ -10,6 +10,7 @@ from __future__ import annotations import asyncio import json import logging +import os import re import time import uuid @@ -41,12 +42,68 @@ from deerflow.runtime.user_context import get_effective_user_id logger = logging.getLogger(__name__) +RUN_SCOPE_EXTERNAL_ROOT = "external_root" +RUN_SCOPE_ORCHESTRATION_CHILD = "orchestration_child" +RUN_SCOPE_BACKGROUND_CHILD = "background_child" +_INTERNAL_RUN_SCOPES = {RUN_SCOPE_ORCHESTRATION_CHILD, RUN_SCOPE_BACKGROUND_CHILD} +_ALL_RUN_SCOPES = {RUN_SCOPE_EXTERNAL_ROOT, *_INTERNAL_RUN_SCOPES} +_INTERNAL_RUN_SCOPE_HEADER = "x-deerflow-internal-run-scope-token" +_INTERNAL_RUN_SCOPE_TOKEN = ( + os.getenv("DEERFLOW_INTERNAL_RUN_SCOPE_TOKEN", "").strip() + or uuid.uuid4().hex +) + + +def internal_run_scope_headers() -> dict[str, str]: + """Headers used by same-process loopback calls to mark internal child runs.""" + + return {_INTERNAL_RUN_SCOPE_HEADER: _INTERNAL_RUN_SCOPE_TOKEN} + + +def _metadata_run_scope(body: Any) -> str | None: + metadata = getattr(body, "metadata", None) or {} + if not isinstance(metadata, dict): + return None + raw = str(metadata.get("run_scope") or "").strip() + return raw or None + + +def _request_is_loopback(request: Request) -> bool: + client = getattr(request, "client", None) + host = str(getattr(client, "host", "") or "").strip() + return host in {"127.0.0.1", "::1", "localhost"} + + +def _has_internal_run_scope_header(request: Request) -> bool: + headers = getattr(request, "headers", None) + token = headers.get(_INTERNAL_RUN_SCOPE_HEADER) if headers is not None else None + return str(token or "") == _INTERNAL_RUN_SCOPE_TOKEN + + +def _resolve_run_scope(body: Any, request: Request, run_scope: str | None) -> str: + """Return the trusted run scope used for concurrency accounting.""" + + if run_scope: + scope = str(run_scope).strip() + return scope if scope in _ALL_RUN_SCOPES else RUN_SCOPE_EXTERNAL_ROOT + + scope = _metadata_run_scope(body) + if ( + scope in _INTERNAL_RUN_SCOPES + and _request_is_loopback(request) + and _has_internal_run_scope_header(request) + ): + return scope + if scope == RUN_SCOPE_EXTERNAL_ROOT: + return RUN_SCOPE_EXTERNAL_ROOT + return RUN_SCOPE_EXTERNAL_ROOT + async def _run_agent_with_concurrency_lease( *, concurrency_gate: Any | None, lease_acquired: bool, - lease_metadata: dict[str, str], + lease_metadata: dict[str, Any], heartbeat_interval_seconds: int, run_agent_kwargs: dict[str, Any], ) -> None: @@ -750,6 +807,7 @@ async def start_run( request: Request, *, enforce_access: bool = True, + run_scope: str | None = None, ) -> RunRecord: """Create a RunRecord and launch the background agent task. @@ -793,21 +851,29 @@ async def start_run( effective_model_name = _resolve_effective_run_model_name(body) effective_user_id = get_effective_user_id() + effective_run_scope = _resolve_run_scope(body, request, run_scope) model_concurrency_limit = _get_model_concurrency_limit(effective_model_name) - user_model_concurrency_limit = _get_user_model_concurrency_limit(effective_model_name) + user_model_concurrency_limit = ( + _get_user_model_concurrency_limit(effective_model_name) + if effective_run_scope == RUN_SCOPE_EXTERNAL_ROOT + else None + ) run_id = str(uuid.uuid4()) run_metadata = dict(body.metadata or {}) run_metadata["model_name"] = effective_model_name run_metadata["user_id"] = effective_user_id + run_metadata["run_scope"] = effective_run_scope redis_lease_acquired = False redis_gate_degraded = True + user_model_accounted = user_model_concurrency_limit is not None lease_metadata = { "run_id": run_id, "thread_id": thread_id, "model_name": effective_model_name, "user_id": effective_user_id, + "user_model_accounted": user_model_accounted, } if concurrency_gate is not None: acquire = await concurrency_gate.try_acquire( diff --git a/offline-backend-20260512/backend/config.yaml b/offline-backend-20260512/backend/config.yaml index 1b01c238..474607d1 100644 --- a/offline-backend-20260512/backend/config.yaml +++ b/offline-backend-20260512/backend/config.yaml @@ -18,7 +18,10 @@ concurrency: enabled: false url: redis://127.0.0.1:6379/0 password: null - namespace: deerflow:concurrency + # Prefix with the deployment/system id because Redis may be shared by + # multiple projects. Keep this unique per system to avoid cross-project + # leases affecting each other. + namespace: cmzs_deerflow:concurrency socket_connect_timeout_ms: 80 socket_timeout_ms: 80 acquire_timeout_ms: 120 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/concurrency_config.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/concurrency_config.py index d741fda1..381e099b 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/concurrency_config.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/concurrency_config.py @@ -14,7 +14,10 @@ class RedisConcurrencyConfig(BaseModel): default=None, description="Optional Redis password. You may also include credentials directly in url.", ) - namespace: str = Field(default="deerflow:concurrency", description="Redis key prefix for concurrency leases.") + namespace: str = Field( + default="cmzs_deerflow:concurrency", + description="Redis key prefix for concurrency leases. Use a deployment/system prefix when Redis is shared.", + ) socket_connect_timeout_ms: int = Field(default=80, ge=1, le=10_000) socket_timeout_ms: int = Field(default=80, ge=1, le=10_000) acquire_timeout_ms: int = Field(default=120, ge=1, le=10_000) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/concurrency_gate.py b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/concurrency_gate.py index b2bd9e2f..b483a2e3 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/concurrency_gate.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/concurrency_gate.py @@ -110,7 +110,9 @@ end redis.call('ZADD', KEYS[1], expires_at, run_id) redis.call('ZADD', KEYS[2], expires_at, run_id) -redis.call('ZADD', KEYS[3], expires_at, run_id) +if user_model_limit > 0 then + redis.call('ZADD', KEYS[3], expires_at, run_id) +end redis.call('ZADD', KEYS[4], expires_at, run_id) redis.call('SET', KEYS[5], payload, 'PX', ttl_ms) return {'acquired', ''} @@ -146,7 +148,7 @@ return {'acquired', ''} logger.debug("Failed to close Redis concurrency client", exc_info=True) def _ns(self) -> str: - return self.config.namespace.strip().rstrip(":") or "deerflow:concurrency" + return self.config.namespace.strip().rstrip(":") or "cmzs_deerflow:concurrency" def _keys(self, *, run_id: str, model_name: str, user_id: str, thread_id: str) -> list[str]: ns = self._ns() @@ -263,6 +265,7 @@ return {'acquired', ''} thread_id: str, model_name: str, user_id: str, + user_model_accounted: bool = True, ) -> None: if not self.enabled or not self._breaker.allow(): return @@ -272,7 +275,10 @@ return {'acquired', ''} try: client = await self._redis() pipe = client.pipeline(transaction=False) - for key in keys[:4]: + refresh_keys = [keys[0], keys[1], keys[3]] + if user_model_accounted: + refresh_keys.append(keys[2]) + for key in refresh_keys: pipe.zadd(key, {run_id: expires_at}) pipe.expire(keys[4], self.config.lease_ttl_seconds) await self._with_timeout(pipe.execute(), self.config.operation_timeout_ms) @@ -287,6 +293,7 @@ return {'acquired', ''} thread_id: str, model_name: str, user_id: str, + user_model_accounted: bool = True, ) -> None: if not self.enabled or not self._breaker.allow(): return @@ -294,7 +301,10 @@ return {'acquired', ''} try: client = await self._redis() pipe = client.pipeline(transaction=False) - for key in keys[:4]: + release_keys = [keys[0], keys[1], keys[3]] + if user_model_accounted: + release_keys.append(keys[2]) + for key in release_keys: pipe.zrem(key, run_id) pipe.delete(keys[4]) await self._with_timeout(pipe.execute(), self.config.operation_timeout_ms) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py index edef5296..07daf14c 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/runs/manager.py @@ -273,6 +273,7 @@ class RunManager: if r.status in (RunStatus.pending, RunStatus.running) and str((r.metadata or {}).get("model_name") or "").strip() == model_name and str((r.metadata or {}).get("user_id") or "").strip() == user_id + and str((r.metadata or {}).get("run_scope") or "external_root").strip() == "external_root" ) if active_for_user_model >= max(1, int(limit)): raise ModelConcurrencyLimitError(model_name, limit, scope="user_model", user_id=user_id) diff --git a/offline-backend-20260512/backend/tests/test_model_concurrency.py b/offline-backend-20260512/backend/tests/test_model_concurrency.py index 86091e12..7468d9d4 100644 --- a/offline-backend-20260512/backend/tests/test_model_concurrency.py +++ b/offline-backend-20260512/backend/tests/test_model_concurrency.py @@ -10,11 +10,17 @@ import pytest from app.gateway.routers import system_settings as ss_router from app.gateway.services import ( + RUN_SCOPE_EXTERNAL_ROOT, + RUN_SCOPE_ORCHESTRATION_CHILD, _get_model_concurrency_limit, _get_user_model_concurrency_limit, + _resolve_run_scope, _resolve_effective_run_model_name, + internal_run_scope_headers, ) from deerflow.config import system_settings as ss +from deerflow.config.concurrency_config import RedisConcurrencyConfig +from deerflow.runtime.runs.concurrency_gate import RedisConcurrencyGate from deerflow.runtime.runs.manager import ModelConcurrencyLimitError, RunManager @@ -212,6 +218,15 @@ async def test_run_manager_rejects_user_model_limit_atomically( assert _get_user_model_concurrency_limit("other") == 3 run_mgr = RunManager() + await run_mgr.create_or_reject( + "t-child", + metadata={ + "model_name": "glm5", + "user_id": "u1", + "run_scope": RUN_SCOPE_ORCHESTRATION_CHILD, + }, + user_model_concurrency_limit=("glm5", "u1", 1), + ) await run_mgr.create_or_reject("t1", metadata={"model_name": "glm5", "user_id": "u1"}) await run_mgr.create_or_reject( "t2", @@ -247,3 +262,44 @@ def test_get_model_concurrency_limit_skips_when_disabled( ) assert _get_model_concurrency_limit("glm5") is None assert _get_user_model_concurrency_limit("glm5") is None + + +def test_internal_run_scope_is_only_trusted_for_loopback_or_server_argument() -> None: + body = SimpleNamespace(metadata={"run_scope": RUN_SCOPE_ORCHESTRATION_CHILD}) + + trusted_loopback_request = SimpleNamespace( + client=SimpleNamespace(host="127.0.0.1"), + headers=internal_run_scope_headers(), + ) + untrusted_loopback_request = SimpleNamespace( + client=SimpleNamespace(host="127.0.0.1"), + headers={}, + ) + public_request = SimpleNamespace( + client=SimpleNamespace(host="10.0.0.8"), + headers=internal_run_scope_headers(), + ) + + assert ( + _resolve_run_scope(body, trusted_loopback_request, None) + == RUN_SCOPE_ORCHESTRATION_CHILD + ) + assert ( + _resolve_run_scope(body, untrusted_loopback_request, None) + == RUN_SCOPE_EXTERNAL_ROOT + ) + assert _resolve_run_scope(body, public_request, None) == RUN_SCOPE_EXTERNAL_ROOT + assert ( + _resolve_run_scope( + SimpleNamespace(metadata={}), + public_request, + RUN_SCOPE_ORCHESTRATION_CHILD, + ) + == RUN_SCOPE_ORCHESTRATION_CHILD + ) + + +def test_redis_concurrency_namespace_has_system_prefix() -> None: + assert RedisConcurrencyConfig().namespace == "cmzs_deerflow:concurrency" + gate = RedisConcurrencyGate(RedisConcurrencyConfig(namespace="")) + assert gate._ns() == "cmzs_deerflow:concurrency" From 2b0ba2a68b5e6e1770b9d2fd2406b96aaba6b49b Mon Sep 17 00:00:00 2001 From: Rocky Kwok Date: Wed, 1 Jul 2026 10:08:46 +0800 Subject: [PATCH 12/16] =?UTF-8?q?fix:=20=E6=B3=A8=E9=87=8A=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workspace/workspace-nav-menu.tsx | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/frontend-web/src/components/workspace/workspace-nav-menu.tsx b/frontend-web/src/components/workspace/workspace-nav-menu.tsx index 5df42f84..67b450f3 100644 --- a/frontend-web/src/components/workspace/workspace-nav-menu.tsx +++ b/frontend-web/src/components/workspace/workspace-nav-menu.tsx @@ -7,12 +7,11 @@ import { BriefcaseIcon, CalendarClockIcon, ChevronsUpDown, - DatabaseZapIcon, - GaugeIcon, - HistoryIcon, - MessageCircleQuestionIcon, - PlugIcon, - ScrollTextIcon, + DatabaseZapIcon, + GaugeIcon, + HistoryIcon, + MessageCircleQuestionIcon, + ScrollTextIcon, Settings2Icon, SettingsIcon, SparklesIcon, @@ -219,14 +218,14 @@ export function WorkspaceNavMenu() { {t.adminTags.title} - {isAdmin && ( - navigate("/page/workspace/admin/plugins")} - > - - 插件管理 - - )} + {/* {isAdmin && ( + navigate("/page/workspace/admin/plugins")} + > + + 插件管理 + + )} */} {isAdmin && ( navigate("/page/workspace/admin/positions")} From f8cb7be18cc236f258603099b6c211e501a3e4b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Tue, 21 Jul 2026 14:35:06 +0800 Subject: [PATCH 13/16] chore: add offline backend wheelhouse deployment --- offline-backend-20260512/README.md | 64 +++++++ offline-backend-20260512/backend/CLAUDE.md | 1 + offline-backend-20260512/backend/README.md | 1 + .../offline-runtime-requirements.txt | 27 +++ .../prepare_wheelhouse.sh | 99 ++++++++++ .../start_backend_offline.sh | 178 ++++++++++++++++++ 6 files changed, 370 insertions(+) create mode 100644 offline-backend-20260512/README.md create mode 100644 offline-backend-20260512/offline-runtime-requirements.txt create mode 100755 offline-backend-20260512/prepare_wheelhouse.sh create mode 100755 offline-backend-20260512/start_backend_offline.sh diff --git a/offline-backend-20260512/README.md b/offline-backend-20260512/README.md new file mode 100644 index 00000000..e77ac35a --- /dev/null +++ b/offline-backend-20260512/README.md @@ -0,0 +1,64 @@ +# DeerFlow 离线后端部署补充 + +本目录提供一套与 `start_backend_offline.sh` 配套的 wheelhouse 流程,用于在联网机器准备 Python 依赖,再将整个发布目录复制到无网络服务器运行。 + +## 文件 + +- `offline-runtime-requirements.txt`:启动时补装的固定版本依赖。 +- `prepare_wheelhouse.sh`:联网机器下载 Linux/Python 3.12 wheel,并验证传递依赖完整性。 +- `start_backend_offline.sh`:从本地 wheelhouse 补装依赖后启动 Docker 后端。 +- `wheelhouse/`:下载脚本生成;应与启动脚本一起复制到离线机。 + +## 联网机器准备 + +默认目标是后端镜像使用的 CPython 3.12、Linux x86_64: + +```bash +cd offline-backend-20260512 +bash prepare_wheelhouse.sh +``` + +脚本固定下载: + +- `croniter==6.2.2` +- `hindsight-client==0.6.2` +- `redis==5.2.1` +- 上述包在 Python 3.12/Linux x86_64 下的 19 个传递依赖;版本也已全部锁定在 `offline-runtime-requirements.txt` + +国内镜像示例: + +```bash +PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple bash prepare_wheelhouse.sh +``` + +ARM64 镜像需要改目标平台: + +```bash +TARGET_PLATFORM=manylinux2014_aarch64 bash prepare_wheelhouse.sh +``` + +下载完成后,`wheelhouse/SHA256SUMS` 可用于离线机校验文件完整性。 + +## 离线机器启动 + +确保发布目录还包含 Docker 镜像 tar、`.env`、`config.yaml`、`skill_evolution.json` 和 `backend/`,然后运行。`extensions_config.json` 与 `skills/` 会优先从发布根目录读取,不存在时自动回退到 `backend/`: + +```bash +cd offline-backend-20260512 +bash start_backend_offline.sh +docker logs -f deerflow-backend +``` + +只检查目录、配置项、wheel 和 checksum,不操作容器: + +```bash +PREFLIGHT_ONLY=1 bash start_backend_offline.sh +``` + +启动脚本只使用 `/app/wheelhouse`,不会访问 PyPI。它会验证 checksum,并在镜像虚拟环境缺包时一次性安装三个固定版本的运行时包。 + +## Redis 说明 + +`redis==5.2.1` 只是 Python 客户端,不包含 Redis 服务端。`config.yaml` 中 `concurrency.redis.enabled: false` 时无需部署 Redis;启用后必须另行准备可访问的 Redis 服务或离线 Redis Docker 镜像。 + +如果 Redis 运行在另一个容器,`redis://127.0.0.1:6379/0` 指向的是后端容器自身,不能直接使用。应将两个容器加入同一个 Docker network,并把 URL 改成类似 `redis://redis:6379/0`,同时为不同环境设置不同的 `namespace`。 diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 1cb9bcd3..839e9615 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -998,6 +998,7 @@ Both the `view_image` tool and `ViewImageMiddleware` are registered whenever `vi ## Documentation See `docs/` directory for detailed documentation: +- [../README.md](../README.md) - Offline Docker wheelhouse preparation and startup workflow. The pinned versions in `../offline-runtime-requirements.txt` and `../start_backend_offline.sh` must stay synchronized. The download target defaults to CPython 3.12 on Linux x86_64 and verifies dependency closure using wheelhouse only. - [CONFIGURATION.md](docs/CONFIGURATION.md) - Configuration options - [ARCHITECTURE.md](docs/ARCHITECTURE.md) - Architecture details - [API.md](docs/API.md) - API reference diff --git a/offline-backend-20260512/backend/README.md b/offline-backend-20260512/backend/README.md index 40e7e03b..2ffcacd6 100644 --- a/offline-backend-20260512/backend/README.md +++ b/offline-backend-20260512/backend/README.md @@ -407,6 +407,7 @@ uv run pytest - [Configuration Guide](docs/CONFIGURATION.md) - [Architecture Details](docs/ARCHITECTURE.md) - [API Reference](docs/API.md) +- [Offline Docker wheelhouse deployment (ZH)](../README.md) - [File Upload](docs/FILE_UPLOAD.md) - [Path Examples](docs/PATH_EXAMPLES.md) - [Context Summarization](docs/summarization.md) diff --git a/offline-backend-20260512/offline-runtime-requirements.txt b/offline-backend-20260512/offline-runtime-requirements.txt new file mode 100644 index 00000000..b493ed02 --- /dev/null +++ b/offline-backend-20260512/offline-runtime-requirements.txt @@ -0,0 +1,27 @@ +# Direct runtime packages layered into the prebuilt offline backend image. +# Keep these three pins synchronized with start_backend_offline.sh. +croniter==6.2.2 +hindsight-client==0.6.2 +redis==5.2.1 + +# Fully pinned dependency closure for CPython 3.12 on Linux. Keeping the +# transitive packages here makes a newly generated wheelhouse reproducible. +aiohappyeyeballs==2.7.1 +aiohttp==3.14.2 +aiohttp-retry==2.9.1 +aiosignal==1.4.0 +annotated-types==0.7.0 +async-timeout==5.0.1 +attrs==26.1.0 +frozenlist==1.8.0 +idna==3.18 +multidict==6.7.1 +propcache==0.5.2 +pydantic==2.13.4 +pydantic-core==2.46.4 +python-dateutil==2.9.0.post0 +six==1.17.0 +typing-extensions==4.16.0 +typing-inspection==0.4.2 +urllib3==2.7.0 +yarl==1.24.5 diff --git a/offline-backend-20260512/prepare_wheelhouse.sh b/offline-backend-20260512/prepare_wheelhouse.sh new file mode 100755 index 00000000..81cff94a --- /dev/null +++ b/offline-backend-20260512/prepare_wheelhouse.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +PYTHON_BIN="${PYTHON_BIN:-python3}" +REQUIREMENTS_FILE="${REQUIREMENTS_FILE:-${SCRIPT_DIR}/offline-runtime-requirements.txt}" +WHEELHOUSE_DIR="${WHEELHOUSE_DIR:-${SCRIPT_DIR}/wheelhouse}" +PIP_INDEX_URL="${PIP_INDEX_URL:-https://pypi.org/simple}" + +# The supplied backend image is CPython 3.12 on Debian x86_64. These values can +# be overridden when building an offline bundle for a different image/CPU. +TARGET_PLATFORM="${TARGET_PLATFORM:-manylinux2014_x86_64}" +TARGET_PYTHON_VERSION="${TARGET_PYTHON_VERSION:-312}" +TARGET_IMPLEMENTATION="${TARGET_IMPLEMENTATION:-cp}" +TARGET_ABI="${TARGET_ABI:-cp312}" + +die() { + echo "ERROR: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "command not found: $1" +} + +require_wheel() { + local pattern="$1" + compgen -G "${WHEELHOUSE_DIR}/${pattern}" >/dev/null || die "wheel not downloaded: ${pattern}" +} + +require_cmd "${PYTHON_BIN}" +require_cmd sha256sum +[ -f "${REQUIREMENTS_FILE}" ] || die "requirements file not found: ${REQUIREMENTS_FILE}" +"${PYTHON_BIN}" -m pip --version >/dev/null 2>&1 || die "pip is not available for ${PYTHON_BIN}" + +mkdir -p "${WHEELHOUSE_DIR}" + +download_args=( + download + --dest "${WHEELHOUSE_DIR}" + --only-binary=:all: + --platform "${TARGET_PLATFORM}" + --python-version "${TARGET_PYTHON_VERSION}" + --implementation "${TARGET_IMPLEMENTATION}" + --abi "${TARGET_ABI}" +) + +echo "Downloading Linux/Python runtime wheels" +echo " requirements: ${REQUIREMENTS_FILE}" +echo " destination: ${WHEELHOUSE_DIR}" +echo " platform: ${TARGET_PLATFORM}" +echo " Python/ABI: ${TARGET_PYTHON_VERSION}/${TARGET_ABI}" + +"${PYTHON_BIN}" -m pip "${download_args[@]}" \ + --index-url "${PIP_INDEX_URL}" \ + --requirement "${REQUIREMENTS_FILE}" + +require_wheel 'croniter-6.2.2-*.whl' +require_wheel 'hindsight_client-0.6.2-*.whl' +require_wheel 'redis-5.2.1-*.whl' + +# Resolve the same requirements a second time using only wheelhouse. This +# catches a missing transitive dependency before the bundle reaches offline. +verify_dir="$(mktemp -d)" +cleanup() { + if [ -n "${verify_dir:-}" ] && [ -d "${verify_dir}" ]; then + rm -rf -- "${verify_dir}" + fi +} +trap cleanup EXIT + +verify_args=( + download + --dest "${verify_dir}" + --no-index + --find-links "${WHEELHOUSE_DIR}" + --only-binary=:all: + --platform "${TARGET_PLATFORM}" + --python-version "${TARGET_PYTHON_VERSION}" + --implementation "${TARGET_IMPLEMENTATION}" + --abi "${TARGET_ABI}" + --requirement "${REQUIREMENTS_FILE}" +) +"${PYTHON_BIN}" -m pip "${verify_args[@]}" >/dev/null + +shopt -s nullglob +wheels=("${WHEELHOUSE_DIR}"/*.whl) +[ "${#wheels[@]}" -gt 0 ] || die "wheelhouse is empty: ${WHEELHOUSE_DIR}" +( + cd "${WHEELHOUSE_DIR}" + sha256sum -- *.whl | LC_ALL=C sort > SHA256SUMS +) + +echo +echo "Wheelhouse is complete for the pinned runtime requirements." +echo " wheel count: ${#wheels[@]}" +echo " checksums: ${WHEELHOUSE_DIR}/SHA256SUMS" +echo "Copy the wheelhouse directory with the deployment bundle." diff --git a/offline-backend-20260512/start_backend_offline.sh b/offline-backend-20260512/start_backend_offline.sh new file mode 100755 index 00000000..1952f766 --- /dev/null +++ b/offline-backend-20260512/start_backend_offline.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +IMAGE_TAG="${IMAGE_TAG:-deerflow-backend-offline:20260505}" +CONTAINER_NAME="${CONTAINER_NAME:-deerflow-backend}" +HOST_PORT="${HOST_PORT:-8001}" +IMAGE_TAR="${IMAGE_TAR:-${SCRIPT_DIR}/deerflow-backend-offline-image-20260505.tar}" +CONTAINER_TZ="${CONTAINER_TZ:-${TZ:-Asia/Shanghai}}" + +default_extensions_config="${SCRIPT_DIR}/extensions_config.json" +if [ ! -f "${default_extensions_config}" ]; then + default_extensions_config="${SCRIPT_DIR}/backend/extensions_config.json" +fi +EXTENSIONS_CONFIG_FILE="${EXTENSIONS_CONFIG_FILE:-${default_extensions_config}}" + +default_skills_dir="${SCRIPT_DIR}/skills" +if [ ! -d "${default_skills_dir}" ]; then + default_skills_dir="${SCRIPT_DIR}/backend/skills" +fi +SKILLS_DIR="${SKILLS_DIR:-${default_skills_dir}}" + +die() { + echo "ERROR: $*" >&2 + exit 1 +} + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "command not found: $1" +} + +require_file() { + [ -f "$1" ] || die "required file not found: $1" +} + +require_dir() { + [ -d "$1" ] || die "required directory not found: $1" +} + +require_env_key() { + local key="$1" + grep -Eq "^[[:space:]]*${key}=" "${SCRIPT_DIR}/.env" || die "required .env key not found: ${key}" +} + +require_wheel() { + local pattern="$1" + compgen -G "${SCRIPT_DIR}/wheelhouse/${pattern}" >/dev/null || die "required wheel not found: wheelhouse/${pattern}" +} + +require_cmd docker + +if [ ! -f "${SCRIPT_DIR}/.env" ]; then + if [ -f "${SCRIPT_DIR}/.env.example" ]; then + cp "${SCRIPT_DIR}/.env.example" "${SCRIPT_DIR}/.env" + echo "Generated .env from .env.example. Please edit .env and run this script again." + exit 1 + fi + die "missing .env" +fi + +if [ ! -f "${SCRIPT_DIR}/config.yaml" ]; then + if [ -f "${SCRIPT_DIR}/config.yaml.example" ]; then + cp "${SCRIPT_DIR}/config.yaml.example" "${SCRIPT_DIR}/config.yaml" + echo "Generated config.yaml from config.yaml.example. Please edit model/API settings and run this script again." + exit 1 + fi + die "missing config.yaml" +fi + +require_env_key AUTH_JWT_SECRET +require_env_key SCHEDULED_TASKS_ENABLED +require_env_key ELEMENT_ENABLED +require_file "${SCRIPT_DIR}/config.yaml" +require_file "${EXTENSIONS_CONFIG_FILE}" +require_file "${SCRIPT_DIR}/skill_evolution.json" +require_file "${SCRIPT_DIR}/backend/pyproject.toml" +require_file "${SCRIPT_DIR}/backend/app/gateway/app.py" +require_file "${SCRIPT_DIR}/backend/packages/harness/deerflow/runtime/scheduler/element.py" +require_file "${SCRIPT_DIR}/backend/langgraph.json" +require_dir "${SKILLS_DIR}" +require_dir "${SCRIPT_DIR}/wheelhouse" +require_dir "${SCRIPT_DIR}/backend/knowledge/knowledge_base" +require_wheel 'croniter-6.2.2-*.whl' +require_wheel 'hindsight_client-0.6.2-*.whl' +require_wheel 'redis-5.2.1-*.whl' + +if [ -f "${SCRIPT_DIR}/wheelhouse/SHA256SUMS" ]; then + require_cmd sha256sum + echo "Verifying wheelhouse checksums" + (cd "${SCRIPT_DIR}/wheelhouse" && sha256sum -c SHA256SUMS) +fi + +mkdir -p "${SCRIPT_DIR}/data" + +if [ "${PREFLIGHT_ONLY:-0}" = "1" ]; then + echo "Offline backend preflight passed. No container was changed." + exit 0 +fi + +if ! docker image inspect "${IMAGE_TAG}" >/dev/null 2>&1; then + require_file "${IMAGE_TAR}" + echo "Loading Docker image from ${IMAGE_TAR}" + docker load -i "${IMAGE_TAR}" +fi + +if docker ps -a --format '{{.Names}}' | awk -v name="${CONTAINER_NAME}" '$0 == name { found=1 } END { exit(found ? 0 : 1) }'; then + echo "Removing existing container ${CONTAINER_NAME}" + docker rm -f "${CONTAINER_NAME}" >/dev/null +fi + +mount_args=( + -v "${SCRIPT_DIR}/backend/app:/app/backend/app" + -v "${SCRIPT_DIR}/backend/packages:/app/backend/packages" + -v "${SCRIPT_DIR}/backend/scripts:/app/backend/scripts" + -v "${SCRIPT_DIR}/backend/pyproject.toml:/app/backend/pyproject.toml:ro" + -v "${SCRIPT_DIR}/backend/langgraph.json:/app/backend/langgraph.json:ro" + -v "${SCRIPT_DIR}/config.yaml:/app/config.yaml:ro" + -v "${EXTENSIONS_CONFIG_FILE}:/app/extensions_config.json:ro" + -v "${SCRIPT_DIR}/skill_evolution.json:/app/skill_evolution.json:ro" + -v "${SKILLS_DIR}:/app/skills:ro" + -v "${SCRIPT_DIR}/wheelhouse:/app/wheelhouse:ro" + -v "${SCRIPT_DIR}/backend/knowledge/knowledge_base:/mnt/knowledge_base:ro" + -v "${SCRIPT_DIR}/data:/app/backend/.deer-flow" +) + +# uv.lock is not needed by uv run --no-sync, but mount it when the release +# bundle contains one so the container exactly reflects the online build. +if [ -f "${SCRIPT_DIR}/backend/uv.lock" ]; then + mount_args+=( + -v "${SCRIPT_DIR}/backend/uv.lock:/app/backend/uv.lock:ro" + ) +fi + +echo "Starting backend container ${CONTAINER_NAME}" +docker run -d \ + --name "${CONTAINER_NAME}" \ + --restart unless-stopped \ + -p "${HOST_PORT}:8001" \ + --env-file "${SCRIPT_DIR}/.env" \ + -e TZ="${CONTAINER_TZ}" \ + -e DEER_FLOW_PROJECT_ROOT=/app \ + -e DEER_FLOW_HOME=/app/backend/.deer-flow \ + -e DEER_FLOW_CONFIG_PATH=/app/config.yaml \ + -e DEER_FLOW_EXTENSIONS_CONFIG_PATH=/app/extensions_config.json \ + -e DEER_FLOW_SKILL_EVOLUTION_CONFIG_PATH=/app/skill_evolution.json \ + -e DEER_FLOW_SKILLS_PATH=/app/skills \ + -e UV_LINK_MODE=copy \ + "${mount_args[@]}" \ + "${IMAGE_TAG}" \ + sh -c 'set -eu +cd backend +if [ -n "${TZ:-}" ] && [ -f "/usr/share/zoneinfo/${TZ}" ]; then + ln -sf "/usr/share/zoneinfo/${TZ}" /etc/localtime 2>/dev/null || true + echo "${TZ}" > /etc/timezone 2>/dev/null || true +fi + +if [ "${OFFLINE_RUNTIME_INSTALL_DEPS:-1}" != "0" ]; then + if ! uv run --no-sync python -c "import croniter, hindsight_client, redis" >/dev/null 2>&1; then + echo "Installing offline runtime packages from /app/wheelhouse" + PYTHON_BIN="$(uv run --no-sync python -c "import sys; print(sys.executable)")" + # Word splitting is intentional so .env may override this package list. + uv pip install \ + --python "${PYTHON_BIN}" \ + --no-index \ + --find-links /app/wheelhouse \ + ${OFFLINE_RUNTIME_PIP_PACKAGES:-croniter==6.2.2 hindsight-client==0.6.2 redis==5.2.1} + fi + uv run --no-sync python -c "import importlib.metadata as m; names=(\"croniter\", \"hindsight-client\", \"redis\"); print(\"Offline runtime packages:\", \", \".join(f\"{name}={m.version(name)}\" for name in names))" +fi + +PYTHONPATH=. uv run --no-sync python -m uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --workers "${GATEWAY_WORKERS:-2}"' + +echo +echo "Backend started." +echo " URL: http://:${HOST_PORT}" +echo " Logs: docker logs -f ${CONTAINER_NAME}" +echo " Stop: docker rm -f ${CONTAINER_NAME}" From 5d2997b6a0c6eca772052e990c1acdaeb5543ceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Tue, 21 Jul 2026 14:39:57 +0800 Subject: [PATCH 14/16] fix: preserve offline deployment LF files --- offline-backend-20260512/.gitattributes | 3 +++ offline-backend-20260512/start_backend_offline.sh | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 offline-backend-20260512/.gitattributes diff --git a/offline-backend-20260512/.gitattributes b/offline-backend-20260512/.gitattributes new file mode 100644 index 00000000..8fa659ea --- /dev/null +++ b/offline-backend-20260512/.gitattributes @@ -0,0 +1,3 @@ +*.sh text eol=lf +offline-runtime-requirements.txt text eol=lf +wheelhouse/SHA256SUMS text eol=lf diff --git a/offline-backend-20260512/start_backend_offline.sh b/offline-backend-20260512/start_backend_offline.sh index 1952f766..9e612b41 100755 --- a/offline-backend-20260512/start_backend_offline.sh +++ b/offline-backend-20260512/start_backend_offline.sh @@ -87,8 +87,9 @@ require_wheel 'redis-5.2.1-*.whl' if [ -f "${SCRIPT_DIR}/wheelhouse/SHA256SUMS" ]; then require_cmd sha256sum + require_cmd sed echo "Verifying wheelhouse checksums" - (cd "${SCRIPT_DIR}/wheelhouse" && sha256sum -c SHA256SUMS) + (cd "${SCRIPT_DIR}/wheelhouse" && sed 's/\r$//' SHA256SUMS | sha256sum -c -) fi mkdir -p "${SCRIPT_DIR}/data" From e571caaf2cfa305869018fa0623d336f960f042d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Tue, 21 Jul 2026 14:46:58 +0800 Subject: [PATCH 15/16] fix: keep offline start script minimal --- offline-backend-20260512/README.md | 42 +++--- offline-backend-20260512/backend/CLAUDE.md | 2 +- .../offline-runtime-requirements.txt | 2 +- ...rt_backend_offline.sh => start_backend.sh} | 121 ++++++------------ 4 files changed, 65 insertions(+), 102 deletions(-) rename offline-backend-20260512/{start_backend_offline.sh => start_backend.sh} (54%) diff --git a/offline-backend-20260512/README.md b/offline-backend-20260512/README.md index e77ac35a..09610f41 100644 --- a/offline-backend-20260512/README.md +++ b/offline-backend-20260512/README.md @@ -1,13 +1,13 @@ # DeerFlow 离线后端部署补充 -本目录提供一套与 `start_backend_offline.sh` 配套的 wheelhouse 流程,用于在联网机器准备 Python 依赖,再将整个发布目录复制到无网络服务器运行。 +本目录提供与原始 `start_backend.sh` 配套的 wheelhouse。启动脚本保持原有结构,只新增一行 Redis Python 客户端的离线安装命令。 ## 文件 -- `offline-runtime-requirements.txt`:启动时补装的固定版本依赖。 +- `offline-runtime-requirements.txt`:固定版本的完整依赖闭包。 - `prepare_wheelhouse.sh`:联网机器下载 Linux/Python 3.12 wheel,并验证传递依赖完整性。 -- `start_backend_offline.sh`:从本地 wheelhouse 补装依赖后启动 Docker 后端。 -- `wheelhouse/`:下载脚本生成;应与启动脚本一起复制到离线机。 +- `start_backend.sh`:原始 Docker 启动脚本,加一行 Redis 缺包安装命令。 +- `wheelhouse/`:22 个 wheel 和 `SHA256SUMS`,需要随发布目录一起复制。 ## 联网机器准备 @@ -18,12 +18,13 @@ cd offline-backend-20260512 bash prepare_wheelhouse.sh ``` -脚本固定下载: +直接依赖为: - `croniter==6.2.2` - `hindsight-client==0.6.2` - `redis==5.2.1` -- 上述包在 Python 3.12/Linux x86_64 下的 19 个传递依赖;版本也已全部锁定在 `offline-runtime-requirements.txt` + +上述依赖在 Python 3.12/Linux x86_64 下的 19 个传递依赖也已锁定在 `offline-runtime-requirements.txt`。 国内镜像示例: @@ -37,28 +38,29 @@ ARM64 镜像需要改目标平台: TARGET_PLATFORM=manylinux2014_aarch64 bash prepare_wheelhouse.sh ``` -下载完成后,`wheelhouse/SHA256SUMS` 可用于离线机校验文件完整性。 - ## 离线机器启动 -确保发布目录还包含 Docker 镜像 tar、`.env`、`config.yaml`、`skill_evolution.json` 和 `backend/`,然后运行。`extensions_config.json` 与 `skills/` 会优先从发布根目录读取,不存在时自动回退到 `backend/`: +保持原脚本要求的发布目录结构,包括 Docker 镜像 tar、`.env`、`config.yaml`、`extensions_config.json`、`skill_evolution.json`、`skills/`、`wheelhouse/`、`backend/uv.lock` 和 `backend/`。 + +可先校验 wheel: ```bash -cd offline-backend-20260512 -bash start_backend_offline.sh +cd offline-backend-20260512/wheelhouse +sha256sum -c SHA256SUMS +cd .. +``` + +然后启动: + +```bash +bash start_backend.sh docker logs -f deerflow-backend ``` -只检查目录、配置项、wheel 和 checksum,不操作容器: - -```bash -PREFLIGHT_ONLY=1 bash start_backend_offline.sh -``` - -启动脚本只使用 `/app/wheelhouse`,不会访问 PyPI。它会验证 checksum,并在镜像虚拟环境缺包时一次性安装三个固定版本的运行时包。 +原脚本已有 `croniter` 和 `hindsight-client` 的离线补装逻辑;新增的一行命令只负责在缺少 `redis` 模块时,从 `/app/wheelhouse` 安装 `redis==5.2.1`。 ## Redis 说明 -`redis==5.2.1` 只是 Python 客户端,不包含 Redis 服务端。`config.yaml` 中 `concurrency.redis.enabled: false` 时无需部署 Redis;启用后必须另行准备可访问的 Redis 服务或离线 Redis Docker 镜像。 +`redis==5.2.1` 只是 Python 客户端,不包含 Redis 服务端。`config.yaml` 中 `concurrency.redis.enabled: false` 时无需部署 Redis;启用后必须另行准备 Redis 服务或离线 Redis Docker 镜像。 -如果 Redis 运行在另一个容器,`redis://127.0.0.1:6379/0` 指向的是后端容器自身,不能直接使用。应将两个容器加入同一个 Docker network,并把 URL 改成类似 `redis://redis:6379/0`,同时为不同环境设置不同的 `namespace`。 +如果 Redis 运行在另一个容器,`redis://127.0.0.1:6379/0` 指向后端容器自身,不能直接使用。应将两个容器加入同一个 Docker network,并将 URL 改为类似 `redis://redis:6379/0`,同时为不同环境配置不同的 `namespace`。 diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index 839e9615..320b879a 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -998,7 +998,7 @@ Both the `view_image` tool and `ViewImageMiddleware` are registered whenever `vi ## Documentation See `docs/` directory for detailed documentation: -- [../README.md](../README.md) - Offline Docker wheelhouse preparation and startup workflow. The pinned versions in `../offline-runtime-requirements.txt` and `../start_backend_offline.sh` must stay synchronized. The download target defaults to CPython 3.12 on Linux x86_64 and verifies dependency closure using wheelhouse only. +- [../README.md](../README.md) - Offline Docker wheelhouse preparation and startup workflow. The pinned versions in `../offline-runtime-requirements.txt` and `../start_backend.sh` must stay synchronized. `start_backend.sh` stays aligned with the deployment script and adds only the Redis wheel fallback line. The download target defaults to CPython 3.12 on Linux x86_64 and verifies dependency closure using wheelhouse only. - [CONFIGURATION.md](docs/CONFIGURATION.md) - Configuration options - [ARCHITECTURE.md](docs/ARCHITECTURE.md) - Architecture details - [API.md](docs/API.md) - API reference diff --git a/offline-backend-20260512/offline-runtime-requirements.txt b/offline-backend-20260512/offline-runtime-requirements.txt index b493ed02..bcea964d 100644 --- a/offline-backend-20260512/offline-runtime-requirements.txt +++ b/offline-backend-20260512/offline-runtime-requirements.txt @@ -1,5 +1,5 @@ # Direct runtime packages layered into the prebuilt offline backend image. -# Keep these three pins synchronized with start_backend_offline.sh. +# Keep these three pins synchronized with start_backend.sh. croniter==6.2.2 hindsight-client==0.6.2 redis==5.2.1 diff --git a/offline-backend-20260512/start_backend_offline.sh b/offline-backend-20260512/start_backend.sh similarity index 54% rename from offline-backend-20260512/start_backend_offline.sh rename to offline-backend-20260512/start_backend.sh index 9e612b41..f6cfa872 100755 --- a/offline-backend-20260512/start_backend_offline.sh +++ b/offline-backend-20260512/start_backend.sh @@ -7,20 +7,13 @@ IMAGE_TAG="${IMAGE_TAG:-deerflow-backend-offline:20260505}" CONTAINER_NAME="${CONTAINER_NAME:-deerflow-backend}" HOST_PORT="${HOST_PORT:-8001}" IMAGE_TAR="${IMAGE_TAR:-${SCRIPT_DIR}/deerflow-backend-offline-image-20260505.tar}" +# Default timezone for the container. The offline image is built from a base +# layer that ships with UTC, which made scheduled tasks fire at the wrong wall +# clock and made log timestamps confusing for ops. ``Asia/Shanghai`` keeps the +# scheduler aligned with the operator's expectation; override with TZ=... when +# deploying to a different region. CONTAINER_TZ="${CONTAINER_TZ:-${TZ:-Asia/Shanghai}}" -default_extensions_config="${SCRIPT_DIR}/extensions_config.json" -if [ ! -f "${default_extensions_config}" ]; then - default_extensions_config="${SCRIPT_DIR}/backend/extensions_config.json" -fi -EXTENSIONS_CONFIG_FILE="${EXTENSIONS_CONFIG_FILE:-${default_extensions_config}}" - -default_skills_dir="${SCRIPT_DIR}/skills" -if [ ! -d "${default_skills_dir}" ]; then - default_skills_dir="${SCRIPT_DIR}/backend/skills" -fi -SKILLS_DIR="${SKILLS_DIR:-${default_skills_dir}}" - die() { echo "ERROR: $*" >&2 exit 1 @@ -43,11 +36,6 @@ require_env_key() { grep -Eq "^[[:space:]]*${key}=" "${SCRIPT_DIR}/.env" || die "required .env key not found: ${key}" } -require_wheel() { - local pattern="$1" - compgen -G "${SCRIPT_DIR}/wheelhouse/${pattern}" >/dev/null || die "required wheel not found: wheelhouse/${pattern}" -} - require_cmd docker if [ ! -f "${SCRIPT_DIR}/.env" ]; then @@ -68,37 +56,21 @@ if [ ! -f "${SCRIPT_DIR}/config.yaml" ]; then die "missing config.yaml" fi -require_env_key AUTH_JWT_SECRET -require_env_key SCHEDULED_TASKS_ENABLED -require_env_key ELEMENT_ENABLED +require_env_key "AUTH_JWT_SECRET" +require_env_key "SCHEDULED_TASKS_ENABLED" +require_env_key "ELEMENT_ENABLED" require_file "${SCRIPT_DIR}/config.yaml" -require_file "${EXTENSIONS_CONFIG_FILE}" +require_file "${SCRIPT_DIR}/extensions_config.json" require_file "${SCRIPT_DIR}/skill_evolution.json" require_file "${SCRIPT_DIR}/backend/pyproject.toml" require_file "${SCRIPT_DIR}/backend/app/gateway/app.py" require_file "${SCRIPT_DIR}/backend/packages/harness/deerflow/runtime/scheduler/element.py" -require_file "${SCRIPT_DIR}/backend/langgraph.json" -require_dir "${SKILLS_DIR}" +require_dir "${SCRIPT_DIR}/skills" require_dir "${SCRIPT_DIR}/wheelhouse" +# Knowledge base directory, mounted into the container at /mnt/knowledge_base. require_dir "${SCRIPT_DIR}/backend/knowledge/knowledge_base" -require_wheel 'croniter-6.2.2-*.whl' -require_wheel 'hindsight_client-0.6.2-*.whl' -require_wheel 'redis-5.2.1-*.whl' - -if [ -f "${SCRIPT_DIR}/wheelhouse/SHA256SUMS" ]; then - require_cmd sha256sum - require_cmd sed - echo "Verifying wheelhouse checksums" - (cd "${SCRIPT_DIR}/wheelhouse" && sed 's/\r$//' SHA256SUMS | sha256sum -c -) -fi - mkdir -p "${SCRIPT_DIR}/data" -if [ "${PREFLIGHT_ONLY:-0}" = "1" ]; then - echo "Offline backend preflight passed. No container was changed." - exit 0 -fi - if ! docker image inspect "${IMAGE_TAG}" >/dev/null 2>&1; then require_file "${IMAGE_TAR}" echo "Loading Docker image from ${IMAGE_TAR}" @@ -110,29 +82,6 @@ if docker ps -a --format '{{.Names}}' | awk -v name="${CONTAINER_NAME}" '$0 == n docker rm -f "${CONTAINER_NAME}" >/dev/null fi -mount_args=( - -v "${SCRIPT_DIR}/backend/app:/app/backend/app" - -v "${SCRIPT_DIR}/backend/packages:/app/backend/packages" - -v "${SCRIPT_DIR}/backend/scripts:/app/backend/scripts" - -v "${SCRIPT_DIR}/backend/pyproject.toml:/app/backend/pyproject.toml:ro" - -v "${SCRIPT_DIR}/backend/langgraph.json:/app/backend/langgraph.json:ro" - -v "${SCRIPT_DIR}/config.yaml:/app/config.yaml:ro" - -v "${EXTENSIONS_CONFIG_FILE}:/app/extensions_config.json:ro" - -v "${SCRIPT_DIR}/skill_evolution.json:/app/skill_evolution.json:ro" - -v "${SKILLS_DIR}:/app/skills:ro" - -v "${SCRIPT_DIR}/wheelhouse:/app/wheelhouse:ro" - -v "${SCRIPT_DIR}/backend/knowledge/knowledge_base:/mnt/knowledge_base:ro" - -v "${SCRIPT_DIR}/data:/app/backend/.deer-flow" -) - -# uv.lock is not needed by uv run --no-sync, but mount it when the release -# bundle contains one so the container exactly reflects the online build. -if [ -f "${SCRIPT_DIR}/backend/uv.lock" ]; then - mount_args+=( - -v "${SCRIPT_DIR}/backend/uv.lock:/app/backend/uv.lock:ro" - ) -fi - echo "Starting backend container ${CONTAINER_NAME}" docker run -d \ --name "${CONTAINER_NAME}" \ @@ -147,33 +96,45 @@ docker run -d \ -e DEER_FLOW_SKILL_EVOLUTION_CONFIG_PATH=/app/skill_evolution.json \ -e DEER_FLOW_SKILLS_PATH=/app/skills \ -e UV_LINK_MODE=copy \ - "${mount_args[@]}" \ + -v "${SCRIPT_DIR}/backend/app:/app/backend/app" \ + -v "${SCRIPT_DIR}/backend/packages:/app/backend/packages" \ + -v "${SCRIPT_DIR}/backend/scripts:/app/backend/scripts" \ + -v "${SCRIPT_DIR}/backend/pyproject.toml:/app/backend/pyproject.toml:ro" \ + -v "${SCRIPT_DIR}/backend/uv.lock:/app/backend/uv.lock:ro" \ + -v "${SCRIPT_DIR}/backend/langgraph.json:/app/backend/langgraph.json:ro" \ + -v "${SCRIPT_DIR}/config.yaml:/app/config.yaml:ro" \ + -v "${SCRIPT_DIR}/extensions_config.json:/app/extensions_config.json:ro" \ + -v "${SCRIPT_DIR}/skill_evolution.json:/app/skill_evolution.json:ro" \ + -v "${SCRIPT_DIR}/skills:/app/skills:ro" \ + -v "${SCRIPT_DIR}/wheelhouse:/app/wheelhouse:ro" \ + -v "${SCRIPT_DIR}/backend/knowledge/knowledge_base:/mnt/knowledge_base:ro" \ + -v "${SCRIPT_DIR}/data:/app/backend/.deer-flow" \ "${IMAGE_TAG}" \ - sh -c 'set -eu -cd backend + sh -c 'cd backend +# Apply timezone inside the container. ``TZ`` alone is enough for Python / +# uvicorn log timestamps, but linking /etc/localtime keeps C-level tools +# (e.g. anything that reads /etc/localtime directly) on the same clock. if [ -n "${TZ:-}" ] && [ -f "/usr/share/zoneinfo/${TZ}" ]; then ln -sf "/usr/share/zoneinfo/${TZ}" /etc/localtime 2>/dev/null || true echo "${TZ}" > /etc/timezone 2>/dev/null || true fi - -if [ "${OFFLINE_RUNTIME_INSTALL_DEPS:-1}" != "0" ]; then - if ! uv run --no-sync python -c "import croniter, hindsight_client, redis" >/dev/null 2>&1; then - echo "Installing offline runtime packages from /app/wheelhouse" +if [ "${SCHEDULER_INSTALL_DEPS:-1}" != "0" ]; then + uv run --no-sync python -c "import croniter" >/dev/null 2>&1 || { PYTHON_BIN="$(uv run --no-sync python -c "import sys; print(sys.executable)")" - # Word splitting is intentional so .env may override this package list. - uv pip install \ - --python "${PYTHON_BIN}" \ - --no-index \ - --find-links /app/wheelhouse \ - ${OFFLINE_RUNTIME_PIP_PACKAGES:-croniter==6.2.2 hindsight-client==0.6.2 redis==5.2.1} - fi - uv run --no-sync python -c "import importlib.metadata as m; names=(\"croniter\", \"hindsight-client\", \"redis\"); print(\"Offline runtime packages:\", \", \".join(f\"{name}={m.version(name)}\" for name in names))" + uv pip install --python "${PYTHON_BIN}" --no-index --find-links /app/wheelhouse ${SCHEDULER_PIP_PACKAGES:-croniter==6.2.2} + } fi - -PYTHONPATH=. uv run --no-sync python -m uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --workers "${GATEWAY_WORKERS:-2}"' +if [ "${HINDSIGHT_INSTALL_DEPS:-1}" != "0" ]; then + uv run --no-sync python -c "import hindsight_client" >/dev/null 2>&1 || { + PYTHON_BIN="$(uv run --no-sync python -c "import sys; print(sys.executable)")" + uv pip install --python "${PYTHON_BIN}" --no-index --find-links /app/wheelhouse ${HINDSIGHT_PIP_PACKAGES:-hindsight-client==0.6.2} + } +fi +uv run --no-sync python -c "import redis" >/dev/null 2>&1 || uv pip install --python "$(uv run --no-sync python -c "import sys; print(sys.executable)")" --no-index --find-links /app/wheelhouse ${REDIS_PIP_PACKAGES:-redis==5.2.1} +PYTHONPATH=. uv run --no-sync python -m uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --workers ${GATEWAY_WORKERS:-2}' echo echo "Backend started." echo " URL: http://:${HOST_PORT}" echo " Logs: docker logs -f ${CONTAINER_NAME}" -echo " Stop: docker rm -f ${CONTAINER_NAME}" +echo " Stop: ./stop_backend.sh" From 59f2f56124e69cc373ae0954024614f99a7b2bab Mon Sep 17 00:00:00 2001 From: Rocky Kwok Date: Sun, 26 Jul 2026 17:01:09 +0800 Subject: [PATCH 16/16] =?UTF-8?q?fix:iframe=E5=88=87=E6=8D=A2=E9=A2=9C?= =?UTF-8?q?=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/src/App.tsx | 5 ++++- .../page-layout/chats-iframe-route.test.mjs | 17 +++++++++++++++++ .../src/core/page-layout/chats-iframe-route.ts | 13 +++++++++++-- 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index a4a67c48..0235b013 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -61,7 +61,10 @@ function AppProviders({ children }: { children: React.ReactNode }) { // 走 next-themes forcedTheme(不写 localStorage)→ 同浏览器另开的正常系统页不受影响。 const embedTheme = resolveForcedEmbedTheme(location.search); if (embedTheme) return embedTheme; - const routeTheme = resolveForcedThemeForPath(location.pathname); + const routeTheme = resolveForcedThemeForPath( + location.pathname, + location.search, + ); if (routeTheme) return routeTheme; if (location.pathname === "/embed/roundtable") { const wp = readWujieProps(); diff --git a/frontend-web/src/core/page-layout/chats-iframe-route.test.mjs b/frontend-web/src/core/page-layout/chats-iframe-route.test.mjs index eefe33b2..5cdb649b 100644 --- a/frontend-web/src/core/page-layout/chats-iframe-route.test.mjs +++ b/frontend-web/src/core/page-layout/chats-iframe-route.test.mjs @@ -31,3 +31,20 @@ test("forces light theme for landing and chats_iframe routes only", () => { ); assert.equal(resolveForcedThemeForPath("/page/workspace/chats/new"), undefined); }); + +test("honors the dark-blue theme query on chats_iframe routes", () => { + assert.equal( + resolveForcedThemeForPath( + "/page/workspace/chats_iframe/new", + "?from=iframe&theme=dark-blue", + ), + "dark", + ); + assert.equal( + resolveForcedThemeForPath( + "/page/workspace/chats_iframe/new", + "?from=iframe&theme=red", + ), + "light", + ); +}); diff --git a/frontend-web/src/core/page-layout/chats-iframe-route.ts b/frontend-web/src/core/page-layout/chats-iframe-route.ts index 30c6ba78..06b13117 100644 --- a/frontend-web/src/core/page-layout/chats-iframe-route.ts +++ b/frontend-web/src/core/page-layout/chats-iframe-route.ts @@ -1,3 +1,5 @@ +import { resolveEmbedThemeMode } from "@/core/embed/embed-session"; + export function isChatsIframeWorkspacePath(pathname: string): boolean { return ( pathname === "/page/workspace/chats_iframe/new" || @@ -7,10 +9,17 @@ export function isChatsIframeWorkspacePath(pathname: string): boolean { export function resolveForcedThemeForPath( pathname: string, -): "light" | undefined { - if (pathname === "/landing" || isChatsIframeWorkspacePath(pathname)) { + search = "", +): "light" | "dark" | undefined { + if (pathname === "/landing") { return "light"; } + if (isChatsIframeWorkspacePath(pathname)) { + const normalizedSearch = search.startsWith("?") ? search : `?${search}`; + const rawTheme = new URLSearchParams(normalizedSearch).get("theme")?.trim(); + return rawTheme ? resolveEmbedThemeMode(rawTheme) : "light"; + } + return undefined; }