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:
1 parent
f042f27013
commit
5db3ead883
34 files changed
+3196
-12
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user