diff --git a/.gitignore b/.gitignore index 51a87af..d1d9e06 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,4 @@ CONTEXT.md backups/ .venv/ __pycache__/ -docs/ +/docs/ diff --git a/backend/src/alphapulse/api/settings.py b/backend/src/alphapulse/api/settings.py index 263ee66..65f5805 100644 --- a/backend/src/alphapulse/api/settings.py +++ b/backend/src/alphapulse/api/settings.py @@ -67,6 +67,7 @@ class ProviderPatch(BaseModel): context_window: Annotated[int | None, Field(ge=4096, le=2000000)] = None # 显式传 null 表示不传思考强度 reasoning_effort: llm.Effort | None = None + # 只能将另一模型设为默认,不能直接取消当前默认模型。 is_default: bool | None = None @@ -120,6 +121,8 @@ async def create_provider(body: ProviderIn, session: Annotated[AsyncSession, Dep @router.patch("/providers/{provider_id}") async def patch_provider(provider_id: int, body: ProviderPatch, session: Annotated[AsyncSession, Depends(get_session)]) -> ApiResponse[ProviderOut]: provider = await get_provider(session, provider_id) + if body.is_default is False and provider.is_default: + raise HTTPException(status.HTTP_409_CONFLICT, "不能直接取消默认模型,请将其他模型设为默认") values = body.model_dump(exclude_unset=True, exclude={"api_key", "base_url", "is_default"}) # reasoning_effort 显式传 null 表示清除,其他字段为 null 时忽略 for field, value in values.items(): diff --git a/backend/tests/test_provider_settings.py b/backend/tests/test_provider_settings.py new file mode 100644 index 0000000..58b730a --- /dev/null +++ b/backend/tests/test_provider_settings.py @@ -0,0 +1,53 @@ +"""模型默认配置的接口契约;数据库和认证均隔离。""" + +import asyncio +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import httpx +import pytest + + +@pytest.mark.parametrize("initial,requested,status", [ + (True, False, 409), (False, False, 200), (False, True, 200), (True, True, 200), +]) +def test_patch_provider_default_contract(trading, monkeypatch, initial, requested, status): + from alphapulse.api import settings + from alphapulse.api.main import app + from alphapulse.core.auth import require_admin + from alphapulse.core.db import get_session + + provider = SimpleNamespace( + id=1, name="test", protocol="openai", base_url="https://example.test", + api_key_hint="test", model="test", max_tokens=8192, context_window=200000, + reasoning_effort=None, is_default=initial, updated_at=datetime.now(UTC), + ) + session = SimpleNamespace(commit=AsyncMock(), refresh=AsyncMock(), execute=AsyncMock()) + monkeypatch.setattr(settings, "get_provider", AsyncMock(return_value=provider)) + + async def isolated_session(): + yield session + + app.dependency_overrides[require_admin] = lambda: None + app.dependency_overrides[get_session] = isolated_session + + async def check(): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url="http://test") as client: + response = await client.patch("/api/settings/providers/1", json={"is_default": requested}) + assert response.status_code == status + body = response.json() + if status == 409: + assert body == {"code": 409, "data": None, "message": "不能直接取消默认模型,请将其他模型设为默认"} + assert provider.is_default is initial + session.commit.assert_not_awaited() + session.execute.assert_not_awaited() + else: + assert body["code"] == 0 and body["data"]["is_default"] is requested + session.commit.assert_awaited_once() + assert session.execute.await_count == int(requested) + + try: + asyncio.run(check()) + finally: + app.dependency_overrides.clear() diff --git a/web/src/common/apis/docs/index.ts b/web/src/common/apis/docs/index.ts new file mode 100644 index 0000000..1fb8cfb --- /dev/null +++ b/web/src/common/apis/docs/index.ts @@ -0,0 +1,13 @@ +import type * as Docs from "./type" +import { request } from "@/http/axios" + +/** OpenAPI 按原始规范返回,不使用普通 JSON 外壳。 */ +export function getOpenApiApi(signal?: AbortSignal) { + return request({ + url: "/openapi.json", + method: "get", + rawResponse: true, + notify: false, + signal + }) +} diff --git a/web/src/common/apis/docs/type.ts b/web/src/common/apis/docs/type.ts new file mode 100644 index 0000000..ddca7e0 --- /dev/null +++ b/web/src/common/apis/docs/type.ts @@ -0,0 +1,6 @@ +export interface OpenApiDocument { + openapi: string + info: { title: string, version: string } + paths: Record + components?: Record +} diff --git a/web/src/common/apis/settings/type.ts b/web/src/common/apis/settings/type.ts index 87a08f1..8ff82d6 100644 --- a/web/src/common/apis/settings/type.ts +++ b/web/src/common/apis/settings/type.ts @@ -30,6 +30,7 @@ export interface ProviderForm { context_window: number /** 为空表示不传,由模型决定 */ reasoning_effort: Effort | null + /** 修改时不能直接取消当前默认模型;通过将其他模型设为默认切换。 */ is_default: boolean } diff --git a/web/src/common/components/SkillVersionDrawer/index.vue b/web/src/common/components/SkillVersionDrawer/index.vue index 7352ba6..532fb88 100644 --- a/web/src/common/components/SkillVersionDrawer/index.vue +++ b/web/src/common/components/SkillVersionDrawer/index.vue @@ -3,6 +3,7 @@ import type { SkillNode, SkillVersion } from "@@/apis/skills/type" import { listSkillVersionsApi, publishSkillVersionApi } from "@@/apis/skills" import { useSessionScope } from "@@/composables/useSessionScope" import { diffLines, formatTime } from "@@/utils/text" +import { CanceledError } from "axios" const props = defineProps<{ skill: SkillNode | null }>() @@ -16,32 +17,53 @@ const visible = defineModel({ required: true }) const versions = ref([]) const selectedId = ref(null) const publishing = ref(false) +const loading = ref(false) +const failed = ref(false) +let requestSeq = 0 const published = computed(() => versions.value.find(v => v.version_no === props.skill?.published_version_no) ?? null) const selected = computed(() => versions.value.find(v => v.id === selectedId.value) ?? null) const diff = computed(() => selected.value ? diffLines((published.value?.content ?? "").split("\n"), selected.value.content.split("\n")) : []) async function load() { - if (!props.skill) - return - versions.value = await listSkillVersionsApi(props.skill.id) - // 默认选中最新版本,直接看到草稿相对已发布版本的改动 - selectedId.value = versions.value[0]?.id ?? null + const seq = ++requestSeq + const id = props.skill?.id + versions.value = [] + selectedId.value = null + failed.value = false + loading.value = false + if (!visible.value || id === undefined) return + loading.value = true + try { + const result = await listSkillVersionsApi(id) + if (seq !== requestSeq || !visible.value || id !== props.skill?.id) return + versions.value = result + // 默认选中最新版本,直接看到草稿相对已发布版本的改动 + selectedId.value = result[0]?.id ?? null + } catch (error) { + if (seq === requestSeq) failed.value = true + throw error + } finally { + if (seq === requestSeq) loading.value = false + } } -watch([visible, () => props.skill?.id, () => props.skill?.latest_version_no], ([open]) => { - if (open) - load() -}) +watch([visible, () => props.skill?.id, () => props.skill?.latest_version_no], load) +onBeforeUnmount(() => ++requestSeq) async function publish(version: SkillVersion) { + const skillId = props.skill!.id + const seq = requestSeq const current = props.skill?.published_version_no const action = current !== null && current !== undefined && version.version_no < current ? "回退到" : "发布" await ElMessageBox.confirm(`${action} v${version.version_no}?发布后 Chat 下一轮起按此版本判断。`, "发布确认", { type: "warning" }) sessionScope.assertCurrent() + if (seq !== requestSeq || !visible.value || skillId !== props.skill?.id) + throw new CanceledError("查看的 Skill 已切换") publishing.value = true try { - await publishSkillVersionApi(props.skill!.id, version.id) + await publishSkillVersionApi(skillId, version.id) + if (seq !== requestSeq) return ElMessage.success(`已${action} v${version.version_no}`) emit("published") } finally { @@ -52,7 +74,12 @@ async function publish(version: SkillVersion) {