Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions apps/backend/src/http/routes/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,37 @@ async function modelProviderAction<T>(action: () => Promise<T>): Promise<T> {
}
}

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<AppEnv>()
Expand All @@ -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()
Expand Down
107 changes: 107 additions & 0 deletions apps/backend/test/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
9 changes: 6 additions & 3 deletions apps/frontend/src/hooks/useChallengeDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function useChallengeDetails(
target: ConversationTarget
selectRun: (runId: string) => void
selectSubagent: (subagentId: string | null) => void
challengeAction: (action: "dispatch" | "cancel") => Promise<void>
challengeAction: (action: "dispatch" | "cancel", options?: { force?: boolean }) => Promise<void>
resetChallenge: () => Promise<void>
reload: () => Promise<void>
} {
Expand Down Expand Up @@ -82,14 +82,17 @@ export function useChallengeDetails(
: { type: "solver" }

const challengeAction = useCallback(
async (action: "dispatch" | "cancel"): Promise<void> => {
async (action: "dispatch" | "cancel", options: { force?: boolean } = {}): Promise<void> => {
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) {
Expand Down
52 changes: 44 additions & 8 deletions apps/frontend/src/pages/ChallengePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ConversationViewStateStore>(new Map())
const { projectId = "", challengeId = "" } = useParams()
const {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"
>
<Icon icon={active ? "lucide:square" : "lucide:play"} width="14" />
{active ? "终止" : c.status === "blocked" ? "重新派发" : "派发"}
<Icon icon={dispatchIcon} width="14" />
{dispatchLabel}
</Button>
</div>
</div>
Expand Down Expand Up @@ -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"
>
<Icon icon={active ? "lucide:square" : "lucide:play"} width="14" />
{active ? "终止" : c.status === "blocked" ? "重新派发" : "派发"}
<Icon icon={dispatchIcon} width="14" />
{dispatchLabel}
</Button>
</div>
</div>
Expand All @@ -351,6 +374,19 @@ export function ChallengePage() {
</SheetContent>
</Sheet>

<ConfirmDialog
open={forceDispatchOpen}
onOpenChange={setForceDispatchOpen}
title="强制派发已解出的题目?"
description="这会保留现有解题记录并创建新的 Attempt,用于重新验证题目。"
confirmLabel="确认强制派发"
busy={busy}
busyLabel="正在派发…"
onConfirm={() => {
void challengeAction("dispatch", { force: true }).then(() => setForceDispatchOpen(false))
}}
/>

<ConfirmDialog
open={resetOpen}
onOpenChange={setResetOpen}
Expand Down
Loading