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,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]
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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])
|
||||
@@ -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