From d39b58255ded424554af2fd9b8ddadb40836df7b Mon Sep 17 00:00:00 2001 From: Administrator Date: Mon, 29 Jun 2026 16:09:51 +0800 Subject: [PATCH] =?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({
-
+