并发版

This commit is contained in:
Administrator committed 2026-06-29 16:09:51 +08:00
1 parent cd2ca406a5
commit d39b58255d
11 files changed
+530 -73

No files matched your search

+109
View File
@@ -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> {
+43 -13
View File
@@ -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",
)}