并发版
This commit is contained in:
1 parent
cd2ca406a5
commit
d39b58255d
11 files changed
+530
-73
No files matched your search
@@ -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,
|
||||
|
||||
@@ -449,12 +449,16 @@ export interface ModelConcurrencySettings {
|
||||
enabled: boolean;
|
||||
default_limit: number;
|
||||
model_limits: Record<string, number>;
|
||||
user_default_limit: number;
|
||||
user_model_limits: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ModelConcurrencyUpdate {
|
||||
enabled?: boolean;
|
||||
default_limit?: number;
|
||||
model_limits?: Record<string, number>;
|
||||
user_default_limit?: number;
|
||||
user_model_limits?: Record<string, number>;
|
||||
}
|
||||
|
||||
export async function loadSkillCompressionSettings(): Promise<SkillCompressionSettings> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -13,6 +13,21 @@ import {
|
||||
useUpdateModelConcurrencySettings,
|
||||
} from "@/core/system-settings/hooks";
|
||||
|
||||
function cleanLimitMap(values: Record<string, string>, label: string): Record<string, number> | null {
|
||||
const cleaned: Record<string, number> = {};
|
||||
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<Record<string, string>>({});
|
||||
const [userModelLimits, setUserModelLimits] = useState<Record<string, string>>({});
|
||||
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<string, number> = {};
|
||||
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"
|
||||
}
|
||||
>
|
||||
<div>
|
||||
@@ -93,7 +117,7 @@ export default function ModelConcurrencySettingsPage({
|
||||
模型并发限制
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
按模型限制同时进行中的问答数。达到上限后,新请求会收到“当前使用人数较多,请稍等一下再试”的提示。
|
||||
按模型控制系统总并发,也可以限制单个用户对某个模型同时发起的调用数。达到上限时会在发送问题后立即提示,问题会保留在对话中。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -102,42 +126,59 @@ export default function ModelConcurrencySettingsPage({
|
||||
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive rounded-xl border border-dashed py-16 text-center text-sm">
|
||||
<div className="text-destructive rounded-lg border border-dashed py-16 text-center text-sm">
|
||||
加载失败:{error.message}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 rounded-xl border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<div className="flex flex-col gap-5 rounded-lg border bg-white px-5 py-5 dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">开启限制</div>
|
||||
<div className="text-muted-foreground mt-0.5 text-xs">
|
||||
关闭后,不再按模型并发数拦截问答请求。
|
||||
关闭后,不再按模型或用户并发数拦截问答请求。
|
||||
</div>
|
||||
</div>
|
||||
<Switch checked={enabled} onCheckedChange={setEnabled} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium">默认上限</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
未单独配置的模型,使用这个并发上限。
|
||||
</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10000}
|
||||
value={defaultLimit}
|
||||
disabled={!enabled}
|
||||
onChange={(e) => setDefaultLimit(Number(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium">模型默认总上限</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
未单独配置的模型,使用这个系统总并发上限。
|
||||
</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10000}
|
||||
value={defaultLimit}
|
||||
disabled={!enabled}
|
||||
onChange={(e) => setDefaultLimit(Number(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-sm font-medium">单用户默认上限</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
同一用户对同一模型的默认并发上限。填 0 表示不启用默认用户限制。
|
||||
</p>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10000}
|
||||
value={userDefaultLimit}
|
||||
disabled={!enabled}
|
||||
onChange={(e) => setUserDefaultLimit(Number(e.target.value))}
|
||||
className="w-32"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium">按模型单独设置</p>
|
||||
<p className="text-muted-foreground mt-0.5 text-xs">
|
||||
留空表示使用默认上限。
|
||||
留空表示使用对应默认值;单用户上限填空且默认值为 0 时,不限制该维度。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -150,24 +191,42 @@ export default function ModelConcurrencySettingsPage({
|
||||
modelNames.map((modelName) => (
|
||||
<div
|
||||
key={modelName}
|
||||
className="flex items-center justify-between gap-4 rounded-lg border px-4 py-3"
|
||||
className="grid gap-3 rounded-lg border px-4 py-3 md:grid-cols-[minmax(0,1fr)_8rem_8rem]"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{modelName}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
留空时使用默认上限 {defaultLimit || 10}
|
||||
默认总上限 {defaultLimit || 10};默认单用户上限 {userDefaultLimit || "不限制"}
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10000}
|
||||
value={modelLimits[modelName] ?? ""}
|
||||
disabled={!enabled}
|
||||
onChange={(e) => handleModelLimitChange(modelName, e.target.value)}
|
||||
className="w-32"
|
||||
placeholder={String(defaultLimit || 10)}
|
||||
/>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">模型总上限</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10000}
|
||||
value={modelLimits[modelName] ?? ""}
|
||||
disabled={!enabled}
|
||||
onChange={(e) =>
|
||||
setModelLimits((prev) => ({ ...prev, [modelName]: e.target.value }))
|
||||
}
|
||||
placeholder={String(defaultLimit || 10)}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">单用户上限</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10000}
|
||||
value={userModelLimits[modelName] ?? ""}
|
||||
disabled={!enabled}
|
||||
onChange={(e) =>
|
||||
setUserModelLimits((prev) => ({ ...prev, [modelName]: e.target.value }))
|
||||
}
|
||||
placeholder={userDefaultLimit > 0 ? String(userDefaultLimit) : "不限制"}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -321,14 +321,49 @@ function KnowledgeMarkdownView({
|
||||
() => anchorResolver.headings.filter((item) => item.level >= 1 && item.level <= 3),
|
||||
[anchorResolver],
|
||||
);
|
||||
const [activeHeadingId, setActiveHeadingId] = useState<string | null>(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<HTMLElement>(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<HTMLElement>(selectorForId(id)) ?? document.getElementById(id);
|
||||
setActiveHeadingId(id);
|
||||
target?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}, []);
|
||||
|
||||
@@ -336,12 +371,12 @@ function KnowledgeMarkdownView({
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={cn(
|
||||
"grid min-h-0 gap-4 lg:grid-cols-[minmax(0,1fr)_260px]",
|
||||
"grid min-h-0 gap-5 lg:grid-cols-[minmax(0,1fr)_300px]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<article className="min-w-0 rounded-md border border-slate-200/70 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950">
|
||||
<div className="border-b border-slate-100 bg-slate-50/80 px-5 py-4 dark:border-slate-800 dark:bg-slate-900/60">
|
||||
<article className="min-w-0 overflow-hidden rounded-lg border border-slate-200/80 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950">
|
||||
<div className="border-b border-slate-100 bg-slate-50/90 px-6 py-5 dark:border-slate-800 dark:bg-slate-900/70 md:px-8">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-white px-2 py-1 font-medium text-slate-600 ring-1 ring-slate-200 dark:bg-slate-950 dark:text-slate-300 dark:ring-slate-800">
|
||||
<BookOpen className="size-3.5" />
|
||||
@@ -368,7 +403,7 @@ function KnowledgeMarkdownView({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wiki-markdown px-5 py-5 md:px-7 md:py-6">
|
||||
<div className="wiki-markdown public-report-markdown mx-auto w-full max-w-[980px] px-6 py-7 md:px-10 md:py-9">
|
||||
<MarkdownContent
|
||||
className="max-w-none"
|
||||
content={content}
|
||||
@@ -380,19 +415,21 @@ function KnowledgeMarkdownView({
|
||||
</article>
|
||||
|
||||
<aside className="hidden min-w-0 lg:block">
|
||||
<div className="sticky top-3 rounded-md border border-slate-200/70 bg-white p-3 shadow-sm dark:border-slate-800 dark:bg-slate-950">
|
||||
<div className="sticky top-4 rounded-lg border border-slate-200/80 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-950">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-slate-800 dark:text-slate-100">
|
||||
<List className="size-4" />
|
||||
内容目录
|
||||
</div>
|
||||
{headings.length > 0 ? (
|
||||
<nav className="max-h-[calc(100vh-260px)] space-y-1 overflow-auto pr-1">
|
||||
<nav className="max-h-[calc(100vh-210px)] space-y-1 overflow-auto pr-1">
|
||||
{headings.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"block w-full truncate rounded-md px-2 py-1.5 text-left text-xs text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-slate-50",
|
||||
"block w-full truncate rounded-md border-l-2 border-transparent px-2 py-1.5 text-left text-xs leading-5 text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-950 dark:text-slate-300 dark:hover:bg-slate-900 dark:hover:text-slate-50",
|
||||
activeHeadingId === item.id &&
|
||||
"border-primary bg-primary/10 font-medium text-primary dark:text-primary",
|
||||
item.level === 2 && "pl-4",
|
||||
item.level === 3 && "pl-7 text-slate-500 dark:text-slate-400",
|
||||
)}
|
||||
|
||||
@@ -21,7 +21,6 @@ from deerflow.config.system_settings import (
|
||||
PublishSquare,
|
||||
PublishSquaresSettings,
|
||||
SkillCompressionSettings,
|
||||
SystemSettings,
|
||||
get_module_tag_ids,
|
||||
list_publish_squares,
|
||||
load_system_settings,
|
||||
@@ -223,12 +222,16 @@ class ModelConcurrencyResponse(BaseModel):
|
||||
enabled: bool = Field(..., description="Whether per-model concurrency limiting is active")
|
||||
default_limit: int = Field(..., description="Fallback max concurrent runs for models without an explicit override")
|
||||
model_limits: dict[str, int] = Field(default_factory=dict, description="Per-model overrides: model_name -> max concurrent runs")
|
||||
user_default_limit: int = Field(..., description="Fallback max concurrent runs per user and model. 0 disables the per-user fallback")
|
||||
user_model_limits: dict[str, int] = Field(default_factory=dict, description="Per-user per-model overrides: model_name -> max concurrent runs for one user")
|
||||
|
||||
|
||||
class ModelConcurrencyUpdateRequest(BaseModel):
|
||||
enabled: bool | None = None
|
||||
default_limit: int | None = Field(default=None, ge=1, le=10_000)
|
||||
model_limits: dict[str, int] | None = None
|
||||
user_default_limit: int | None = Field(default=None, ge=0, le=10_000)
|
||||
user_model_limits: dict[str, int] | None = None
|
||||
|
||||
|
||||
def _skill_compression_to_response(settings: SkillCompressionSettings) -> SkillCompressionResponse:
|
||||
@@ -252,10 +255,24 @@ def _model_concurrency_to_response(settings: ModelConcurrencySettings) -> ModelC
|
||||
if normalized_limit < 1:
|
||||
continue
|
||||
cleaned[model_name] = min(normalized_limit, 10_000)
|
||||
cleaned_user: dict[str, int] = {}
|
||||
for name, limit in (settings.user_model_limits or {}).items():
|
||||
model_name = str(name).strip()
|
||||
if not model_name:
|
||||
continue
|
||||
try:
|
||||
normalized_limit = int(limit)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if normalized_limit < 1:
|
||||
continue
|
||||
cleaned_user[model_name] = min(normalized_limit, 10_000)
|
||||
return ModelConcurrencyResponse(
|
||||
enabled=settings.enabled,
|
||||
default_limit=max(1, min(int(settings.default_limit), 10_000)),
|
||||
model_limits=cleaned,
|
||||
user_default_limit=max(0, min(int(getattr(settings, "user_default_limit", 0) or 0), 10_000)),
|
||||
user_model_limits=cleaned_user,
|
||||
)
|
||||
|
||||
|
||||
@@ -333,6 +350,22 @@ async def update_model_concurrency(
|
||||
continue
|
||||
cleaned[model_name] = min(normalized_limit, 10_000)
|
||||
update["model_limits"] = cleaned
|
||||
if "user_default_limit" in fields and fields["user_default_limit"] is not None:
|
||||
update["user_default_limit"] = max(0, min(int(fields["user_default_limit"]), 10_000))
|
||||
if "user_model_limits" in fields and fields["user_model_limits"] is not None:
|
||||
cleaned_user: dict[str, int] = {}
|
||||
for name, limit in (body.user_model_limits or {}).items():
|
||||
model_name = str(name).strip()
|
||||
if not model_name:
|
||||
continue
|
||||
try:
|
||||
normalized_limit = int(limit)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if normalized_limit < 1:
|
||||
continue
|
||||
cleaned_user[model_name] = min(normalized_limit, 10_000)
|
||||
update["user_model_limits"] = cleaned_user
|
||||
settings = current.model_concurrency.model_copy(update=update)
|
||||
saved = save_system_settings(current.model_copy(update={"model_concurrency": settings}))
|
||||
return _model_concurrency_to_response(saved.model_concurrency)
|
||||
|
||||
@@ -36,6 +36,7 @@ from deerflow.runtime import (
|
||||
UnsupportedStrategyError,
|
||||
run_agent,
|
||||
)
|
||||
from deerflow.runtime.user_context import get_effective_user_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -498,6 +499,49 @@ def _resolve_effective_run_model_name(body: Any) -> str:
|
||||
return default_model_name
|
||||
|
||||
|
||||
def _resolve_effective_model_name(body: Any, app_config: Any | None = None) -> str:
|
||||
"""Compatibility wrapper for resolving the effective run model."""
|
||||
if app_config is None:
|
||||
return _resolve_effective_run_model_name(body)
|
||||
|
||||
default_model_name = app_config.models[0].name if app_config.models else None
|
||||
if not default_model_name:
|
||||
raise ValueError("No chat models are configured. Please configure at least one model in config.yaml.")
|
||||
|
||||
requested_model_name = _extract_requested_model_name(body)
|
||||
if requested_model_name and app_config.get_model_config(requested_model_name):
|
||||
return requested_model_name
|
||||
|
||||
agent_id = _extract_effective_agent_id(body)
|
||||
if agent_id:
|
||||
try:
|
||||
agent_config = load_agent_config(agent_id)
|
||||
except Exception:
|
||||
agent_config = None
|
||||
agent_model_name = str(getattr(agent_config, "model", "") or "").strip()
|
||||
if agent_model_name and app_config.get_model_config(agent_model_name):
|
||||
return agent_model_name
|
||||
|
||||
return default_model_name
|
||||
|
||||
|
||||
def _resolve_model_concurrency_limit(model_name: str, app_config: Any) -> int | None:
|
||||
"""Read legacy per-model max-concurrency values from model config extras."""
|
||||
model_config = app_config.get_model_config(model_name)
|
||||
extras = getattr(model_config, "model_extra", None) or {}
|
||||
for key in ("max_concurrency", "model_concurrency_limit"):
|
||||
raw = extras.get(key)
|
||||
if raw is None:
|
||||
continue
|
||||
try:
|
||||
limit = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if limit > 0:
|
||||
return limit
|
||||
return None
|
||||
|
||||
|
||||
def _get_model_concurrency_limit(model_name: str) -> int | None:
|
||||
"""Return the configured limit for a model, or ``None`` when limiting is off."""
|
||||
settings = load_system_settings().model_concurrency
|
||||
@@ -512,6 +556,25 @@ def _get_model_concurrency_limit(model_name: str) -> int | None:
|
||||
return max(1, limit)
|
||||
|
||||
|
||||
def _get_user_model_concurrency_limit(model_name: str) -> int | None:
|
||||
"""Return the configured per-user limit for a model, or ``None`` when off."""
|
||||
settings = load_system_settings().model_concurrency
|
||||
if not settings.enabled:
|
||||
return None
|
||||
|
||||
raw_limit = settings.user_model_limits.get(
|
||||
model_name,
|
||||
getattr(settings, "user_default_limit", 0) or 0,
|
||||
)
|
||||
try:
|
||||
limit = int(raw_limit)
|
||||
except (TypeError, ValueError):
|
||||
limit = int(getattr(settings, "user_default_limit", 0) or 0)
|
||||
if limit < 1:
|
||||
return None
|
||||
return max(1, limit)
|
||||
|
||||
|
||||
async def _sync_legacy_agent_records(store) -> None:
|
||||
"""Import existing folder-backed agents as built-in records.
|
||||
|
||||
@@ -688,10 +751,13 @@ async def start_run(
|
||||
t_post_enforce = time.perf_counter()
|
||||
|
||||
effective_model_name = _resolve_effective_run_model_name(body)
|
||||
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_metadata = dict(body.metadata or {})
|
||||
run_metadata["model_name"] = effective_model_name
|
||||
run_metadata["user_id"] = effective_user_id
|
||||
|
||||
try:
|
||||
record = await run_mgr.create_or_reject(
|
||||
@@ -702,9 +768,14 @@ async def start_run(
|
||||
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,
|
||||
)
|
||||
except ModelConcurrencyLimitError as exc:
|
||||
raise HTTPException(status_code=429, detail="当前使用人数较多,请稍等一下再试。") from exc
|
||||
if getattr(exc, "scope", "") == "user_model":
|
||||
detail = "当前账号对该模型的并发调用已达到上限,请等待已有回答结束后再试。"
|
||||
else:
|
||||
detail = "当前使用人数较多,请稍等一下再试。"
|
||||
raise HTTPException(status_code=429, detail=detail) from exc
|
||||
except ConflictError as exc:
|
||||
# 同一会话上一轮回答还没结束,又提交了新问题 → 拒绝。给用户中文提示,
|
||||
# 而不是把内部英文「Thread ... already has an active run」直接抛到前端。
|
||||
|
||||
@@ -286,6 +286,16 @@ class ModelConcurrencySettings(BaseModel):
|
||||
default_factory=dict,
|
||||
description="Optional per-model overrides: model_name -> max concurrent runs.",
|
||||
)
|
||||
user_default_limit: int = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
le=10_000,
|
||||
description="Fallback max concurrent runs per user and model. 0 disables the per-user fallback.",
|
||||
)
|
||||
user_model_limits: dict[str, int] = Field(
|
||||
default_factory=dict,
|
||||
description="Optional per-user per-model overrides: model_name -> max concurrent runs for one user.",
|
||||
)
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
|
||||
@@ -218,6 +218,7 @@ class RunManager:
|
||||
kwargs: dict | None = None,
|
||||
multitask_strategy: str = "reject",
|
||||
model_concurrency_limit: tuple[str, int] | None = None,
|
||||
user_model_concurrency_limit: tuple[str, str, int] | None = None,
|
||||
) -> RunRecord:
|
||||
"""Atomically check for inflight runs and create a new one.
|
||||
|
||||
@@ -261,7 +262,19 @@ class RunManager:
|
||||
model_name, limit = model_concurrency_limit
|
||||
active_for_model = sum(1 for r in self._runs.values() if r.status in (RunStatus.pending, RunStatus.running) and str((r.metadata or {}).get("model_name") or "").strip() == model_name)
|
||||
if active_for_model >= max(1, int(limit)):
|
||||
raise ModelConcurrencyLimitError(model_name, limit)
|
||||
raise ModelConcurrencyLimitError(model_name, limit, scope="model")
|
||||
|
||||
if user_model_concurrency_limit is not None:
|
||||
model_name, user_id, limit = user_model_concurrency_limit
|
||||
active_for_user_model = sum(
|
||||
1
|
||||
for r in self._runs.values()
|
||||
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
|
||||
)
|
||||
if active_for_user_model >= max(1, int(limit)):
|
||||
raise ModelConcurrencyLimitError(model_name, limit, scope="user_model", user_id=user_id)
|
||||
|
||||
record = RunRecord(
|
||||
run_id=run_id,
|
||||
@@ -316,9 +329,11 @@ class ConflictError(Exception):
|
||||
class ModelConcurrencyLimitError(Exception):
|
||||
"""Raised when a model-specific in-flight run limit has been reached."""
|
||||
|
||||
def __init__(self, model_name: str, limit: int) -> None:
|
||||
def __init__(self, model_name: str, limit: int, *, scope: str = "model", user_id: str | None = None) -> None:
|
||||
self.model_name = model_name
|
||||
self.limit = limit
|
||||
self.scope = scope
|
||||
self.user_id = user_id
|
||||
super().__init__(f"Model {model_name} has reached its concurrency limit")
|
||||
|
||||
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
"""Small in-memory gate used by legacy model-concurrency tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
|
||||
class ModelConcurrencyGate:
|
||||
"""Track per-model active counts with atomic try-acquire semantics."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = asyncio.Lock()
|
||||
self._counts: dict[str, int] = {}
|
||||
|
||||
async def try_acquire(self, model_name: str, limit: int) -> bool:
|
||||
key = str(model_name).strip()
|
||||
if not key:
|
||||
return True
|
||||
async with self._lock:
|
||||
current = self._counts.get(key, 0)
|
||||
if current >= max(1, int(limit)):
|
||||
return False
|
||||
self._counts[key] = current + 1
|
||||
return True
|
||||
|
||||
async def release(self, model_name: str) -> None:
|
||||
key = str(model_name).strip()
|
||||
if not key:
|
||||
return
|
||||
async with self._lock:
|
||||
current = self._counts.get(key, 0)
|
||||
if current <= 1:
|
||||
self._counts.pop(key, None)
|
||||
else:
|
||||
self._counts[key] = current - 1
|
||||
|
||||
async def snapshot(self) -> dict[str, int]:
|
||||
async with self._lock:
|
||||
return dict(self._counts)
|
||||
@@ -11,6 +11,7 @@ import pytest
|
||||
from app.gateway.routers import system_settings as ss_router
|
||||
from app.gateway.services import (
|
||||
_get_model_concurrency_limit,
|
||||
_get_user_model_concurrency_limit,
|
||||
_resolve_effective_run_model_name,
|
||||
)
|
||||
from deerflow.config import system_settings as ss
|
||||
@@ -52,23 +53,31 @@ async def test_model_concurrency_settings_round_trip(
|
||||
enabled=True,
|
||||
default_limit=8,
|
||||
model_limits={"glm5": 10, "deepseek": 4},
|
||||
user_default_limit=2,
|
||||
user_model_limits={"glm5": 1},
|
||||
),
|
||||
req,
|
||||
)
|
||||
assert saved.enabled is True
|
||||
assert saved.default_limit == 8
|
||||
assert saved.model_limits == {"glm5": 10, "deepseek": 4}
|
||||
assert saved.user_default_limit == 2
|
||||
assert saved.user_model_limits == {"glm5": 1}
|
||||
|
||||
file_path = tmp_runtime_home / "system_settings.json"
|
||||
payload = json.loads(file_path.read_text(encoding="utf-8"))
|
||||
assert payload["model_concurrency"]["enabled"] is True
|
||||
assert payload["model_concurrency"]["default_limit"] == 8
|
||||
assert payload["model_concurrency"]["model_limits"]["glm5"] == 10
|
||||
assert payload["model_concurrency"]["user_default_limit"] == 2
|
||||
assert payload["model_concurrency"]["user_model_limits"]["glm5"] == 1
|
||||
|
||||
loaded = await ss_router.get_model_concurrency(req)
|
||||
assert loaded.enabled is True
|
||||
assert loaded.default_limit == 8
|
||||
assert loaded.model_limits["deepseek"] == 4
|
||||
assert loaded.user_default_limit == 2
|
||||
assert loaded.user_model_limits["glm5"] == 1
|
||||
|
||||
|
||||
def test_resolve_effective_run_model_name_prefers_requested_model(
|
||||
@@ -183,6 +192,44 @@ async def test_run_manager_rejects_model_limit_atomically(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_manager_rejects_user_model_limit_atomically(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"app.gateway.services.load_system_settings",
|
||||
lambda: SimpleNamespace(
|
||||
model_concurrency=SimpleNamespace(
|
||||
enabled=True,
|
||||
default_limit=10,
|
||||
model_limits={},
|
||||
user_default_limit=3,
|
||||
user_model_limits={"glm5": 1},
|
||||
)
|
||||
),
|
||||
)
|
||||
assert _get_user_model_concurrency_limit("glm5") == 1
|
||||
assert _get_user_model_concurrency_limit("other") == 3
|
||||
|
||||
run_mgr = RunManager()
|
||||
await run_mgr.create_or_reject("t1", metadata={"model_name": "glm5", "user_id": "u1"})
|
||||
await run_mgr.create_or_reject(
|
||||
"t2",
|
||||
metadata={"model_name": "glm5", "user_id": "u2"},
|
||||
user_model_concurrency_limit=("glm5", "u2", 1),
|
||||
)
|
||||
|
||||
with pytest.raises(ModelConcurrencyLimitError) as exc_info:
|
||||
await run_mgr.create_or_reject(
|
||||
"t3",
|
||||
metadata={"model_name": "glm5", "user_id": "u1"},
|
||||
user_model_concurrency_limit=("glm5", "u1", 1),
|
||||
)
|
||||
|
||||
assert exc_info.value.scope == "user_model"
|
||||
assert exc_info.value.user_id == "u1"
|
||||
|
||||
|
||||
def test_get_model_concurrency_limit_skips_when_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -193,7 +240,10 @@ def test_get_model_concurrency_limit_skips_when_disabled(
|
||||
enabled=False,
|
||||
default_limit=1,
|
||||
model_limits={"glm5": 1},
|
||||
user_default_limit=1,
|
||||
user_model_limits={"glm5": 1},
|
||||
)
|
||||
),
|
||||
)
|
||||
assert _get_model_concurrency_limit("glm5") is None
|
||||
assert _get_user_model_concurrency_limit("glm5") is None
|
||||
Reference in new issue
Block a user