Add AI chat with skills management and model provider settings

- Model providers for OpenAI and Anthropic message formats (e.g. via
  Sub2API), with API keys encrypted at rest and never returned
- Skills tree with markdown editing, priorities, drafts, publishing and
  rollback; only published skills enter the chat prompt
- Chat page with tool calls over collected news and Binance USD-M
  klines, a persisted CONTEXT.md, reset, and stop-generation support
- Chat may only create skill drafts and update CONTEXT.md; publishing,
  deleting, moving and priority changes stay with the user
This commit is contained in:
eezd committed 2026-10-14 13:11:45 +08:00
1 parent f042f27013
commit 5db3ead883
34 files changed
+3196 -12

No files matched your search

+233
View File
@@ -0,0 +1,233 @@
"""Chat:单一主会话 + CONTEXT.md。生成过程以 SSE 推送;浏览器断开即停止生成"""
import asyncio
import json
from collections.abc import AsyncIterator
from datetime import UTC, datetime
from typing import Annotated, Literal
import anyio
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.sse import EventSourceResponse, ServerSentEvent
from pydantic import BaseModel, Field
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.ai import llm
from alphapulse.ai.prompt import build_system_prompt, current_context, estimate_tokens
from alphapulse.ai.tools import run_tool, tool_specs
from alphapulse.core.auth import require_admin
from alphapulse.core.db import SessionLocal, get_session
from alphapulse.models import AiProvider, ChatMessage, ChatSession, ContextVersion
router = APIRouter(prefix="/api/chat", tags=["chat"], dependencies=[Depends(require_admin)])
# 一轮回答中最多调用工具的轮数,超出后不再提供工具,让模型给出当前结论
MAX_TOOL_ROUNDS = 8
# 发给前端展示的工具结果长度
TOOL_PREVIEW_CHARS = 2000
STOPPED_RESULT = json.dumps({"error": "用户已停止生成,工具未执行"}, ensure_ascii=False)
# api 只有一个进程:同一时间只允许一轮生成,Reset 也要等生成结束
generating = asyncio.Lock()
class MessageOut(BaseModel):
id: int
role: Literal["user", "assistant", "tool"]
content: dict
provider_id: int | None
usage: dict | None
created_at: datetime
class SessionOut(BaseModel):
id: int
created_at: datetime
messages: list[MessageOut]
# 下一轮的 prompt 估算(不含新输入),前端据此提示是否接近上下文上限
estimated_tokens: int
generating: bool
class SendIn(BaseModel):
text: Annotated[str, Field(min_length=1, max_length=20000)]
provider_id: int | None = None
class ResetIn(BaseModel):
clear_context: bool = False
class ContextOut(BaseModel):
id: int | None
content: str
source: str | None
created_at: datetime | None
class ContextIn(BaseModel):
content: str
def event(name: str, data: dict) -> ServerSentEvent:
return ServerSentEvent(raw_data=json.dumps(data, ensure_ascii=False, default=str), event=name)
def neutral(m: ChatMessage) -> dict:
return {"role": m.role, **m.content}
async def active_session(session: AsyncSession) -> ChatSession:
current = await session.scalar(select(ChatSession).where(ChatSession.ended_at.is_(None)).order_by(ChatSession.id.desc()).limit(1))
if current is None:
current = ChatSession()
session.add(current)
await session.commit()
return current
async def session_messages(session: AsyncSession, session_id: int) -> list[ChatMessage]:
return list((await session.scalars(select(ChatMessage).where(ChatMessage.session_id == session_id).order_by(ChatMessage.id))).all())
async def save_messages(*messages: ChatMessage) -> None:
async with SessionLocal() as session:
session.add_all(messages)
await session.commit()
async def save_shielded(*messages: ChatMessage) -> None:
"""停止生成(请求被取消)时仍要落库,保证历史完整:屏蔽取消再写"""
with anyio.CancelScope(shield=True):
await save_messages(*messages)
@router.get("/session")
async def get_chat_session(session: Annotated[AsyncSession, Depends(get_session)]) -> SessionOut:
# 先取生成状态再读消息:反过来的话,读消息后生成恰好结束,会返回「未在生成」却缺最后一条
is_generating = generating.locked()
current = await active_session(session)
messages = await session_messages(session, current.id)
system = await build_system_prompt(session)
history = json.dumps([neutral(m) for m in messages], ensure_ascii=False)
return SessionOut(
id=current.id, created_at=current.created_at,
messages=[MessageOut.model_validate(m, from_attributes=True) for m in messages],
estimated_tokens=estimate_tokens(system) + estimate_tokens(history),
generating=is_generating,
)
@router.post("/messages", response_class=EventSourceResponse)
async def send_message(body: SendIn) -> AsyncIterator[ServerSentEvent]:
"""事件:prompt、delta、tool_call、tool_result、skill_draft、context_updated、usage、error、done"""
if generating.locked():
yield event("error", {"message": "上一轮还在生成,请等待完成或先停止"})
return
async with generating:
async with SessionLocal() as session:
provider = await session.get(AiProvider, body.provider_id) if body.provider_id else await session.scalar(
select(AiProvider).where(AiProvider.is_default)
)
if provider is None:
yield event("error", {"message": "没有可用的模型配置,请先到「环境变量」页面添加"})
return
session.expunge(provider)
current = await active_session(session)
system = await build_system_prompt(session)
history = [neutral(m) for m in await session_messages(session, current.id)]
user = {"role": "user", "text": body.text}
messages = [*history, user]
estimated = estimate_tokens(system) + estimate_tokens(json.dumps(messages, ensure_ascii=False))
yield event("prompt", {"estimated_tokens": estimated, "context_window": provider.context_window, "provider": provider.name})
if estimated + provider.max_tokens > provider.context_window:
yield event("error", {"message": f"上下文约 {estimated} token,加上最大输出 {provider.max_tokens} 超过上限 {provider.context_window}。请 Reset 或精简 skills / CONTEXT.md"})
return
await save_messages(ChatMessage(session_id=current.id, role="user", content={"text": body.text}))
specs = tool_specs()
for round_no in range(MAX_TOOL_ROUNDS + 1):
text = ""
completion: llm.Completion | None = None
try:
async for item in llm.stream(provider, system, messages, specs if round_no < MAX_TOOL_ROUNDS else []):
if isinstance(item, llm.TextDelta):
text += item.text
yield event("delta", {"text": item.text})
else:
completion = item
except llm.LlmError as e:
if text:
await save_messages(ChatMessage(session_id=current.id, role="assistant", content={"text": text, "tool_calls": [], "error": str(e)}, provider_id=provider.id))
yield event("error", {"message": str(e)})
return
except (asyncio.CancelledError, GeneratorExit):
# 停止生成:保留已输出的部分,不保留未完成的工具调用。
# 取消发生在 await 时是 CancelledError,发生在 yield 处(生成器被关闭)时是 GeneratorExit
await save_shielded(ChatMessage(session_id=current.id, role="assistant", content={"text": text, "tool_calls": [], "stopped": True}, provider_id=provider.id))
raise
assistant = {"text": completion.text, "tool_calls": completion.tool_calls}
await save_messages(ChatMessage(session_id=current.id, role="assistant", content=assistant, provider_id=provider.id, usage=completion.usage))
messages.append({"role": "assistant", **assistant})
yield event("usage", completion.usage)
if not completion.tool_calls:
break
pending = list(completion.tool_calls)
try:
while pending:
call = pending[0]
yield event("tool_call", {"id": call["id"], "name": call["name"], "arguments": call["arguments"]})
outcome = await run_tool(call["name"], call["arguments"])
result = {"tool_call_id": call["id"], "name": call["name"], "result": outcome.result}
await save_messages(ChatMessage(session_id=current.id, role="tool", content=result))
pending.pop(0)
messages.append({"role": "tool", **result})
yield event("tool_result", {"id": call["id"], "name": call["name"], "preview": outcome.result[:TOOL_PREVIEW_CHARS]})
if outcome.event:
yield event(*outcome.event)
except (asyncio.CancelledError, GeneratorExit):
# 每个工具调用都要有结果,否则下一轮发给模型的历史不合法
await save_shielded(*(
ChatMessage(session_id=current.id, role="tool", content={"tool_call_id": c["id"], "name": c["name"], "result": STOPPED_RESULT})
for c in pending
))
raise
yield event("done", {})
@router.post("/reset")
async def reset(body: ResetIn, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
"""结束当前会话(消息保留在库里,不再展示),可选同时清空 CONTEXT.md(写入空版本,可找回)"""
if generating.locked():
raise HTTPException(status.HTTP_409_CONFLICT, "正在生成,请先停止")
await session.execute(update(ChatSession).where(ChatSession.ended_at.is_(None)).values(ended_at=datetime.now(UTC)))
if body.clear_context:
session.add(ContextVersion(content="", source="reset"))
await session.commit()
@router.get("/context")
async def get_context(session: Annotated[AsyncSession, Depends(get_session)]) -> ContextOut:
current = await current_context(session)
if current is None:
return ContextOut(id=None, content="", source=None, created_at=None)
return ContextOut.model_validate(current, from_attributes=True)
@router.put("/context")
async def put_context(body: ContextIn, session: Annotated[AsyncSession, Depends(get_session)]) -> ContextOut:
version = ContextVersion(content=body.content, source="user")
session.add(version)
await session.commit()
await session.refresh(version)
return ContextOut.model_validate(version, from_attributes=True)
@router.get("/context/versions")
async def context_versions(session: Annotated[AsyncSession, Depends(get_session)]) -> list[ContextOut]:
rows = await session.scalars(select(ContextVersion).order_by(ContextVersion.id.desc()).limit(100))
return [ContextOut.model_validate(v, from_attributes=True) for v in rows]
+4 -1
View File
@@ -2,7 +2,7 @@ from fastapi import Depends, FastAPI
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.responses import HTMLResponse
from alphapulse.api import auth, crawler, market, news
from alphapulse.api import auth, chat, crawler, market, news, settings, skills
from alphapulse.core.auth import require_admin
# 关闭默认文档路由,改为登录后才能访问
@@ -12,6 +12,9 @@ app.include_router(news.router)
app.include_router(crawler.router)
app.include_router(market.router)
app.include_router(market.ws_router)
app.include_router(settings.router)
app.include_router(skills.router)
app.include_router(chat.router)
@app.get("/api/health", include_in_schema=False)
+16 -10
View File
@@ -55,16 +55,8 @@ class Revision(BaseModel):
captured_at: datetime
@router.get("")
async def list_news(
session: Annotated[AsyncSession, Depends(get_session)],
source: Annotated[list[str] | None, Query()] = None,
q: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
page: Annotated[int, Query(ge=1)] = 1,
limit: Annotated[int, Query(ge=1, le=100)] = 30,
) -> NewsPage:
def news_filters(source: list[str] | None, q: str | None, start: datetime | None, end: datetime | None) -> list:
"""新闻列表和 Chat 检索工具共用的筛选条件"""
conditions = []
if source:
conditions.append(NewsItem.source_code.in_(source))
@@ -77,6 +69,20 @@ async def list_news(
conditions.append(NewsItem.published_at >= start)
if end:
conditions.append(NewsItem.published_at < end)
return conditions
@router.get("")
async def list_news(
session: Annotated[AsyncSession, Depends(get_session)],
source: Annotated[list[str] | None, Query()] = None,
q: str | None = None,
start: datetime | None = None,
end: datetime | None = None,
page: Annotated[int, Query(ge=1)] = 1,
limit: Annotated[int, Query(ge=1, le=100)] = 30,
) -> NewsPage:
conditions = news_filters(source, q, start, end)
total = await session.scalar(select(func.count()).select_from(NewsItem).where(*conditions))
rows = (await session.execute(
select(NewsItem, func.left(NewsItem.content_text, EXCERPT_LENGTH).label("excerpt"))
+155
View File
@@ -0,0 +1,155 @@
"""环境变量页:模型接入配置。api_key 只写不读,接口只返回末 4 位"""
import time
from datetime import datetime
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, HttpUrl
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.ai import llm
from alphapulse.core.auth import require_admin
from alphapulse.core.crypto import encrypt
from alphapulse.core.db import get_session
from alphapulse.models import AiProvider
router = APIRouter(prefix="/api/settings", tags=["settings"], dependencies=[Depends(require_admin)])
Protocol = Literal["openai", "anthropic"]
class ProviderOut(BaseModel):
id: int
name: str
protocol: Protocol
base_url: str
api_key_hint: str
model: str
max_tokens: int
context_window: int
temperature: float | None
is_default: bool
updated_at: datetime
class ProviderIn(BaseModel):
name: Annotated[str, Field(min_length=1, max_length=64)]
protocol: Protocol
base_url: HttpUrl
api_key: Annotated[str, Field(min_length=1)]
model: Annotated[str, Field(min_length=1, max_length=128)]
max_tokens: Annotated[int, Field(ge=256, le=128000)] = 8192
context_window: Annotated[int, Field(ge=4096, le=2000000)] = 200000
temperature: Annotated[float | None, Field(ge=0, le=2)] = None
is_default: bool = False
class ProviderPatch(BaseModel):
name: Annotated[str | None, Field(min_length=1, max_length=64)] = None
protocol: Protocol | None = None
base_url: HttpUrl | None = None
# 为空表示不修改
api_key: str | None = None
model: Annotated[str | None, Field(min_length=1, max_length=128)] = None
max_tokens: Annotated[int | None, Field(ge=256, le=128000)] = None
context_window: Annotated[int | None, Field(ge=4096, le=2000000)] = None
temperature: Annotated[float | None, Field(ge=0, le=2)] = None
is_default: bool | None = None
class TestResult(BaseModel):
ok: bool
elapsed_ms: int
reply: str | None = None
error: str | None = None
def key_fields(api_key: str) -> dict:
api_key = api_key.strip()
return {"api_key_encrypted": encrypt(api_key), "api_key_hint": api_key[-4:]}
async def get_provider(session: AsyncSession, provider_id: int) -> AiProvider:
provider = await session.get(AiProvider, provider_id)
if provider is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "模型配置不存在")
return provider
async def commit_unique(session: AsyncSession) -> None:
try:
await session.commit()
except IntegrityError:
raise HTTPException(status.HTTP_409_CONFLICT, "名称已存在") from None
@router.get("/providers")
async def list_providers(session: Annotated[AsyncSession, Depends(get_session)]) -> list[ProviderOut]:
rows = await session.scalars(select(AiProvider).order_by(AiProvider.id))
return [ProviderOut.model_validate(p, from_attributes=True) for p in rows]
@router.post("/providers", status_code=status.HTTP_201_CREATED)
async def create_provider(body: ProviderIn, session: Annotated[AsyncSession, Depends(get_session)]) -> ProviderOut:
# 第一套配置自动成为默认
is_default = body.is_default or await session.scalar(select(AiProvider.id).limit(1)) is None
if is_default:
await session.execute(update(AiProvider).values(is_default=False))
provider = AiProvider(
**body.model_dump(exclude={"api_key", "base_url", "is_default"}),
base_url=str(body.base_url), is_default=is_default, **key_fields(body.api_key),
)
session.add(provider)
await commit_unique(session)
return ProviderOut.model_validate(provider, from_attributes=True)
@router.patch("/providers/{provider_id}")
async def patch_provider(provider_id: int, body: ProviderPatch, session: Annotated[AsyncSession, Depends(get_session)]) -> ProviderOut:
provider = await get_provider(session, provider_id)
values = body.model_dump(exclude_unset=True, exclude={"api_key", "base_url", "is_default"})
# temperature 显式传 null 表示清除,其他字段为 null 时忽略
for field, value in values.items():
if value is not None or field == "temperature":
setattr(provider, field, value)
if body.base_url is not None:
provider.base_url = str(body.base_url)
if body.api_key and body.api_key.strip():
for field, value in key_fields(body.api_key).items():
setattr(provider, field, value)
if body.is_default:
await session.execute(update(AiProvider).where(AiProvider.id != provider_id).values(is_default=False))
provider.is_default = True
await commit_unique(session)
await session.refresh(provider)
return ProviderOut.model_validate(provider, from_attributes=True)
@router.delete("/providers/{provider_id}")
async def delete_provider(provider_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
provider = await get_provider(session, provider_id)
await session.delete(provider)
await session.flush()
if provider.is_default:
# 删掉默认配置后,让最早的一套成为默认,避免 Chat 没有可用配置
first = await session.scalar(select(AiProvider).order_by(AiProvider.id).limit(1))
if first:
first.is_default = True
await session.commit()
@router.post("/providers/{provider_id}/test")
async def test_provider(provider_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> TestResult:
provider = await get_provider(session, provider_id)
started = time.monotonic()
try:
completion = None
async for event in llm.stream(provider, "只回复 OK。", [{"role": "user", "text": "ping"}], []):
if isinstance(event, llm.Completion):
completion = event
except llm.LlmError as e:
return TestResult(ok=False, elapsed_ms=round((time.monotonic() - started) * 1000), error=str(e))
return TestResult(ok=True, elapsed_ms=round((time.monotonic() - started) * 1000), reply=completion.text[:200])
+202
View File
@@ -0,0 +1,202 @@
"""Skills 管理:树、节点、版本、发布与回退"""
from datetime import datetime
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from alphapulse.ai.skills import SkillError, add_version, create_node, load_tree
from alphapulse.core.auth import require_admin
from alphapulse.core.db import get_session
from alphapulse.models import SkillNode, SkillVersion
router = APIRouter(prefix="/api/skills", tags=["skills"], dependencies=[Depends(require_admin)])
class NodeOut(BaseModel):
id: int
parent_id: int | None
kind: Literal["folder", "skill"]
name: str
path: str
sort_order: int
priority: int
enabled: bool
published_version_no: int | None
latest_version_no: int | None
has_draft: bool
updated_at: datetime
class NodeIn(BaseModel):
parent_id: int | None = None
kind: Literal["folder", "skill"]
name: Annotated[str, Field(min_length=1, max_length=128)]
priority: int = 0
# 新建 skill 时的初始内容,保存为 v1 草稿(未发布);为空则不建版本
content: str | None = None
class NodePatch(BaseModel):
name: Annotated[str | None, Field(min_length=1, max_length=128)] = None
priority: int | None = None
enabled: bool | None = None
class MoveIn(BaseModel):
# null 表示移到根目录
parent_id: int | None
# 在新父节点下的位置(从 0 开始)
index: Annotated[int, Field(ge=0)]
class VersionOut(BaseModel):
id: int
version_no: int
content: str
source: Literal["user", "chat"]
note: str
created_at: datetime
class VersionIn(BaseModel):
content: str
note: str = ""
class PublishIn(BaseModel):
version_id: int
async def get_node(session: AsyncSession, node_id: int, kind: str | None = None) -> SkillNode:
node = await session.get(SkillNode, node_id)
if node is None or (kind and node.kind != kind):
raise HTTPException(status.HTTP_404_NOT_FOUND, "节点不存在")
return node
async def node_out(session: AsyncSession, node_id: int) -> NodeOut:
info = next(i for i in await load_tree(session) if i.node.id == node_id)
return NodeOut(**{**info.node.__dict__, "path": info.path, "published_version_no": info.published_version_no,
"latest_version_no": info.latest_version_no, "has_draft": info.has_draft})
def bad_request(e: SkillError) -> HTTPException:
return HTTPException(status.HTTP_400_BAD_REQUEST, str(e))
@router.get("/tree")
async def tree(session: Annotated[AsyncSession, Depends(get_session)]) -> list[NodeOut]:
"""扁平列表,前端按 parent_id 组装成树"""
return [
NodeOut(**{**i.node.__dict__, "path": i.path, "published_version_no": i.published_version_no,
"latest_version_no": i.latest_version_no, "has_draft": i.has_draft})
for i in await load_tree(session)
]
@router.post("/nodes", status_code=status.HTTP_201_CREATED)
async def create(body: NodeIn, session: Annotated[AsyncSession, Depends(get_session)]) -> NodeOut:
try:
node = await create_node(session, body.parent_id, body.kind, body.name, body.priority)
# 没有初始内容时不建空版本,第一次保存即 v1
if body.kind == "skill" and body.content:
await add_version(session, node, body.content, "user", "新建")
except SkillError as e:
raise bad_request(e) from None
await session.commit()
return await node_out(session, node.id)
@router.patch("/nodes/{node_id}")
async def patch(node_id: int, body: NodePatch, session: Annotated[AsyncSession, Depends(get_session)]) -> NodeOut:
node = await get_node(session, node_id)
if body.name is not None:
name = body.name.strip()
if "/" in name:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "名称不能包含 /")
duplicate = await session.scalar(select(SkillNode.id).where(
SkillNode.parent_id.is_not_distinct_from(node.parent_id), SkillNode.name == name, SkillNode.id != node_id
))
if duplicate:
raise HTTPException(status.HTTP_409_CONFLICT, f"同一文件夹下已存在:{name}")
node.name = name
if body.priority is not None:
node.priority = body.priority
if body.enabled is not None:
node.enabled = body.enabled
await session.commit()
return await node_out(session, node_id)
@router.post("/nodes/{node_id}/move")
async def move(node_id: int, body: MoveIn, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
node = await get_node(session, node_id)
if body.parent_id is not None:
parent = await get_node(session, body.parent_id)
if parent.kind != "folder":
raise HTTPException(status.HTTP_400_BAD_REQUEST, "只能移动到文件夹中")
# 不能移到自己或自己的子孙下面
cursor: SkillNode | None = parent
while cursor is not None:
if cursor.id == node_id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "不能移动到自身或其子文件夹中")
cursor = await session.get(SkillNode, cursor.parent_id) if cursor.parent_id else None
duplicate = await session.scalar(select(SkillNode.id).where(
SkillNode.parent_id.is_not_distinct_from(body.parent_id), SkillNode.name == node.name, SkillNode.id != node_id
))
if duplicate:
raise HTTPException(status.HTTP_409_CONFLICT, f"目标文件夹下已存在同名节点:{node.name}")
siblings = (await session.scalars(
select(SkillNode).where(SkillNode.parent_id.is_not_distinct_from(body.parent_id), SkillNode.id != node_id)
.order_by(SkillNode.sort_order, SkillNode.id)
)).all()
ordered = list(siblings)
ordered.insert(min(body.index, len(ordered)), node)
node.parent_id = body.parent_id
for i, sibling in enumerate(ordered):
sibling.sort_order = i
await session.commit()
@router.delete("/nodes/{node_id}")
async def delete(node_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> None:
node = await get_node(session, node_id)
if node.kind == "folder" and await session.scalar(select(func.count()).where(SkillNode.parent_id == node_id)):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "文件夹不为空,请先移走或删除其中的节点")
# 先解除对版本的引用,版本随节点级联删除
node.published_version_id = None
await session.flush()
await session.delete(node)
await session.commit()
@router.get("/{skill_id}/versions")
async def versions(skill_id: int, session: Annotated[AsyncSession, Depends(get_session)]) -> list[VersionOut]:
await get_node(session, skill_id, "skill")
rows = await session.scalars(select(SkillVersion).where(SkillVersion.skill_id == skill_id).order_by(SkillVersion.version_no.desc()))
return [VersionOut.model_validate(v, from_attributes=True) for v in rows]
@router.post("/{skill_id}/versions", status_code=status.HTTP_201_CREATED)
async def save_draft(skill_id: int, body: VersionIn, session: Annotated[AsyncSession, Depends(get_session)]) -> VersionOut:
skill = await get_node(session, skill_id, "skill")
version = await add_version(session, skill, body.content, "user", body.note)
await session.commit()
await session.refresh(version)
return VersionOut.model_validate(version, from_attributes=True)
@router.post("/{skill_id}/publish")
async def publish(skill_id: int, body: PublishIn, session: Annotated[AsyncSession, Depends(get_session)]) -> NodeOut:
"""发布指定版本;发布旧版本即回退"""
skill = await get_node(session, skill_id, "skill")
version = await session.get(SkillVersion, body.version_id)
if version is None or version.skill_id != skill_id:
raise HTTPException(status.HTTP_404_NOT_FOUND, "版本不存在")
skill.published_version_id = version.id
await session.commit()
return await node_out(session, skill_id)