chore: sync workspace changes

This commit is contained in:
chengkai3
2026-04-17 21:55:27 +08:00
parent 7fced9756d
commit a737e5f542
202 changed files with 49852 additions and 2482 deletions
+4
View File
@@ -3,7 +3,9 @@ from fastapi import APIRouter
from .v1.admin import router as admin_router
from .v1.admin_files import router as admin_files_router
from .v1.auth import router as auth_router
from .v1.chat import router as chat_router
from .v1.requirements import router as requirements_router
from .v1.todos import router as todos_router
from .v1.users import router as users_router
from .v1.ws import router as ws_router
@@ -13,6 +15,8 @@ api_router.include_router(users_router)
api_router.include_router(admin_router)
api_router.include_router(admin_files_router)
api_router.include_router(requirements_router)
api_router.include_router(todos_router)
api_router.include_router(chat_router)
api_router.include_router(ws_router)
+53
View File
@@ -0,0 +1,53 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.dependencies import CurrentUser, require_permission
from ...schemas.chat import (
ChatMessageCreateRequest,
ChatMessageListResponse,
ChatSendResponse,
ChatSessionCreateRequest,
ChatSessionListResponse,
ChatSessionPublic,
)
from ...services.chat_service import create_session, list_messages, list_sessions, send_message
router = APIRouter(prefix="/chat", tags=["chat"])
@router.get("/sessions", response_model=ChatSessionListResponse)
def get_chat_sessions(
current_user: CurrentUser = Depends(require_permission("chat.use")),
db: Session = Depends(get_db),
) -> ChatSessionListResponse:
return list_sessions(db, actor=current_user.user)
@router.post("/sessions", response_model=ChatSessionPublic)
def create_chat_session(
payload: ChatSessionCreateRequest,
current_user: CurrentUser = Depends(require_permission("chat.use")),
db: Session = Depends(get_db),
) -> ChatSessionPublic:
return create_session(db, payload, actor=current_user.user)
@router.get("/sessions/{session_id}/messages", response_model=ChatMessageListResponse)
def get_chat_messages(
session_id: str,
limit: int = Query(default=200, ge=1, le=500),
current_user: CurrentUser = Depends(require_permission("chat.use")),
db: Session = Depends(get_db),
) -> ChatMessageListResponse:
return list_messages(db, session_id=session_id, actor=current_user.user, limit=limit)
@router.post("/sessions/{session_id}/messages", response_model=ChatSendResponse)
def send_chat_message(
session_id: str,
payload: ChatMessageCreateRequest,
current_user: CurrentUser = Depends(require_permission("chat.use")),
db: Session = Depends(get_db),
) -> ChatSendResponse:
return send_message(db, session_id=session_id, payload=payload, actor=current_user.user)
+91
View File
@@ -0,0 +1,91 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.dependencies import CurrentUser, require_any_permission, require_permission
from ...schemas.todo import (
TodoCreateRequest,
TodoListResponse,
TodoSummary,
TodoTransitionRequest,
TodoUpdateRequest,
)
from ...services.todo_service import (
create_todo,
delete_todo,
get_todo_by_id,
list_todos,
serialize_todo,
transition_todo,
update_todo,
)
router = APIRouter(prefix="/todos", tags=["todos"])
@router.get("", response_model=TodoListResponse)
def get_todo_list(
keyword: str | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
priority: str | None = Query(default=None),
assignee_user_id: str | None = Query(default=None),
_: CurrentUser = Depends(require_permission("todo.read")),
db: Session = Depends(get_db),
) -> TodoListResponse:
return list_todos(
db,
keyword=keyword,
status_filter=status_filter,
priority=priority,
assignee_user_id=assignee_user_id,
)
@router.post("", response_model=TodoSummary)
def create_todo_endpoint(
payload: TodoCreateRequest,
current_user: CurrentUser = Depends(require_any_permission("todo.create", "todo.manage")),
db: Session = Depends(get_db),
) -> TodoSummary:
return create_todo(db, payload, actor=current_user.user)
@router.get("/{todo_id}", response_model=TodoSummary)
def get_todo_detail(
todo_id: str,
_: CurrentUser = Depends(require_permission("todo.read")),
db: Session = Depends(get_db),
) -> TodoSummary:
todo = get_todo_by_id(db, todo_id)
if not todo:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found")
return serialize_todo(todo)
@router.patch("/{todo_id}", response_model=TodoSummary)
def update_todo_endpoint(
todo_id: str,
payload: TodoUpdateRequest,
current_user: CurrentUser = Depends(require_any_permission("todo.process", "todo.manage")),
db: Session = Depends(get_db),
) -> TodoSummary:
return update_todo(db, todo_id, payload, actor=current_user.user)
@router.post("/{todo_id}/transition", response_model=TodoSummary)
def transition_todo_endpoint(
todo_id: str,
payload: TodoTransitionRequest,
current_user: CurrentUser = Depends(require_any_permission("todo.process", "todo.manage")),
db: Session = Depends(get_db),
) -> TodoSummary:
return transition_todo(db, todo_id, payload, actor=current_user.user)
@router.delete("/{todo_id}")
def delete_todo_endpoint(
todo_id: str,
current_user: CurrentUser = Depends(require_any_permission("todo.manage", "todo.process")),
db: Session = Depends(get_db),
) -> dict[str, bool]:
return delete_todo(db, todo_id, actor=current_user.user)
+52 -1
View File
@@ -3,10 +3,21 @@ from sqlalchemy.orm import Session
from ...core.database import get_db
from ...core.dependencies import CurrentUser, get_current_user, require_permission
from ...schemas.user import UserListResponse, UserPublic, UserRoleUpdateRequest, UserUpdateRequest
from ...schemas.auth import MessageResponse
from ...schemas.user import (
UserCreateRequest,
UserListResponse,
UserPasswordResetRequest,
UserPublic,
UserRoleUpdateRequest,
UserUpdateRequest,
)
from ...services.user_service import (
create_user,
delete_user,
get_user_by_id,
list_users,
reset_user_password,
serialize_user,
set_user_roles,
update_user,
@@ -15,6 +26,21 @@ from ...services.user_service import (
router = APIRouter(prefix="/users", tags=["users"])
@router.post("", response_model=UserPublic)
def create_user_account(
payload: UserCreateRequest,
_: CurrentUser = Depends(require_permission("user.manage")),
db: Session = Depends(get_db),
) -> UserPublic:
created = create_user(db, payload)
if not created:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="User id/email/username already exists or default role missing",
)
return created
@router.get("", response_model=UserListResponse)
def list_all_users(
limit: int = Query(default=50, ge=1, le=200),
@@ -60,6 +86,31 @@ def update_user_profile(
return updated
@router.post("/{user_id}/password", response_model=UserPublic)
def reset_password(
user_id: str,
payload: UserPasswordResetRequest,
_: CurrentUser = Depends(require_permission("user.manage")),
db: Session = Depends(get_db),
) -> UserPublic:
updated = reset_user_password(db, user_id, payload)
if not updated:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return updated
@router.delete("/{user_id}", response_model=MessageResponse)
def remove_user(
user_id: str,
_: CurrentUser = Depends(require_permission("user.manage")),
db: Session = Depends(get_db),
) -> MessageResponse:
deleted = delete_user(db, user_id)
if not deleted:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return MessageResponse(message="User deleted")
@router.post("/{user_id}/roles", response_model=UserPublic)
def assign_roles(
user_id: str,
+47 -1
View File
@@ -1,4 +1,5 @@
from functools import lru_cache
import json
import re
from typing import Literal
@@ -26,6 +27,11 @@ class Settings(BaseSettings):
refresh_cookie_secure: bool = False
refresh_cookie_samesite: Literal["lax", "strict", "none"] = "lax"
llm_provider_api_keys: str = ""
llm_request_timeout_seconds: int = 60
chat_context_message_limit: int = 12
chat_default_system_prompt: str = "You are a helpful assistant."
initial_admin_email: str | None = None
initial_admin_username: str = "admin"
initial_admin_password: str | None = None
@@ -37,7 +43,12 @@ class Settings(BaseSettings):
extra="ignore",
)
@field_validator("access_token_expire_minutes", "refresh_token_expire_days")
@field_validator(
"access_token_expire_minutes",
"refresh_token_expire_days",
"llm_request_timeout_seconds",
"chat_context_message_limit",
)
@classmethod
def validate_positive_numbers(cls, value: int) -> int:
if value <= 0:
@@ -80,6 +91,41 @@ class Settings(BaseSettings):
return None
return "|".join(f"(?:{part})" for part in regex_parts)
@property
def llm_provider_key_map(self) -> dict[str, str]:
raw = self.llm_provider_api_keys.strip()
if not raw:
return {}
if raw.startswith("{"):
try:
data = json.loads(raw)
except json.JSONDecodeError:
return {}
if not isinstance(data, dict):
return {}
normalized: dict[str, str] = {}
for provider, value in data.items():
if not isinstance(provider, str) or not isinstance(value, str):
continue
provider_key = provider.strip().lower()
secret = value.strip()
if provider_key and secret:
normalized[provider_key] = secret
return normalized
mapping: dict[str, str] = {}
for token in re.split(r"[,\n;]+", raw):
pair = token.strip()
if not pair or "=" not in pair:
continue
provider, value = pair.split("=", 1)
provider_key = provider.strip().lower()
secret = value.strip()
if provider_key and secret:
mapping[provider_key] = secret
return mapping
@lru_cache
def get_settings() -> Settings:
+12 -1
View File
@@ -39,7 +39,18 @@ def get_db() -> Generator[Session, None, None]:
def init_db() -> None:
# Import models so metadata includes every table before create_all.
from ..models import audit_log, auth_session, file_storage, menu, model_registry, rbac, requirement, user # noqa: F401
from ..models import (
audit_log,
auth_session,
chat,
file_storage,
menu,
model_registry,
rbac,
requirement,
todo,
user,
) # noqa: F401
from ..services.seed_service import seed_defaults
Base.metadata.create_all(bind=engine)
+3 -1
View File
@@ -4,15 +4,17 @@ Import all model modules during package initialization so SQLAlchemy can
resolve string-based relationships regardless of route/service import order.
"""
from . import audit_log, auth_session, file_storage, menu, model_registry, rbac, requirement, user
from . import audit_log, auth_session, chat, file_storage, menu, model_registry, rbac, requirement, todo, user
__all__ = [
"audit_log",
"auth_session",
"chat",
"file_storage",
"menu",
"model_registry",
"rbac",
"requirement",
"todo",
"user",
]
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import uuid4
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..core.database import Base
from .base import utcnow
if TYPE_CHECKING:
from .user import User
class ChatSession(Base):
__tablename__ = "chat_sessions"
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid4()),
)
owner_user_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("users.id", ondelete="CASCADE"),
index=True,
)
title: Mapped[str] = mapped_column(String(200), default="新会话")
system_prompt: Mapped[str] = mapped_column(Text(), default="")
model_code: Mapped[str | None] = mapped_column(String(64), index=True)
last_message_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utcnow,
onupdate=utcnow,
)
owner: Mapped[User] = relationship("User", lazy="selectin")
messages: Mapped[list[ChatMessage]] = relationship(
"ChatMessage",
back_populates="session",
lazy="selectin",
cascade="all, delete-orphan",
order_by="ChatMessage.created_at.asc(), ChatMessage.id.asc()",
)
class ChatMessage(Base):
__tablename__ = "chat_messages"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
session_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("chat_sessions.id", ondelete="CASCADE"),
index=True,
)
author_user_id: Mapped[str | None] = mapped_column(
String(36),
ForeignKey("users.id", ondelete="SET NULL"),
index=True,
)
role: Mapped[str] = mapped_column(String(16), index=True)
content: Mapped[str] = mapped_column(Text())
is_error: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
model_code: Mapped[str | None] = mapped_column(String(64), index=True)
provider: Mapped[str | None] = mapped_column(String(64), index=True)
provider_model: Mapped[str | None] = mapped_column(String(128), index=True)
prompt_tokens: Mapped[int | None] = mapped_column(Integer)
completion_tokens: Mapped[int | None] = mapped_column(Integer)
total_tokens: Mapped[int | None] = mapped_column(Integer)
latency_ms: Mapped[int | None] = mapped_column(Integer)
error_message: Mapped[str | None] = mapped_column(Text())
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, index=True)
session: Mapped[ChatSession] = relationship("ChatSession", back_populates="messages", lazy="selectin")
author: Mapped[User | None] = relationship("User", lazy="selectin")
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from uuid import uuid4
from sqlalchemy import DateTime, ForeignKey, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from ..core.database import Base
from .base import utcnow
if TYPE_CHECKING:
from .user import User
class Todo(Base):
__tablename__ = "todos"
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid4()),
)
title: Mapped[str] = mapped_column(String(200), index=True)
description: Mapped[str] = mapped_column(Text(), default="")
status: Mapped[str] = mapped_column(String(32), default="TODO", index=True)
priority: Mapped[str] = mapped_column(String(16), default="medium", index=True)
assignee_user_id: Mapped[str | None] = mapped_column(
String(36),
ForeignKey("users.id", ondelete="SET NULL"),
index=True,
)
creator_user_id: Mapped[str | None] = mapped_column(
String(36),
ForeignKey("users.id", ondelete="SET NULL"),
index=True,
)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utcnow,
onupdate=utcnow,
)
creator: Mapped[User | None] = relationship(
"User",
foreign_keys=[creator_user_id],
lazy="selectin",
)
assignee: Mapped[User | None] = relationship(
"User",
foreign_keys=[assignee_user_id],
lazy="selectin",
)
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
ChatRole = Literal["system", "user", "assistant"]
class ChatSessionCreateRequest(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=200)
system_prompt: str | None = Field(default=None, max_length=4000)
class ChatSessionPublic(BaseModel):
id: str
owner_user_id: str
title: str
system_prompt: str
model_code: str | None = None
last_message_at: datetime | None = None
created_at: datetime
updated_at: datetime
class ChatSessionListResponse(BaseModel):
items: list[ChatSessionPublic]
total: int
class ChatMessageCreateRequest(BaseModel):
content: str = Field(min_length=1, max_length=20000)
class ChatMessagePublic(BaseModel):
id: int
session_id: str
author_user_id: str | None = None
role: ChatRole
content: str
is_error: bool
model_code: str | None = None
provider: str | None = None
provider_model: str | None = None
prompt_tokens: int | None = None
completion_tokens: int | None = None
total_tokens: int | None = None
latency_ms: int | None = None
error_message: str | None = None
created_at: datetime
class ChatMessageListResponse(BaseModel):
items: list[ChatMessagePublic]
total: int
class ChatSendResponse(BaseModel):
session: ChatSessionPublic
user_message: ChatMessagePublic
assistant_message: ChatMessagePublic
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field
from .user import UserPublic
TodoStatus = Literal["TODO", "IN_PROGRESS", "DONE"]
TodoPriority = Literal["low", "medium", "high", "urgent"]
class TodoSummary(BaseModel):
id: str
title: str
description: str
status: TodoStatus
priority: TodoPriority
assignee_user_id: str | None = None
creator_user_id: str | None = None
due_at: datetime | None = None
completed_at: datetime | None = None
created_at: datetime
updated_at: datetime
creator: UserPublic | None = None
assignee: UserPublic | None = None
class TodoListResponse(BaseModel):
items: list[TodoSummary]
total: int
class TodoCreateRequest(BaseModel):
title: str = Field(min_length=2, max_length=200)
description: str = Field(default="", max_length=20000)
status: TodoStatus = "TODO"
priority: TodoPriority = "medium"
assignee_user_id: str | None = Field(default=None, max_length=36)
due_at: datetime | None = None
class TodoUpdateRequest(BaseModel):
title: str | None = Field(default=None, min_length=2, max_length=200)
description: str | None = Field(default=None, max_length=20000)
priority: TodoPriority | None = None
assignee_user_id: str | None = Field(default=None, max_length=36)
due_at: datetime | None = None
class TodoTransitionRequest(BaseModel):
status: TodoStatus
note: str | None = Field(default=None, max_length=2000)
+20 -1
View File
@@ -1,7 +1,7 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, EmailStr, Field
from pydantic import BaseModel, EmailStr, Field, field_validator
class UserPublic(BaseModel):
@@ -27,3 +27,22 @@ class UserUpdateRequest(BaseModel):
class UserRoleUpdateRequest(BaseModel):
role_codes: list[str] = Field(min_length=1)
class UserPasswordResetRequest(BaseModel):
new_password: str = Field(min_length=8, max_length=128)
class UserCreateRequest(BaseModel):
user_id: str = Field(min_length=3, max_length=64)
email: EmailStr
username: str = Field(min_length=3, max_length=64)
password: str = Field(min_length=8, max_length=128)
@field_validator("user_id")
@classmethod
def validate_user_id(cls, value: str) -> str:
normalized = value.strip()
if not normalized:
raise ValueError("user_id cannot be empty")
return normalized
+1 -1
View File
@@ -303,7 +303,7 @@ def update_menu(db: Session, menu_id: int, payload: MenuUpdateRequest) -> MenuPu
def delete_menu(db: Session, menu_id: int) -> bool:
menu = get_menu_by_id(db, menu_id)
if not menu or menu.code in {"dashboard", "admin.users", "admin.roles", "admin.menus", "admin.files", "admin.requirements", "admin.models"}:
if not menu or menu.code in {"dashboard", "admin.users", "admin.roles", "admin.menus", "admin.files", "admin.requirements", "admin.chat", "admin.models"}:
return False
child_exists = db.scalar(select(Menu.id).where(Menu.parent_id == menu_id))
if child_exists is not None:
+236
View File
@@ -0,0 +1,236 @@
from __future__ import annotations
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..core.config import get_settings
from ..models.base import utcnow
from ..models.chat import ChatMessage, ChatSession
from ..models.user import User
from ..schemas.chat import (
ChatMessageCreateRequest,
ChatMessageListResponse,
ChatMessagePublic,
ChatSendResponse,
ChatSessionCreateRequest,
ChatSessionListResponse,
ChatSessionPublic,
)
from .llm_gateway import create_assistant_reply
settings = get_settings()
def list_sessions(db: Session, *, actor: User) -> ChatSessionListResponse:
sessions = db.execute(
select(ChatSession)
.where(ChatSession.owner_user_id == actor.id)
.order_by(ChatSession.updated_at.desc(), ChatSession.created_at.desc())
).scalars().all()
return ChatSessionListResponse(items=[serialize_session(item) for item in sessions], total=len(sessions))
def create_session(
db: Session,
payload: ChatSessionCreateRequest,
*,
actor: User,
) -> ChatSessionPublic:
title = (payload.title or "").strip() or "新会话"
system_prompt = (payload.system_prompt or "").strip() or settings.chat_default_system_prompt
session = ChatSession(
owner_user_id=actor.id,
title=title,
system_prompt=system_prompt,
)
db.add(session)
db.commit()
saved = get_owned_session(db, session.id, actor_id=actor.id)
if not saved:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Chat session save failed")
return serialize_session(saved)
def list_messages(
db: Session,
*,
session_id: str,
actor: User,
limit: int = 200,
) -> ChatMessageListResponse:
session = get_owned_session(db, session_id, actor_id=actor.id)
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat session not found")
safe_limit = max(1, min(limit, 500))
messages = db.execute(
select(ChatMessage)
.where(ChatMessage.session_id == session.id)
.order_by(ChatMessage.created_at.asc(), ChatMessage.id.asc())
.limit(safe_limit)
).scalars().all()
return ChatMessageListResponse(items=[serialize_message(item) for item in messages], total=len(messages))
def send_message(
db: Session,
*,
session_id: str,
payload: ChatMessageCreateRequest,
actor: User,
) -> ChatSendResponse:
session = get_owned_session(db, session_id, actor_id=actor.id)
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat session not found")
normalized_content = payload.content.strip()
if not normalized_content:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Message content cannot be empty")
user_message = ChatMessage(
session_id=session.id,
author_user_id=actor.id,
role="user",
content=normalized_content,
)
db.add(user_message)
db.flush()
context_messages = _load_context_messages(db, session_id=session.id, exclude_message_id=user_message.id)
assistant_message: ChatMessage
try:
result = create_assistant_reply(
db,
user_message=normalized_content,
context_messages=context_messages,
system_prompt=session.system_prompt or settings.chat_default_system_prompt,
)
assistant_message = ChatMessage(
session_id=session.id,
role="assistant",
content=result.content,
model_code=result.model_code,
provider=result.provider,
provider_model=result.provider_model,
prompt_tokens=result.prompt_tokens,
completion_tokens=result.completion_tokens,
total_tokens=result.total_tokens,
latency_ms=result.latency_ms,
is_error=False,
)
session.model_code = result.model_code
except HTTPException as exc:
assistant_message = ChatMessage(
session_id=session.id,
role="assistant",
content=f"模型调用失败:{exc.detail}",
is_error=True,
error_message=str(exc.detail),
)
except Exception as exc: # pragma: no cover - defensive fallback
assistant_message = ChatMessage(
session_id=session.id,
role="assistant",
content="模型调用失败:unexpected_error",
is_error=True,
error_message=str(exc),
)
db.add(assistant_message)
# Set a meaningful title after first user turn.
if session.title in {"新会话", "New Chat"}:
session.title = _derive_title(normalized_content)
now = utcnow()
session.last_message_at = now
session.updated_at = now
db.commit()
saved_session = get_owned_session(db, session.id, actor_id=actor.id)
saved_user_message = db.execute(select(ChatMessage).where(ChatMessage.id == user_message.id)).scalar_one_or_none()
saved_assistant_message = db.execute(select(ChatMessage).where(ChatMessage.id == assistant_message.id)).scalar_one_or_none()
if not saved_session or not saved_user_message or not saved_assistant_message:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Chat message save failed")
return ChatSendResponse(
session=serialize_session(saved_session),
user_message=serialize_message(saved_user_message),
assistant_message=serialize_message(saved_assistant_message),
)
def get_owned_session(db: Session, session_id: str, *, actor_id: str) -> ChatSession | None:
return db.execute(
select(ChatSession).where(
ChatSession.id == session_id,
ChatSession.owner_user_id == actor_id,
)
).scalar_one_or_none()
def serialize_session(session: ChatSession) -> ChatSessionPublic:
return ChatSessionPublic(
id=session.id,
owner_user_id=session.owner_user_id,
title=session.title,
system_prompt=session.system_prompt,
model_code=session.model_code,
last_message_at=session.last_message_at,
created_at=session.created_at,
updated_at=session.updated_at,
)
def serialize_message(message: ChatMessage) -> ChatMessagePublic:
return ChatMessagePublic(
id=message.id,
session_id=message.session_id,
author_user_id=message.author_user_id,
role=message.role,
content=message.content,
is_error=message.is_error,
model_code=message.model_code,
provider=message.provider,
provider_model=message.provider_model,
prompt_tokens=message.prompt_tokens,
completion_tokens=message.completion_tokens,
total_tokens=message.total_tokens,
latency_ms=message.latency_ms,
error_message=message.error_message,
created_at=message.created_at,
)
def _load_context_messages(
db: Session,
*,
session_id: str,
exclude_message_id: int | None = None,
) -> list[tuple[str, str]]:
limit = max(1, settings.chat_context_message_limit)
messages = db.execute(
select(ChatMessage)
.where(ChatMessage.session_id == session_id)
.order_by(ChatMessage.created_at.desc(), ChatMessage.id.desc())
.limit(limit)
).scalars().all()
messages.reverse()
result: list[tuple[str, str]] = []
for item in messages:
if item.role not in {"user", "assistant"}:
continue
if exclude_message_id is not None and item.id == exclude_message_id:
continue
result.append((item.role, item.content))
return result
def _derive_title(content: str) -> str:
compact = " ".join(content.split())
if len(compact) <= 32:
return compact or "新会话"
return f"{compact[:32]}..."
+237
View File
@@ -0,0 +1,237 @@
from __future__ import annotations
import json
import time
from dataclasses import dataclass
import httpx
from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.orm import Session
from ..core.config import get_settings
from ..models.model_registry import ModelApiKey, ModelRegistry, ModelRouteRule
settings = get_settings()
CHAT_CAPABILITY_ROUTE_KEY = "chat.default"
GLOBAL_ROUTE_KEY = "__global__"
DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
@dataclass
class LlmCompletionResult:
content: str
model_code: str
provider: str
provider_model: str
prompt_tokens: int | None
completion_tokens: int | None
total_tokens: int | None
latency_ms: int
def create_assistant_reply(
db: Session,
*,
user_message: str,
context_messages: list[tuple[str, str]],
system_prompt: str,
) -> LlmCompletionResult:
model = _resolve_chat_model(db)
provider_key = _resolve_provider_key(model.provider)
endpoint = _build_endpoint(model.base_url)
payload = {
"model": model.provider_model,
"messages": _build_messages(
system_prompt=system_prompt,
context_messages=context_messages,
user_message=user_message,
),
}
started = time.perf_counter()
try:
with httpx.Client(timeout=settings.llm_request_timeout_seconds) as client:
response = client.post(
endpoint,
headers={
"Authorization": f"Bearer {provider_key}",
"Content-Type": "application/json",
},
json=payload,
)
except httpx.TimeoutException as exc:
raise HTTPException(status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail="LLM request timeout") from exc
except httpx.HTTPError as exc:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"LLM request failed: {exc.__class__.__name__}") from exc
latency_ms = int((time.perf_counter() - started) * 1000)
if response.status_code >= 400:
detail = _extract_http_error_detail(response)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"LLM response error: {detail}")
body = response.json()
content = _extract_content(body)
if not content:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="LLM returned empty content")
usage = body.get("usage") if isinstance(body, dict) else None
prompt_tokens = _to_int(usage.get("prompt_tokens")) if isinstance(usage, dict) else None
completion_tokens = _to_int(usage.get("completion_tokens")) if isinstance(usage, dict) else None
total_tokens = _to_int(usage.get("total_tokens")) if isinstance(usage, dict) else None
return LlmCompletionResult(
content=content,
model_code=model.code,
provider=model.provider,
provider_model=model.provider_model,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
latency_ms=latency_ms,
)
def _resolve_chat_model(db: Session) -> ModelRegistry:
capability_model = _resolve_model_from_route(db, route_type="CAPABILITY", route_key=CHAT_CAPABILITY_ROUTE_KEY)
if capability_model:
return capability_model
global_model = _resolve_model_from_route(db, route_type="GLOBAL", route_key=GLOBAL_ROUTE_KEY)
if global_model:
return global_model
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No enabled model route for chat (CAPABILITY:chat.default or GLOBAL)",
)
def _resolve_model_from_route(
db: Session,
*,
route_type: str,
route_key: str,
) -> ModelRegistry | None:
rows = db.execute(
select(ModelRouteRule, ModelRegistry)
.join(ModelRegistry, ModelRouteRule.target_model_code == ModelRegistry.code)
.where(
ModelRouteRule.route_type == route_type,
ModelRouteRule.route_key == route_key,
ModelRouteRule.enabled.is_(True),
ModelRegistry.status == "ENABLED",
)
.order_by(ModelRouteRule.priority.asc(), ModelRouteRule.id.asc())
).all()
if not rows:
return None
for _, model in rows:
active_key_exists = db.scalar(
select(ModelApiKey.id).where(
ModelApiKey.model_id == model.id,
ModelApiKey.is_active.is_(True),
)
)
if active_key_exists is not None:
return model
return None
def _resolve_provider_key(provider: str) -> str:
key = settings.llm_provider_key_map.get(provider.strip().lower())
if key:
return key
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Missing provider key for {provider}. Configure LLM_PROVIDER_API_KEYS.",
)
def _build_messages(
*,
system_prompt: str,
context_messages: list[tuple[str, str]],
user_message: str,
) -> list[dict[str, str]]:
messages: list[dict[str, str]] = []
normalized_system_prompt = system_prompt.strip()
if normalized_system_prompt:
messages.append({"role": "system", "content": normalized_system_prompt})
for role, content in context_messages:
if role not in {"user", "assistant"}:
continue
normalized_content = content.strip()
if not normalized_content:
continue
messages.append({"role": role, "content": normalized_content})
messages.append({"role": "user", "content": user_message.strip()})
return messages
def _build_endpoint(base_url: str | None) -> str:
normalized = (base_url or "").strip().rstrip("/")
if not normalized:
return f"{DEFAULT_OPENAI_BASE_URL}/chat/completions"
if normalized.endswith("/chat/completions"):
return normalized
return f"{normalized}/chat/completions"
def _extract_content(body: object) -> str:
if not isinstance(body, dict):
return ""
choices = body.get("choices")
if not isinstance(choices, list) or not choices:
return ""
first = choices[0]
if not isinstance(first, dict):
return ""
message = first.get("message")
if not isinstance(message, dict):
return ""
content = message.get("content")
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
texts: list[str] = []
for item in content:
if isinstance(item, dict):
text = item.get("text")
if isinstance(text, str) and text.strip():
texts.append(text.strip())
return "\n".join(texts).strip()
return ""
def _extract_http_error_detail(response: httpx.Response) -> str:
try:
payload = response.json()
except json.JSONDecodeError:
return f"HTTP {response.status_code}"
if isinstance(payload, dict):
detail = payload.get("error")
if isinstance(detail, dict):
message = detail.get("message")
if isinstance(message, str) and message.strip():
return message.strip()
message = payload.get("message")
if isinstance(message, str) and message.strip():
return message.strip()
detail_field = payload.get("detail")
if isinstance(detail_field, str) and detail_field.strip():
return detail_field.strip()
return f"HTTP {response.status_code}"
def _to_int(value: object) -> int | None:
if isinstance(value, int):
return value
if isinstance(value, str) and value.isdigit():
return int(value)
return None
+37 -1
View File
@@ -22,10 +22,15 @@ DEFAULT_PERMISSIONS: dict[str, str] = {
"model.manage": "Manage model registry, routes, keys, and health checks",
"file.read": "Read file mounts and indexed entries",
"file.manage": "Manage file operations and storage sync",
"chat.use": "Use AI chat feature",
"requirement.read": "Read requirements",
"requirement.create": "Create requirements",
"requirement.process": "Process requirements",
"requirement.manage": "Manage all requirements",
"todo.read": "Read todos",
"todo.create": "Create todos",
"todo.process": "Process todos",
"todo.manage": "Manage all todos",
}
DEFAULT_ROLES: dict[str, dict[str, object]] = {
@@ -43,10 +48,15 @@ DEFAULT_ROLES: dict[str, dict[str, object]] = {
"model.manage",
"file.read",
"file.manage",
"chat.use",
"requirement.read",
"requirement.create",
"requirement.process",
"requirement.manage",
"todo.read",
"todo.create",
"todo.process",
"todo.manage",
],
},
"user": {
@@ -134,6 +144,32 @@ DEFAULT_MENUS: list[dict[str, object]] = [
"cacheable": False,
"permission_code": "requirement.read",
},
{
"code": "admin.todos",
"name": "待办管理",
"path": "/admin/todos",
"icon": "ListTodo",
"parent_code": None,
"type": "menu",
"sort_order": 52,
"status": "enabled",
"visible": True,
"cacheable": False,
"permission_code": "todo.read",
},
{
"code": "admin.chat",
"name": "AI 聊天",
"path": "/admin/chat",
"icon": "MessagesSquare",
"parent_code": None,
"type": "menu",
"sort_order": 57,
"status": "enabled",
"visible": True,
"cacheable": False,
"permission_code": "chat.use",
},
{
"code": "admin.models",
"name": "模型管理",
@@ -150,7 +186,7 @@ DEFAULT_MENUS: list[dict[str, object]] = [
]
ROLE_MENU_BINDINGS: dict[str, list[str]] = {
"admin": ["dashboard", "admin.users", "admin.roles", "admin.menus", "admin.files", "admin.requirements", "admin.models"],
"admin": ["dashboard", "admin.users", "admin.roles", "admin.menus", "admin.files", "admin.requirements", "admin.todos", "admin.chat", "admin.models"],
"user": ["dashboard"],
}
+238
View File
@@ -0,0 +1,238 @@
from __future__ import annotations
import asyncio
from fastapi import HTTPException, status
from sqlalchemy import or_, select
from sqlalchemy.orm import Session, selectinload
from ..models.base import utcnow
from ..models.todo import Todo
from ..models.user import User
from ..schemas.todo import (
TodoCreateRequest,
TodoListResponse,
TodoSummary,
TodoTransitionRequest,
TodoUpdateRequest,
)
from .push_service import publish_topic
from .user_service import serialize_user
TODO_LOAD_OPTIONS = (
selectinload(Todo.creator).selectinload(User.roles),
selectinload(Todo.assignee).selectinload(User.roles),
)
TOPIC_NAME = "todos"
ALLOWED_TRANSITIONS: dict[str, set[str]] = {
"TODO": {"IN_PROGRESS", "DONE"},
"IN_PROGRESS": {"TODO", "DONE"},
"DONE": {"TODO", "IN_PROGRESS"},
}
def _todo_stmt():
return select(Todo).options(*TODO_LOAD_OPTIONS)
def list_todos(
db: Session,
*,
keyword: str | None,
status_filter: str | None,
priority: str | None,
assignee_user_id: str | None,
) -> TodoListResponse:
stmt = _todo_stmt()
if keyword:
like = f"%{keyword.strip()}%"
stmt = stmt.where(or_(Todo.title.ilike(like), Todo.description.ilike(like)))
if status_filter:
stmt = stmt.where(Todo.status == status_filter)
if priority:
stmt = stmt.where(Todo.priority == priority)
if assignee_user_id:
stmt = stmt.where(Todo.assignee_user_id == assignee_user_id)
todos = db.execute(stmt.order_by(Todo.updated_at.desc())).scalars().all()
return TodoListResponse(items=[serialize_todo(item) for item in todos], total=len(todos))
def get_todo_by_id(db: Session, todo_id: str) -> Todo | None:
return db.execute(_todo_stmt().where(Todo.id == todo_id)).scalar_one_or_none()
def create_todo(
db: Session,
payload: TodoCreateRequest,
*,
actor: User,
) -> TodoSummary:
assignee = _load_user_if_exists(db, payload.assignee_user_id)
if payload.assignee_user_id and not assignee:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Assignee not found")
todo = Todo(
title=payload.title.strip(),
description=payload.description.strip(),
status=payload.status,
priority=payload.priority,
assignee_user_id=assignee.id if assignee else None,
creator_user_id=actor.id,
due_at=payload.due_at,
completed_at=utcnow() if payload.status == "DONE" else None,
)
db.add(todo)
db.commit()
saved = get_todo_by_id(db, todo.id)
if not saved:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Todo save failed")
_publish_todo_change("todos.created", saved, action="created")
return serialize_todo(saved)
def update_todo(
db: Session,
todo_id: str,
payload: TodoUpdateRequest,
*,
actor: User,
) -> TodoSummary:
todo = get_todo_by_id(db, todo_id)
if not todo:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found")
update_data = payload.model_dump(exclude_unset=True)
if "assignee_user_id" in update_data:
assignee = _load_user_if_exists(db, update_data["assignee_user_id"])
if update_data["assignee_user_id"] and not assignee:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Assignee not found")
todo.assignee_user_id = assignee.id if assignee else None
for field in ["title", "description", "priority", "due_at"]:
if field in update_data:
value = update_data[field]
setattr(todo, field, _normalize_str(value) if isinstance(value, str) else value)
db.commit()
saved = get_todo_by_id(db, todo.id)
if not saved:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Todo load failed")
_publish_todo_change("todos.updated", saved, action="updated")
return serialize_todo(saved)
def transition_todo(
db: Session,
todo_id: str,
payload: TodoTransitionRequest,
*,
actor: User,
) -> TodoSummary:
todo = get_todo_by_id(db, todo_id)
if not todo:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found")
current_status = todo.status
target_status = payload.status
if current_status == target_status:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Status is unchanged")
if target_status not in ALLOWED_TRANSITIONS.get(current_status, set()):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Transition not allowed: {current_status} -> {target_status}",
)
todo.status = target_status
todo.completed_at = utcnow() if target_status == "DONE" else None
db.commit()
saved = get_todo_by_id(db, todo.id)
if not saved:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Todo load failed")
_publish_todo_change("todos.transitioned", saved, action="transitioned")
return serialize_todo(saved)
def delete_todo(db: Session, todo_id: str, *, actor: User) -> dict[str, bool]:
todo = get_todo_by_id(db, todo_id)
if not todo:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Todo not found")
deleted_id = todo.id
db.delete(todo)
db.commit()
_fire_and_forget(
publish_topic(
TOPIC_NAME,
name="todos.deleted",
payload={"action": "deleted", "todo_id": deleted_id, "actor_user_id": actor.id},
requires_refetch=["/api/v1/todos"],
dedupe_key=f"todos:deleted:{deleted_id}",
)
)
return {"success": True}
def serialize_todo(todo: Todo) -> TodoSummary:
return TodoSummary(
id=todo.id,
title=todo.title,
description=todo.description,
status=todo.status,
priority=todo.priority,
assignee_user_id=todo.assignee_user_id,
creator_user_id=todo.creator_user_id,
due_at=todo.due_at,
completed_at=todo.completed_at,
created_at=todo.created_at,
updated_at=todo.updated_at,
creator=serialize_user(todo.creator) if todo.creator else None,
assignee=serialize_user(todo.assignee) if todo.assignee else None,
)
def _publish_todo_change(event_name: str, todo: Todo, *, action: str) -> None:
payload = {
"action": action,
"todo_id": todo.id,
"status": todo.status,
"priority": todo.priority,
"assignee_user_id": todo.assignee_user_id,
}
_fire_and_forget(
publish_topic(
TOPIC_NAME,
name=event_name,
payload=payload,
requires_refetch=[
"/api/v1/todos",
f"/api/v1/todos/{todo.id}",
],
dedupe_key=f"todos:{action}:{todo.id}",
)
)
def _load_user_if_exists(db: Session, user_id: str | None) -> User | None:
if not user_id:
return None
return db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
def _normalize_str(value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip()
return normalized or None
def _fire_and_forget(coro: object) -> None:
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
loop.create_task(coro)
+1
View File
@@ -23,6 +23,7 @@ TOPIC_RULES: dict[str, TopicRule] = {
"admin.menus": TopicRule(any_permission_codes={"menu.read", "menu.manage"}),
"admin.files": TopicRule(any_permission_codes={"file.read", "file.manage"}),
"requirements": TopicRule(any_permission_codes={"requirement.read", "requirement.process", "requirement.manage"}),
"todos": TopicRule(any_permission_codes={"todo.read", "todo.process", "todo.manage"}),
}
+117 -2
View File
@@ -9,8 +9,15 @@ from ..models.auth_session import AuthSession
from ..models.base import utcnow
from ..models.rbac import Role
from ..models.user import User
from ..schemas.user import UserListResponse, UserPublic, UserRoleUpdateRequest, UserUpdateRequest
from .push_service import publish_to_user, publish_topic
from ..schemas.user import (
UserCreateRequest,
UserListResponse,
UserPasswordResetRequest,
UserPublic,
UserRoleUpdateRequest,
UserUpdateRequest,
)
from ..core.security import hash_password
from .ws_manager import ws_connection_manager
@@ -40,6 +47,114 @@ def get_user_by_email(db: Session, email: str) -> User | None:
return db.execute(stmt).unique().scalar_one_or_none()
def get_user_by_username(db: Session, username: str) -> User | None:
stmt = _user_with_rbac_stmt().where(User.username == username)
return db.execute(stmt).unique().scalar_one_or_none()
def create_user(
db: Session,
payload: UserCreateRequest,
) -> UserPublic | None:
user_id = payload.user_id.strip()
duplicate = db.scalar(
select(User.id).where(
(User.id == user_id) | (User.email == payload.email.lower()) | (User.username == payload.username)
)
)
if duplicate:
return None
role = db.scalar(select(Role).where(Role.code == "user"))
if not role:
return None
user = User(
id=user_id,
email=payload.email.lower(),
username=payload.username,
password_hash=hash_password(payload.password),
status="active",
)
user.roles.append(role)
db.add(user)
db.commit()
created = get_user_by_id(db, user_id)
if created:
queue_user_auth_refresh(created)
_fire_and_forget(
publish_topic(
"admin.users",
name="users.changed",
payload={"action": "created", "user_id": created.id},
requires_refetch=["/api/v1/users"],
dedupe_key=f"users:created:{created.id}",
)
)
return serialize_user(created) if created else None
def delete_user(db: Session, user_id: str) -> bool:
user = get_user_by_id(db, user_id)
if not user:
return False
revoke_active_sessions_for_user(db, user_id)
db.delete(user)
db.commit()
_fire_and_forget(
publish_topic(
"admin.users",
name="users.changed",
payload={"action": "deleted", "user_id": user_id},
requires_refetch=["/api/v1/users"],
dedupe_key=f"users:deleted:{user_id}",
)
)
return True
def reset_user_password(
db: Session,
user_id: str,
payload: UserPasswordResetRequest,
) -> UserPublic | None:
user = get_user_by_id(db, user_id)
if not user:
return None
user.password_hash = hash_password(payload.new_password)
revoke_active_sessions_for_user(db, user_id)
db.commit()
updated = get_user_by_id(db, user_id)
if updated:
_fire_and_forget(
publish_to_user(
updated.id,
topic="auth",
name="auth.password_reset",
payload={"user_id": updated.id},
requires_refetch=["/api/v1/auth/me"],
dedupe_key=f"auth:password_reset:{updated.id}",
)
)
_fire_and_forget(
publish_topic(
"admin.users",
name="users.changed",
payload={"action": "password_reset", "user_id": updated.id},
requires_refetch=["/api/v1/users"],
dedupe_key=f"users:password_reset:{updated.id}",
)
)
return serialize_user(updated) if updated else None
def update_user(
db: Session,
user_id: str,
+1
View File
@@ -12,3 +12,4 @@ argon2-cffi==23.1.0
email-validator==2.3.0
python-multipart==0.0.20
boto3==1.40.59
httpx==0.28.1