diff --git a/apps/backend/src/http/routes/models.ts b/apps/backend/src/http/routes/models.ts index a1c8a0a..bffc26f 100644 --- a/apps/backend/src/http/routes/models.ts +++ b/apps/backend/src/http/routes/models.ts @@ -30,6 +30,37 @@ async function modelProviderAction(action: () => Promise): Promise { } } +function assertRemovedModelsAreUnused( + services: Services, + currentModelIds: readonly string[], + nextModelIds: readonly string[], +): void { + const nextIds = new Set(nextModelIds) + const removedIds = new Set(currentModelIds.filter((modelId) => !nextIds.has(modelId))) + if (removedIds.size === 0 || !services.controlPlane) return + + const conflicts = services.controlPlane.projectSnapshots().flatMap((project) => { + const runtime = services.runtime(project.id)?.snapshot() + if (!runtime) return [] + + const bindings: string[] = [] + if (removedIds.has(runtime.solverModelId)) bindings.push(`Solver (${runtime.solverModelId})`) + if (removedIds.has(runtime.tldrModelId)) bindings.push(`TLDR (${runtime.tldrModelId})`) + for (const [role, modelId] of Object.entries(runtime.subagentModels)) { + if (removedIds.has(modelId)) bindings.push(`SubAgent ${role} (${modelId})`) + } + return bindings.length > 0 ? [`${project.name} [${project.id}]:${bindings.join("、")}`] : [] + }) + + if (conflicts.length > 0) { + throw new HttpError( + 409, + "model_in_use", + `模型仍被比赛引用,请先在比赛设置中更换模型:${conflicts.join(";")}`, + ) + } +} + /** 模型配置与凭据路由(全局版)。 */ export function modelsRoutes(services: Services): App { const app = new Hono() @@ -44,6 +75,11 @@ export function modelsRoutes(services: Services): App { code: "invalid_model_configuration", maximumSize: 256_000, }) + assertRemovedModelsAreUnused( + services, + modelConfiguration.snapshot().models.map((model) => model.id), + body.models.map((model) => model.id), + ) const configuration = await modelConfiguration.update(body) const scheduler = services.currentScheduler() if (!configuration.configured) scheduler?.pauseAuto() diff --git a/apps/backend/test/models.test.ts b/apps/backend/test/models.test.ts index 4f2a1c1..f7d2419 100644 --- a/apps/backend/test/models.test.ts +++ b/apps/backend/test/models.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path" import { afterEach, describe, expect, it, vi } from "vite-plus/test" import { createFileModelAuthentication } from "../src/domain/model-auth.ts" import { createWebUiApp } from "../src/server.ts" +import type { DashboardControlPlane } from "../src/types.ts" import { FakeDashboardProject, FakeDashboardScheduler, @@ -80,6 +81,112 @@ describe("模型配置与模型鉴权路由", () => { }) }) + it("删除比赛仍在使用的模型时返回具体引用,重新绑定后允许删除", async () => { + const project = new FakeDashboardProject() + const modelConfiguration = new FakeModelConfiguration() + const model = (id: string) => ({ + id, + provider: "openai", + modelId: id, + contextWindow: 128_000, + thinkingEnabled: false, + reasoningEffort: "off", + passthroughThinking: false, + supportedThinkingLevels: ["off"], + configured: true, + }) + modelConfiguration.value = { + models: [model("old-model"), model("new-model")], + customProviders: [], + solverModelId: "old-model", + tldrModelId: "old-model", + fatherModelId: "old-model", + subagentModels: {}, + supportedThinkingLevels: ["off"], + configured: true, + } + let runtime = { + solverModelId: "old-model", + tldrModelId: "new-model", + subagentModels: { helper: "old-model" }, + maxConcurrent: 2, + maxRemoteEnvironments: 1, + } + const projectSnapshot = { + id: "training", + name: "训练赛", + platformId: "ctfd", + phase: "ready" as const, + lastError: null, + createdAt: "2026-08-19T00:00:00.000Z", + updatedAt: "2026-08-19T00:00:00.000Z", + archived: false, + } + const controlPlaneSnapshot = { + phase: "ready" as const, + configured: true, + projectId: projectSnapshot.id, + projectName: projectSnapshot.name, + platformId: projectSnapshot.platformId, + lastError: null, + updatedAt: projectSnapshot.updatedAt, + selectedProjectId: projectSnapshot.id, + projects: [projectSnapshot], + } + const controlPlane = { + snapshot: () => controlPlaneSnapshot, + projectSnapshots: () => [projectSnapshot], + currentProject: () => project, + currentScheduler: () => undefined, + } as unknown as DashboardControlPlane + const { app } = createWebUiApp({ + controlPlane, + modelConfiguration, + projectRuntimeConfiguration: (projectId) => + projectId === "training" + ? { + snapshot: () => structuredClone(runtime), + update: async (configuration) => configuration, + } + : undefined, + frontendDist: "/nonexistent", + }) + const payload = { + models: [ + { + id: "new-model", + provider: "openai", + modelId: "new-model", + contextWindow: 128_000, + thinkingEnabled: false, + reasoningEffort: "off", + passthroughThinking: false, + }, + ], + solverModelId: "new-model", + tldrModelId: "new-model", + fatherModelId: "new-model", + subagentModels: {}, + } + + const blocked = await jsonRequest(app, "/api/model-config", "POST", payload) + expect(blocked.status).toBe(409) + expect(await jsonOf(blocked)).toMatchObject({ + error: "model_in_use", + message: expect.stringContaining( + "训练赛 [training]:Solver (old-model)、SubAgent helper (old-model)", + ), + }) + + runtime = { + ...runtime, + solverModelId: "new-model", + subagentModels: { helper: "new-model" }, + } + const saved = await jsonRequest(app, "/api/model-config", "POST", payload) + expect(saved.status).toBe(200) + }) + it("创建、编辑和删除自定义兼容供应商", async () => { const modelConfiguration = new FakeModelConfiguration() const { app } = createWebUiApp({ diff --git a/apps/frontend/src/hooks/useChallengeDetails.ts b/apps/frontend/src/hooks/useChallengeDetails.ts index 598ab92..4da5040 100644 --- a/apps/frontend/src/hooks/useChallengeDetails.ts +++ b/apps/frontend/src/hooks/useChallengeDetails.ts @@ -27,7 +27,7 @@ export function useChallengeDetails( target: ConversationTarget selectRun: (runId: string) => void selectSubagent: (subagentId: string | null) => void - challengeAction: (action: "dispatch" | "cancel") => Promise + challengeAction: (action: "dispatch" | "cancel", options?: { force?: boolean }) => Promise resetChallenge: () => Promise reload: () => Promise } { @@ -82,14 +82,17 @@ export function useChallengeDetails( : { type: "solver" } const challengeAction = useCallback( - async (action: "dispatch" | "cancel"): Promise => { + async (action: "dispatch" | "cancel", options: { force?: boolean } = {}): Promise => { setBusy(true) setError("") try { + const force = + action === "dispatch" && + (options.force === true || details?.challenge.status === "blocked") await apiMutation( projectApi(projectId, `/api/challenges/${encodeURIComponent(challengeId)}/${action}`), "POST", - action === "dispatch" && details?.challenge.status === "blocked" ? { force: true } : {}, + force ? { force: true } : {}, ) await load() } catch (cause) { diff --git a/apps/frontend/src/pages/ChallengePage.tsx b/apps/frontend/src/pages/ChallengePage.tsx index c474c67..9952a9a 100644 --- a/apps/frontend/src/pages/ChallengePage.tsx +++ b/apps/frontend/src/pages/ChallengePage.tsx @@ -40,6 +40,7 @@ import { WorkspaceFilesPanel } from "./challenge/WorkspaceFilesPanel.tsx" export function ChallengePage() { const [mobileDetailsOpen, setMobileDetailsOpen] = useState(false) const [resetOpen, setResetOpen] = useState(false) + const [forceDispatchOpen, setForceDispatchOpen] = useState(false) const conversationViewStates = useRef(new Map()) const { projectId = "", challengeId = "" } = useParams() const { @@ -69,6 +70,24 @@ export function ChallengePage() { const c = details.challenge const active = ["preparing", "running", "blocking", "writing_writeup"].includes(c.status) + const forceDispatch = c.status === "solved" + const dispatchLabel = active + ? "终止" + : forceDispatch + ? "强制派发" + : c.status === "blocked" + ? "重新派发" + : "派发" + const dispatchIcon = active ? "lucide:square" : forceDispatch ? "lucide:rotate-cw" : "lucide:play" + const runPrimaryAction = (): void => { + if (active) { + void challengeAction("cancel") + } else if (forceDispatch) { + setForceDispatchOpen(true) + } else { + void challengeAction("dispatch") + } + } const rid = selectedRun?.id ?? "" const convoUrl = selectedSubagent ? projectApi( @@ -274,13 +293,15 @@ export function ChallengePage() { title={ !active && !details.solverAvailable ? "平台故障或 Project 已归档,当前只能查看本地记录" - : undefined + : forceDispatch + ? "创建新的 Attempt,重新验证已经解出的题目" + : undefined } - onClick={() => void challengeAction(active ? "cancel" : "dispatch")} + onClick={runPrimaryAction} size="sm" > - - {active ? "终止" : c.status === "blocked" ? "重新派发" : "派发"} + + {dispatchLabel} @@ -326,13 +347,15 @@ export function ChallengePage() { title={ !active && !details.solverAvailable ? "平台故障或 Project 已归档,当前只能查看本地记录" - : undefined + : forceDispatch + ? "创建新的 Attempt,重新验证已经解出的题目" + : undefined } - onClick={() => void challengeAction(active ? "cancel" : "dispatch")} + onClick={runPrimaryAction} size="sm" > - - {active ? "终止" : c.status === "blocked" ? "重新派发" : "派发"} + + {dispatchLabel} @@ -351,6 +374,19 @@ export function ChallengePage() { + { + void challengeAction("dispatch", { force: true }).then(() => setForceDispatchOpen(false)) + }} + /> + (() => configuration.models.map(ed)) const [err, setErr] = useState("") const [busy, setBusy] = useState(false) + const [solverModelId, setSolverModelId] = useState(configuration.solverModelId) + const [tldrModelId, setTldrModelId] = useState(configuration.tldrModelId) const [fatherModelId, setFatherModelId] = useState( configuration.fatherModelId ?? configuration.solverModelId, ) + const [subagentModels, setSubagentModels] = useState(configuration.subagentModels) const [customModelIds, setCustomModelIds] = useState>({}) useEffect(() => { @@ -115,16 +118,38 @@ export function ModelPoolEditor({ [authentication.providers, configuration.customProviders], ) - function up(i: number, p: Partial) { - setModels((current) => - current.map((model, index) => { - if (index !== i) return model - const next = { ...model, ...p } - return next.automaticId ? { ...next, id: modelPoolEntryId(next) } : next - }), + function rebindModel(previousId: string, nextId: string): void { + const rebind = (modelId: string) => (modelId === previousId ? nextId : modelId) + setSolverModelId(rebind) + setTldrModelId(rebind) + setFatherModelId(rebind) + setSubagentModels((current) => + Object.fromEntries( + Object.entries(current).flatMap(([role, modelId]) => { + if (modelId !== previousId) return [[role, modelId]] + return nextId ? [[role, nextId]] : [] + }), + ), ) } + function up(i: number, patch: Partial): void { + const current = models[i] + if (!current) return + const patched = { ...current, ...patch } + const next = patched.automaticId ? { ...patched, id: modelPoolEntryId(patched) } : patched + if (next.id !== current.id) rebindModel(current.id, next.id) + setModels((entries) => entries.map((model, index) => (index === i ? next : model))) + } + + function removeModel(i: number): void { + const removed = models[i] + if (!removed) return + const remaining = models.filter((_, index) => index !== i) + setModels(remaining) + rebindModel(removed.id, remaining[0]?.id ?? "") + } + function modelOptions(providerId: string) { const prov = authentication.providers.find((p) => p.id === providerId) if (!prov) return [] @@ -143,10 +168,10 @@ export function ModelPoolEditor({ models: models.map( ({ uiKey: _, automaticId: __, supportedThinkingLevels: ___, ...m }) => m, ), - solverModelId: configuration.solverModelId, - tldrModelId: configuration.tldrModelId, + solverModelId, + tldrModelId, fatherModelId, - subagentModels: configuration.subagentModels, + subagentModels, } const r = await apiMutation<{ configuration: ModelConfigurationView }>( "/api/model-config", @@ -154,6 +179,10 @@ export function ModelPoolEditor({ payload, ) setModels(r.configuration.models.map(ed)) + setSolverModelId(r.configuration.solverModelId) + setTldrModelId(r.configuration.tldrModelId) + setFatherModelId(r.configuration.fatherModelId ?? r.configuration.solverModelId) + setSubagentModels(r.configuration.subagentModels) onSaved(r.configuration) } catch (e) { setErr(errorMessage(e)) @@ -167,21 +196,40 @@ export function ModelPoolEditor({ {err ? : null}
-

Clanker Father

+

Agent 默认模型

- 指定插件工坊使用的全局模型,不跟随比赛配置。 + 新建比赛继承 Solver 与 TLDR 默认值;Father 始终使用这里的全局配置。

- ({ - value: model.id, - label: model.id, - }))} - placeholder="选择 Father 模型" - /> +
+ + ({ value: model.id, label: model.id }))} + placeholder="选择 Solver 模型" + disabled={models.length === 0} + /> + + + ({ value: model.id, label: model.id }))} + placeholder="选择 TLDR 模型" + disabled={models.length === 0} + /> + + + ({ value: model.id, label: model.id }))} + placeholder="选择 Father 模型" + disabled={models.length === 0} + /> + +
@@ -217,7 +265,9 @@ export function ModelPoolEditor({ size="icon" variant="ghost" className="size-7 text-muted-foreground hover:text-danger" - onClick={() => setModels((c) => c.filter((_, j) => j !== i))} + aria-label={`删除模型 ${m.id}`} + title={`删除模型 ${m.id}`} + onClick={() => removeModel(i)} > @@ -380,7 +430,7 @@ export function ModelPoolEditor({ 添加 -