Files
AstrBot/astrbot/dashboard/api/chat.py
Weilong Liao 0d8e8682db refactor(core): migrate backend backbone from Quart to FastAPI and introduce more OpenAPI (#8688)
* refactor: migrate to fastapi

* structure refactor

* fix: pyright fix

* refactor: improve error handling and public messages in plugin services

* feat(api): refactor API client integration and enhance request handling

- Updated API client configuration to use a dedicated HTTP client.
- Introduced utility functions for generating options, queries, and form data for API requests.
- Refactored multiple API methods to utilize the new utility functions for improved consistency and readability.
- Renamed types for clarity and updated import statements accordingly.

feat(docs): add script to update OpenAPI JSON from YAML spec

- Created a Python script to convert OpenAPI YAML specification to JSON format.
- The script supports customizable input and output paths.
- Ensured the script handles directory creation for output paths and validates the YAML structure.

* fix

* feat(auth): implement rate limiting for v1 login endpoint and enhance request handling

* Refactor dashboard API routers to use legacy_router for backward compatibility

- Changed all instances of dashboard_router to legacy_router across multiple API modules including platform, plugins, providers, sessions, skills, stats, subagents, t2i, tools, updates, and asgi_runtime.
- Updated route definitions to ensure existing endpoints remain functional under the new router structure.
- Introduced support for Quart request context in asgi_runtime to enhance compatibility with existing Quart-based plugins.
- Added a test case to validate the functionality of the new Quart request context handling in plugin extensions.

* chore: remove cli test

* fix: update dashboard tests for fastapi migration

* chore: satisfy ruff checks

* fix: update openapi api key scopes

* fix: sync config scope chip selection

* fix: restore quart dependency

* docs: clarify quart plugin api compatibility

* docs: update openapi scope documentation

* fix: use singular skill openapi scope

* fix: hide update service exception details

* fix: address fastapi review comments

* fix: address dashboard review findings

* docs: revert unrelated package deployment changes

* docs: update agent api generation guidance

* feat: add plugin page web api helpers

* docs: add plugin page bridge demo

* fix: type plugin upload files

* fix: stabilize plugin page uploads

* fix: type plugin web request proxy

* docs: remove plugin page docs example

* fix: authenticate plugin page SSE bridge
2026-06-14 15:03:26 +08:00

527 lines
15 KiB
Python

from __future__ import annotations
from typing import Any
from fastapi import APIRouter, Depends, Request
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
from astrbot.dashboard.async_utils import run_maybe_async
from astrbot.dashboard.responses import error, ok
from astrbot.dashboard.schemas import (
ChatMessagePatchRequest,
ChatMessageRegenerateRequest,
ChatSessionBatchDeleteRequest,
ChatSessionPatchRequest,
ChatThreadCreateRequest,
ChatThreadMessageRequest,
)
from astrbot.dashboard.services.chat_service import (
ChatService,
ChatServiceError,
)
from .auth import AuthContext, require_dashboard_user, require_scope
from .multipart import single_upload
router = APIRouter(tags=["Chat"])
legacy_router = APIRouter(
prefix="/api/chat",
tags=["Dashboard Chat"],
include_in_schema=False,
)
def get_service(request: Request) -> ChatService:
return request.app.state.services.chat
async def require_chat_scope(request: Request) -> AuthContext:
return await require_scope(request, "chat")
async def _json_or_empty(request: Request) -> dict[str, Any]:
try:
data = await request.json()
except Exception:
return {}
return data if isinstance(data, dict) else {}
async def _json_or_none(request: Request) -> dict[str, Any] | None:
try:
data = await request.json()
except Exception:
return None
return data if isinstance(data, dict) else None
async def _json_body(request: Request):
try:
return await request.json()
except Exception:
return None
def _model_dict(payload) -> dict[str, Any]:
return payload.model_dump(exclude_unset=True, exclude_none=False)
async def _run(operation):
try:
result = await run_maybe_async(operation)
return ok(result)
except ChatServiceError as exc:
return error(str(exc))
def _file_response(file_path: str, mimetype: str | None):
if mimetype:
return FileResponse(file_path, media_type=mimetype)
return FileResponse(file_path)
async def _send_chat(
*,
request: Request,
username: str,
service: ChatService,
payload: dict[str, Any] | None = None,
):
post_data = payload if payload is not None else await _json_or_none(request)
if post_data is None:
return JSONResponse(error("Missing JSON body"))
try:
stream = await service.build_chat_stream(username, post_data)
except ChatServiceError as exc:
return JSONResponse(error(str(exc)))
return StreamingResponse(
stream,
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Transfer-Encoding": "chunked",
"Connection": "keep-alive",
},
)
@router.get("/chat/sessions/new")
async def create_chat_session(
request: Request,
auth: AuthContext = Depends(require_chat_scope),
service: ChatService = Depends(get_service),
):
return await _run(
lambda: service.new_session(
auth.username,
request.query_params.get("platform_id") or "webchat",
)
)
@router.post("/chat/sessions/batch-delete")
async def batch_delete_chat_sessions(
payload: ChatSessionBatchDeleteRequest,
auth: AuthContext = Depends(require_chat_scope),
service: ChatService = Depends(get_service),
):
return await _run(
lambda: service.batch_delete_sessions_from_dashboard_payload(
auth.username,
_model_dict(payload),
)
)
@router.get("/chat/sessions/{session_id}")
async def get_chat_session(
session_id: str,
auth: AuthContext = Depends(require_chat_scope),
service: ChatService = Depends(get_service),
):
return await _run(lambda: service.get_session(auth.username, session_id))
@router.patch("/chat/sessions/{session_id}")
async def update_chat_session(
session_id: str,
payload: ChatSessionPatchRequest,
auth: AuthContext = Depends(require_chat_scope),
service: ChatService = Depends(get_service),
):
return await _run(
lambda: service.update_session_display_name(
auth.username,
session_id,
payload.display_name,
)
)
@router.delete("/chat/sessions/{session_id}")
async def delete_chat_session(
session_id: str,
auth: AuthContext = Depends(require_chat_scope),
service: ChatService = Depends(get_service),
):
return await _run(lambda: service.delete_webchat_session(auth.username, session_id))
@router.post("/chat/sessions/{session_id}/stop")
async def stop_chat_session(
session_id: str,
auth: AuthContext = Depends(require_chat_scope),
service: ChatService = Depends(get_service),
):
return await _run(lambda: service.stop_session(auth.username, session_id))
@router.patch("/chat/sessions/{session_id}/messages/{message_id}")
async def update_chat_message(
session_id: str,
message_id: str,
payload: ChatMessagePatchRequest,
auth: AuthContext = Depends(require_chat_scope),
service: ChatService = Depends(get_service),
):
return await _run(
lambda: service.update_message(
auth.username,
{
"session_id": session_id,
"message_id": message_id,
**_model_dict(payload),
},
)
)
@router.post("/chat/sessions/{session_id}/messages/{message_id}/regenerate")
async def regenerate_chat_message(
session_id: str,
message_id: str,
request: Request,
payload: ChatMessageRegenerateRequest | None = None,
auth: AuthContext = Depends(require_chat_scope),
service: ChatService = Depends(get_service),
):
body = _model_dict(payload) if payload is not None else {}
try:
chat_payload = await service.prepare_regenerate_message_payload(
auth.username,
{"session_id": session_id, "message_id": message_id, **body},
)
except ChatServiceError as exc:
return JSONResponse(error(str(exc)))
return await _send_chat(
request=request,
username=auth.username,
service=service,
payload=chat_payload,
)
@router.get("/chat/configs")
async def chat_configs(
request: Request,
_auth: AuthContext = Depends(require_chat_scope),
):
return ok(request.app.state.services.config_profiles.list_profiles())
@router.post("/chat/threads")
async def create_chat_thread(
payload: ChatThreadCreateRequest,
auth: AuthContext = Depends(require_chat_scope),
service: ChatService = Depends(get_service),
):
return await _run(
lambda: service.create_thread(auth.username, _model_dict(payload))
)
@router.get("/chat/threads/{thread_id}")
async def get_chat_thread(
thread_id: str,
auth: AuthContext = Depends(require_chat_scope),
service: ChatService = Depends(get_service),
):
return await _run(lambda: service.get_thread(auth.username, thread_id))
@router.delete("/chat/threads/{thread_id}")
async def delete_chat_thread(
thread_id: str,
auth: AuthContext = Depends(require_chat_scope),
service: ChatService = Depends(get_service),
):
return await _run(lambda: service.delete_thread(auth.username, thread_id))
@router.post("/chat/threads/{thread_id}/messages")
async def send_chat_thread_message(
thread_id: str,
request: Request,
payload: ChatThreadMessageRequest,
auth: AuthContext = Depends(require_chat_scope),
service: ChatService = Depends(get_service),
):
try:
chat_payload = await service.prepare_thread_chat_payload(
auth.username,
{"thread_id": thread_id, **_model_dict(payload)},
)
except ChatServiceError as exc:
return JSONResponse(error(str(exc)))
return await _send_chat(
request=request,
username=auth.username,
service=service,
payload=chat_payload,
)
@legacy_router.post("/send")
async def dashboard_send_chat(
request: Request,
username: str = Depends(require_dashboard_user),
service: ChatService = Depends(get_service),
):
return await _send_chat(request=request, username=username, service=service)
@legacy_router.get("/new_session")
async def dashboard_new_session(
request: Request,
username: str = Depends(require_dashboard_user),
service: ChatService = Depends(get_service),
):
return await _run(
lambda: service.new_session(
username,
request.query_params.get("platform_id") or "webchat",
)
)
@legacy_router.get("/sessions")
async def dashboard_get_sessions(
request: Request,
username: str = Depends(require_dashboard_user),
service: ChatService = Depends(get_service),
):
return await _run(
lambda: service.get_sessions(username, request.query_params.get("platform_id"))
)
@legacy_router.get("/get_session")
async def dashboard_get_session(
request: Request,
username: str = Depends(require_dashboard_user),
service: ChatService = Depends(get_service),
):
return await _run(
lambda: service.get_session_from_dashboard_query(
username,
request.query_params.get("session_id"),
)
)
@legacy_router.post("/stop")
async def dashboard_stop_session(
request: Request,
username: str = Depends(require_dashboard_user),
service: ChatService = Depends(get_service),
):
body = await _json_or_empty(request)
return await _run(
lambda: service.stop_session_from_dashboard_payload(username, body)
)
@legacy_router.get("/delete_session")
async def dashboard_delete_session(
request: Request,
username: str = Depends(require_dashboard_user),
service: ChatService = Depends(get_service),
):
return await _run(
lambda: service.delete_webchat_session_from_dashboard_query(
username,
request.query_params.get("session_id"),
)
)
@legacy_router.post("/batch_delete_sessions")
async def dashboard_batch_delete_sessions(
request: Request,
username: str = Depends(require_dashboard_user),
service: ChatService = Depends(get_service),
):
body = await _json_body(request)
return await _run(
lambda: service.batch_delete_sessions_from_dashboard_payload(username, body)
)
@legacy_router.post("/update_session_display_name")
async def dashboard_update_session_display_name(
request: Request,
username: str = Depends(require_dashboard_user),
service: ChatService = Depends(get_service),
):
body = await _json_or_empty(request)
return await _run(
lambda: service.update_session_display_name_from_dashboard_payload(
username,
body,
)
)
@legacy_router.post("/message/edit")
async def dashboard_update_message(
request: Request,
username: str = Depends(require_dashboard_user),
service: ChatService = Depends(get_service),
):
body = await _json_or_empty(request)
return await _run(lambda: service.update_message(username, body))
@legacy_router.post("/message/regenerate")
async def dashboard_regenerate_message(
request: Request,
username: str = Depends(require_dashboard_user),
service: ChatService = Depends(get_service),
):
try:
payload = (
await service.prepare_regenerate_message_payload_from_dashboard_payload(
username,
await _json_or_empty(request),
)
)
except ChatServiceError as exc:
return JSONResponse(error(str(exc)))
return await _send_chat(
request=request,
username=username,
service=service,
payload=payload,
)
@legacy_router.post("/thread/create")
async def dashboard_create_thread(
request: Request,
username: str = Depends(require_dashboard_user),
service: ChatService = Depends(get_service),
):
body = await _json_or_empty(request)
return await _run(lambda: service.create_thread(username, body))
@legacy_router.get("/thread/get")
async def dashboard_get_thread(
request: Request,
username: str = Depends(require_dashboard_user),
service: ChatService = Depends(get_service),
):
return await _run(
lambda: service.get_thread_from_dashboard_query(
username,
request.query_params.get("thread_id"),
)
)
@legacy_router.post("/thread/send")
async def dashboard_send_thread_message(
request: Request,
username: str = Depends(require_dashboard_user),
service: ChatService = Depends(get_service),
):
try:
payload = await service.prepare_thread_chat_payload_from_dashboard_payload(
username,
await _json_or_empty(request),
)
except ChatServiceError as exc:
return JSONResponse(error(str(exc)))
return await _send_chat(
request=request,
username=username,
service=service,
payload=payload,
)
@legacy_router.post("/thread/delete")
async def dashboard_delete_thread(
request: Request,
username: str = Depends(require_dashboard_user),
service: ChatService = Depends(get_service),
):
body = await _json_or_empty(request)
return await _run(
lambda: service.delete_thread_from_dashboard_payload(username, body)
)
@legacy_router.get("/get_file")
async def dashboard_get_file(
request: Request,
_username: str = Depends(require_dashboard_user),
service: ChatService = Depends(get_service),
):
try:
file_path, mimetype = await service.resolve_webchat_file_from_dashboard_query(
request.query_params.get("filename")
)
return _file_response(file_path, mimetype)
except ChatServiceError as exc:
return JSONResponse(error(str(exc)))
except (FileNotFoundError, OSError):
return JSONResponse(error("File access error"))
@legacy_router.get("/get_attachment")
async def dashboard_get_attachment(
request: Request,
_username: str = Depends(require_dashboard_user),
service: ChatService = Depends(get_service),
):
try:
(
file_path,
mimetype,
) = await service.resolve_attachment_file_from_dashboard_query(
request.query_params.get("attachment_id")
)
return _file_response(file_path, mimetype)
except ChatServiceError as exc:
return JSONResponse(error(str(exc)))
except (FileNotFoundError, OSError):
return JSONResponse(error("File access error"))
@legacy_router.post("/post_file")
async def dashboard_post_file(
request: Request,
_username: str = Depends(require_dashboard_user),
service: ChatService = Depends(get_service),
):
try:
upload = await single_upload(request)
if upload is None:
raise ChatServiceError("Missing key: file")
return ok(await service.save_uploaded_file(upload))
except ChatServiceError as exc:
return error(str(exc))