Phase 2: Chat & AI Core — Claude API streaming, chat UI, admin context
Backend: - Chat, Message, ContextFile models + Alembic migration - Chat CRUD with per-user limit enforcement (max_chats) - SSE streaming endpoint: saves user message, streams Claude response, saves assistant message with token usage metadata - Context assembly: primary context file + conversation history - Admin context CRUD (GET/PUT with version tracking) - Anthropic SDK integration with async streaming - Chat ownership isolation (users can't access each other's chats) Frontend: - Chat page with sidebar chat list + main chat window - Real-time SSE streaming via fetch + ReadableStream - Message bubbles with Markdown rendering (react-markdown) - Auto-growing message input (Enter to send, Shift+Enter newline) - Zustand chat store for streaming state management - Admin primary context editor with unsaved changes warning - Updated routing: /chat, /chat/:chatId, /admin/context - Enabled Chat and Admin sidebar navigation - English + Russian translations for all new UI Infrastructure: - nginx: disabled proxy buffering for SSE support - Added ANTHROPIC_API_KEY and CLAUDE_MODEL to config Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,10 @@ ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||
REFRESH_TOKEN_EXPIRE_HOURS=24
|
||||
|
||||
# AI
|
||||
ANTHROPIC_API_KEY=sk-ant-your-key-here
|
||||
CLAUDE_MODEL=claude-sonnet-4-20250514
|
||||
|
||||
# Admin seed
|
||||
FIRST_ADMIN_EMAIL=admin@example.com
|
||||
FIRST_ADMIN_USERNAME=admin
|
||||
|
||||
@@ -208,8 +208,8 @@ Daily scheduled job (APScheduler, 8 AM) reviews each user's memory + recent docs
|
||||
- Summary: Monorepo setup, Docker Compose, FastAPI + Alembic, auth (JWT), frontend shell (Vite + React + shadcn/ui + i18n), seed admin script
|
||||
|
||||
### Phase 2: Chat & AI Core
|
||||
- **Status**: NOT STARTED
|
||||
- [ ] Subplan created (`plans/phase-2-chat-ai.md`)
|
||||
- **Status**: IN PROGRESS
|
||||
- [x] Subplan created (`plans/phase-2-chat-ai.md`)
|
||||
- [ ] Phase completed
|
||||
- Summary: Chats + messages tables, chat CRUD, SSE streaming, Claude API integration, context assembly, frontend chat UI, admin context editor, chat limits
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Create chats, messages, and context_files tables
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2026-03-19
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
revision: str = "002"
|
||||
down_revision: Union[str, None] = "001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"chats",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("title", sa.String(255), nullable=False, server_default="New Chat"),
|
||||
sa.Column("skill_id", UUID(as_uuid=True), nullable=True),
|
||||
sa.Column("is_archived", sa.Boolean, nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"messages",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("chat_id", UUID(as_uuid=True), sa.ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||
sa.Column("role", sa.String(20), nullable=False),
|
||||
sa.Column("content", sa.Text, nullable=False),
|
||||
sa.Column("metadata", JSONB, nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"context_files",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")),
|
||||
sa.Column("type", sa.String(20), nullable=False),
|
||||
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True),
|
||||
sa.Column("content", sa.Text, nullable=False, server_default=""),
|
||||
sa.Column("version", sa.Integer, nullable=False, server_default=sa.text("1")),
|
||||
sa.Column("updated_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.UniqueConstraint("type", "user_id", name="uq_context_files_type_user"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("context_files")
|
||||
op.drop_table("messages")
|
||||
op.drop_table("chats")
|
||||
33
backend/app/api/v1/admin.py
Normal file
33
backend/app/api/v1/admin.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import require_admin
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.chat import ContextFileResponse, UpdateContextRequest
|
||||
from app.services import context_service
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
@router.get("/context", response_model=ContextFileResponse | None)
|
||||
async def get_primary_context(
|
||||
_admin: Annotated[User, Depends(require_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
ctx = await context_service.get_primary_context(db)
|
||||
if not ctx:
|
||||
return None
|
||||
return ContextFileResponse.model_validate(ctx)
|
||||
|
||||
|
||||
@router.put("/context", response_model=ContextFileResponse)
|
||||
async def update_primary_context(
|
||||
data: UpdateContextRequest,
|
||||
admin: Annotated[User, Depends(require_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
ctx = await context_service.upsert_primary_context(db, data.content, admin.id)
|
||||
return ContextFileResponse.model_validate(ctx)
|
||||
103
backend/app/api/v1/chats.py
Normal file
103
backend/app/api/v1/chats.py
Normal file
@@ -0,0 +1,103 @@
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.chat import (
|
||||
ChatListResponse,
|
||||
ChatResponse,
|
||||
CreateChatRequest,
|
||||
MessageListResponse,
|
||||
MessageResponse,
|
||||
SendMessageRequest,
|
||||
UpdateChatRequest,
|
||||
)
|
||||
from app.services import chat_service
|
||||
from app.services.ai_service import stream_ai_response
|
||||
|
||||
router = APIRouter(prefix="/chats", tags=["chats"])
|
||||
|
||||
|
||||
@router.post("/", response_model=ChatResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_chat(
|
||||
data: CreateChatRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
chat = await chat_service.create_chat(db, user, data.title)
|
||||
return ChatResponse.model_validate(chat)
|
||||
|
||||
|
||||
@router.get("/", response_model=ChatListResponse)
|
||||
async def list_chats(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
archived: bool | None = Query(default=None),
|
||||
):
|
||||
chats = await chat_service.get_user_chats(db, user.id, archived)
|
||||
return ChatListResponse(chats=[ChatResponse.model_validate(c) for c in chats])
|
||||
|
||||
|
||||
@router.get("/{chat_id}", response_model=ChatResponse)
|
||||
async def get_chat(
|
||||
chat_id: uuid.UUID,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
chat = await chat_service.get_chat(db, chat_id, user.id)
|
||||
return ChatResponse.model_validate(chat)
|
||||
|
||||
|
||||
@router.patch("/{chat_id}", response_model=ChatResponse)
|
||||
async def update_chat(
|
||||
chat_id: uuid.UUID,
|
||||
data: UpdateChatRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
chat = await chat_service.update_chat(db, chat_id, user.id, data.title, data.is_archived)
|
||||
return ChatResponse.model_validate(chat)
|
||||
|
||||
|
||||
@router.delete("/{chat_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_chat(
|
||||
chat_id: uuid.UUID,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
await chat_service.delete_chat(db, chat_id, user.id)
|
||||
|
||||
|
||||
@router.get("/{chat_id}/messages", response_model=MessageListResponse)
|
||||
async def list_messages(
|
||||
chat_id: uuid.UUID,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
limit: int = Query(default=50, le=200),
|
||||
before: uuid.UUID | None = Query(default=None),
|
||||
):
|
||||
messages = await chat_service.get_messages(db, chat_id, user.id, limit, before)
|
||||
return MessageListResponse(messages=[MessageResponse.model_validate(m) for m in messages])
|
||||
|
||||
|
||||
@router.post("/{chat_id}/messages")
|
||||
async def send_message(
|
||||
chat_id: uuid.UUID,
|
||||
data: SendMessageRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
return StreamingResponse(
|
||||
stream_ai_response(db, chat_id, user.id, data.content),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
@@ -1,10 +1,14 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.auth import router as auth_router
|
||||
from app.api.v1.chats import router as chats_router
|
||||
from app.api.v1.admin import router as admin_router
|
||||
|
||||
api_v1_router = APIRouter(prefix="/api/v1")
|
||||
|
||||
api_v1_router.include_router(auth_router)
|
||||
api_v1_router.include_router(chats_router)
|
||||
api_v1_router.include_router(admin_router)
|
||||
|
||||
|
||||
@api_v1_router.get("/health")
|
||||
|
||||
@@ -12,6 +12,9 @@ class Settings(BaseSettings):
|
||||
|
||||
BACKEND_CORS_ORIGINS: list[str] = ["http://localhost", "http://localhost:3000"]
|
||||
|
||||
ANTHROPIC_API_KEY: str = ""
|
||||
CLAUDE_MODEL: str = "claude-sonnet-4-20250514"
|
||||
|
||||
FIRST_ADMIN_EMAIL: str = "admin@example.com"
|
||||
FIRST_ADMIN_USERNAME: str = "admin"
|
||||
FIRST_ADMIN_PASSWORD: str = "changeme_admin_password"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
from app.models.user import User
|
||||
from app.models.session import Session
|
||||
from app.models.chat import Chat
|
||||
from app.models.message import Message
|
||||
from app.models.context_file import ContextFile
|
||||
|
||||
__all__ = ["User", "Session"]
|
||||
__all__ = ["User", "Session", "Chat", "Message", "ContextFile"]
|
||||
|
||||
25
backend/app/models/chat.py
Normal file
25
backend/app/models/chat.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Chat(Base):
|
||||
__tablename__ = "chats"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False, default="New Chat")
|
||||
skill_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True)
|
||||
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
user: Mapped["User"] = relationship(back_populates="chats") # noqa: F821
|
||||
messages: Mapped[list["Message"]] = relationship(back_populates="chat", cascade="all, delete-orphan") # noqa: F821
|
||||
26
backend/app/models/context_file.py
Normal file
26
backend/app/models/context_file.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class ContextFile(Base):
|
||||
__tablename__ = "context_files"
|
||||
__table_args__ = (UniqueConstraint("type", "user_id", name="uq_context_files_type_user"),)
|
||||
|
||||
type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
updated_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
20
backend/app/models/message.py
Normal file
20
backend/app/models/message.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "messages"
|
||||
|
||||
chat_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
metadata_: Mapped[dict | None] = mapped_column("metadata", JSONB, nullable=True)
|
||||
|
||||
chat: Mapped["Chat"] = relationship(back_populates="messages") # noqa: F821
|
||||
@@ -25,3 +25,4 @@ class User(Base):
|
||||
)
|
||||
|
||||
sessions: Mapped[list["Session"]] = relationship(back_populates="user", cascade="all, delete-orphan") # noqa: F821
|
||||
chats: Mapped[list["Chat"]] = relationship(back_populates="user", cascade="all, delete-orphan") # noqa: F821
|
||||
|
||||
@@ -28,6 +28,7 @@ class UserResponse(BaseModel):
|
||||
full_name: str | None
|
||||
role: str
|
||||
is_active: bool
|
||||
max_chats: int
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
62
backend/app/schemas/chat.py
Normal file
62
backend/app/schemas/chat.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateChatRequest(BaseModel):
|
||||
title: str | None = None
|
||||
|
||||
|
||||
class UpdateChatRequest(BaseModel):
|
||||
title: str | None = None
|
||||
is_archived: bool | None = None
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
content: str = Field(min_length=1, max_length=50000)
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
user_id: uuid.UUID
|
||||
title: str
|
||||
skill_id: uuid.UUID | None
|
||||
is_archived: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class ChatListResponse(BaseModel):
|
||||
chats: list[ChatResponse]
|
||||
|
||||
|
||||
class MessageResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
chat_id: uuid.UUID
|
||||
role: str
|
||||
content: str
|
||||
metadata: dict | None = Field(None, alias="metadata_")
|
||||
created_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True, "populate_by_name": True}
|
||||
|
||||
|
||||
class MessageListResponse(BaseModel):
|
||||
messages: list[MessageResponse]
|
||||
|
||||
|
||||
class ContextFileResponse(BaseModel):
|
||||
id: uuid.UUID
|
||||
type: str
|
||||
content: str
|
||||
version: int
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class UpdateContextRequest(BaseModel):
|
||||
content: str
|
||||
108
backend/app/services/ai_service.py
Normal file
108
backend/app/services/ai_service.py
Normal file
@@ -0,0 +1,108 @@
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from anthropic import AsyncAnthropic
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.chat import Chat
|
||||
from app.models.message import Message
|
||||
from app.services.context_service import DEFAULT_SYSTEM_PROMPT, get_primary_context
|
||||
from app.services.chat_service import get_chat, save_message
|
||||
|
||||
client = AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY)
|
||||
|
||||
|
||||
async def assemble_context(
|
||||
db: AsyncSession, chat_id: uuid.UUID, user_message: str
|
||||
) -> tuple[str, list[dict]]:
|
||||
"""Assemble system prompt and messages for Claude API."""
|
||||
# 1. Primary context
|
||||
ctx = await get_primary_context(db)
|
||||
system_prompt = ctx.content if ctx and ctx.content.strip() else DEFAULT_SYSTEM_PROMPT
|
||||
|
||||
# 2. Conversation history
|
||||
result = await db.execute(
|
||||
select(Message)
|
||||
.where(Message.chat_id == chat_id, Message.role.in_(["user", "assistant"]))
|
||||
.order_by(Message.created_at.asc())
|
||||
)
|
||||
history = result.scalars().all()
|
||||
|
||||
messages = [{"role": msg.role, "content": msg.content} for msg in history]
|
||||
|
||||
# 3. Current user message
|
||||
messages.append({"role": "user", "content": user_message})
|
||||
|
||||
return system_prompt, messages
|
||||
|
||||
|
||||
def _sse_event(event: str, data: dict) -> str:
|
||||
return f"event: {event}\ndata: {json.dumps(data)}\n\n"
|
||||
|
||||
|
||||
async def stream_ai_response(
|
||||
db: AsyncSession, chat_id: uuid.UUID, user_id: uuid.UUID, user_message: str
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""Stream AI response as SSE events."""
|
||||
# Verify ownership
|
||||
chat = await get_chat(db, chat_id, user_id)
|
||||
|
||||
# Save user message
|
||||
await save_message(db, chat_id, "user", user_message)
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
# Assemble context
|
||||
system_prompt, messages = await assemble_context(db, chat_id, user_message)
|
||||
|
||||
# Stream from Claude
|
||||
full_content = ""
|
||||
assistant_msg_id = str(uuid.uuid4())
|
||||
|
||||
yield _sse_event("message_start", {"message_id": assistant_msg_id})
|
||||
|
||||
async with client.messages.stream(
|
||||
model=settings.CLAUDE_MODEL,
|
||||
max_tokens=4096,
|
||||
system=system_prompt,
|
||||
messages=messages,
|
||||
) as stream:
|
||||
async for text in stream.text_stream:
|
||||
full_content += text
|
||||
yield _sse_event("content_delta", {"delta": text})
|
||||
|
||||
# Get final message for metadata
|
||||
final_message = await stream.get_final_message()
|
||||
metadata = {
|
||||
"model": final_message.model,
|
||||
"input_tokens": final_message.usage.input_tokens,
|
||||
"output_tokens": final_message.usage.output_tokens,
|
||||
}
|
||||
|
||||
# Save assistant message
|
||||
saved_msg = await save_message(db, chat_id, "assistant", full_content, metadata)
|
||||
await db.commit()
|
||||
|
||||
# Update chat title if first exchange
|
||||
result = await db.execute(
|
||||
select(Message).where(Message.chat_id == chat_id, Message.role == "assistant")
|
||||
)
|
||||
assistant_count = len(result.scalars().all())
|
||||
if assistant_count == 1 and chat.title == "New Chat":
|
||||
# Auto-generate title from first few words
|
||||
title = full_content[:50].split("\n")[0].strip()
|
||||
if len(title) > 40:
|
||||
title = title[:40] + "..."
|
||||
chat.title = title
|
||||
await db.commit()
|
||||
|
||||
yield _sse_event("message_end", {
|
||||
"message_id": str(saved_msg.id),
|
||||
"metadata": metadata,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
yield _sse_event("error", {"detail": str(e)})
|
||||
93
backend/app/services/chat_service.py
Normal file
93
backend/app/services/chat_service.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.chat import Chat
|
||||
from app.models.message import Message
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
async def create_chat(db: AsyncSession, user: User, title: str | None = None) -> Chat:
|
||||
count = await db.scalar(
|
||||
select(func.count()).select_from(Chat).where(
|
||||
Chat.user_id == user.id, Chat.is_archived == False # noqa: E712
|
||||
)
|
||||
)
|
||||
if user.role != "admin" and count >= user.max_chats:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Chat limit reached. Archive or delete existing chats.",
|
||||
)
|
||||
|
||||
chat = Chat(user_id=user.id, title=title or "New Chat")
|
||||
db.add(chat)
|
||||
await db.flush()
|
||||
return chat
|
||||
|
||||
|
||||
async def get_user_chats(
|
||||
db: AsyncSession, user_id: uuid.UUID, archived: bool | None = None
|
||||
) -> list[Chat]:
|
||||
stmt = select(Chat).where(Chat.user_id == user_id)
|
||||
if archived is not None:
|
||||
stmt = stmt.where(Chat.is_archived == archived)
|
||||
stmt = stmt.order_by(Chat.updated_at.desc())
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_chat(db: AsyncSession, chat_id: uuid.UUID, user_id: uuid.UUID) -> Chat:
|
||||
result = await db.execute(
|
||||
select(Chat).where(Chat.id == chat_id, Chat.user_id == user_id)
|
||||
)
|
||||
chat = result.scalar_one_or_none()
|
||||
if not chat:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
|
||||
return chat
|
||||
|
||||
|
||||
async def update_chat(
|
||||
db: AsyncSession, chat_id: uuid.UUID, user_id: uuid.UUID,
|
||||
title: str | None = None, is_archived: bool | None = None,
|
||||
) -> Chat:
|
||||
chat = await get_chat(db, chat_id, user_id)
|
||||
if title is not None:
|
||||
chat.title = title
|
||||
if is_archived is not None:
|
||||
chat.is_archived = is_archived
|
||||
await db.flush()
|
||||
return chat
|
||||
|
||||
|
||||
async def delete_chat(db: AsyncSession, chat_id: uuid.UUID, user_id: uuid.UUID) -> None:
|
||||
chat = await get_chat(db, chat_id, user_id)
|
||||
await db.delete(chat)
|
||||
|
||||
|
||||
async def get_messages(
|
||||
db: AsyncSession, chat_id: uuid.UUID, user_id: uuid.UUID,
|
||||
limit: int = 50, before: uuid.UUID | None = None,
|
||||
) -> list[Message]:
|
||||
# Verify ownership
|
||||
await get_chat(db, chat_id, user_id)
|
||||
|
||||
stmt = select(Message).where(Message.chat_id == chat_id)
|
||||
if before:
|
||||
before_msg = await db.get(Message, before)
|
||||
if before_msg:
|
||||
stmt = stmt.where(Message.created_at < before_msg.created_at)
|
||||
stmt = stmt.order_by(Message.created_at.asc()).limit(limit)
|
||||
result = await db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def save_message(
|
||||
db: AsyncSession, chat_id: uuid.UUID, role: str, content: str,
|
||||
metadata: dict | None = None,
|
||||
) -> Message:
|
||||
message = Message(chat_id=chat_id, role=role, content=content, metadata_=metadata)
|
||||
db.add(message)
|
||||
await db.flush()
|
||||
return message
|
||||
44
backend/app/services/context_service.py
Normal file
44
backend/app/services/context_service.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.context_file import ContextFile
|
||||
|
||||
DEFAULT_SYSTEM_PROMPT = """You are a personal AI health assistant. Your role is to:
|
||||
- Help users understand their health data and medical documents
|
||||
- Provide health-related recommendations based on uploaded information
|
||||
- Schedule reminders for checkups, medications, and health-related activities
|
||||
- Compile health summaries when requested
|
||||
- Answer health questions clearly and compassionately
|
||||
|
||||
Always be empathetic, accurate, and clear. When uncertain, recommend consulting a healthcare professional.
|
||||
You can communicate in English and Russian based on the user's preference."""
|
||||
|
||||
|
||||
async def get_primary_context(db: AsyncSession) -> ContextFile | None:
|
||||
result = await db.execute(
|
||||
select(ContextFile).where(ContextFile.type == "primary", ContextFile.user_id.is_(None))
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def upsert_primary_context(
|
||||
db: AsyncSession, content: str, admin_user_id: uuid.UUID
|
||||
) -> ContextFile:
|
||||
ctx = await get_primary_context(db)
|
||||
if ctx:
|
||||
ctx.content = content
|
||||
ctx.version = ctx.version + 1
|
||||
ctx.updated_by = admin_user_id
|
||||
else:
|
||||
ctx = ContextFile(
|
||||
type="primary",
|
||||
user_id=None,
|
||||
content=content,
|
||||
version=1,
|
||||
updated_by=admin_user_id,
|
||||
)
|
||||
db.add(ctx)
|
||||
await db.flush()
|
||||
return ctx
|
||||
@@ -15,6 +15,7 @@ dependencies = [
|
||||
"python-jose[cryptography]>=3.3.0",
|
||||
"python-multipart>=0.0.9",
|
||||
"httpx>=0.27.0",
|
||||
"anthropic>=0.40.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
167
backend/tests/test_chats.py
Normal file
167
backend/tests/test_chats.py
Normal file
@@ -0,0 +1,167 @@
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_headers(client: AsyncClient):
|
||||
"""Register a user and return auth headers."""
|
||||
resp = await client.post("/api/v1/auth/register", json={
|
||||
"email": "chatuser@example.com",
|
||||
"username": "chatuser",
|
||||
"password": "testpass123",
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
token = resp.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def admin_headers(client: AsyncClient):
|
||||
"""Register a user, manually set them as admin via the DB."""
|
||||
resp = await client.post("/api/v1/auth/register", json={
|
||||
"email": "admin_chat@example.com",
|
||||
"username": "admin_chat",
|
||||
"password": "adminpass123",
|
||||
})
|
||||
assert resp.status_code == 201
|
||||
token = resp.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def chat_id(client: AsyncClient, auth_headers: dict):
|
||||
"""Create a chat and return its ID."""
|
||||
resp = await client.post("/api/v1/chats/", json={"title": "Test Chat"}, headers=auth_headers)
|
||||
assert resp.status_code == 201
|
||||
return resp.json()["id"]
|
||||
|
||||
|
||||
# --- Chat CRUD ---
|
||||
|
||||
async def test_create_chat(client: AsyncClient, auth_headers: dict):
|
||||
resp = await client.post("/api/v1/chats/", json={"title": "My Chat"}, headers=auth_headers)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["title"] == "My Chat"
|
||||
assert data["is_archived"] is False
|
||||
|
||||
|
||||
async def test_create_chat_default_title(client: AsyncClient, auth_headers: dict):
|
||||
resp = await client.post("/api/v1/chats/", json={}, headers=auth_headers)
|
||||
assert resp.status_code == 201
|
||||
assert resp.json()["title"] == "New Chat"
|
||||
|
||||
|
||||
async def test_list_chats(client: AsyncClient, auth_headers: dict, chat_id: str):
|
||||
resp = await client.get("/api/v1/chats/", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
chats = resp.json()["chats"]
|
||||
assert any(c["id"] == chat_id for c in chats)
|
||||
|
||||
|
||||
async def test_get_chat(client: AsyncClient, auth_headers: dict, chat_id: str):
|
||||
resp = await client.get(f"/api/v1/chats/{chat_id}", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["id"] == chat_id
|
||||
|
||||
|
||||
async def test_update_chat_title(client: AsyncClient, auth_headers: dict, chat_id: str):
|
||||
resp = await client.patch(
|
||||
f"/api/v1/chats/{chat_id}",
|
||||
json={"title": "Updated Title"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["title"] == "Updated Title"
|
||||
|
||||
|
||||
async def test_archive_chat(client: AsyncClient, auth_headers: dict, chat_id: str):
|
||||
resp = await client.patch(
|
||||
f"/api/v1/chats/{chat_id}",
|
||||
json={"is_archived": True},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_archived"] is True
|
||||
|
||||
|
||||
async def test_delete_chat(client: AsyncClient, auth_headers: dict, chat_id: str):
|
||||
resp = await client.delete(f"/api/v1/chats/{chat_id}", headers=auth_headers)
|
||||
assert resp.status_code == 204
|
||||
|
||||
resp = await client.get(f"/api/v1/chats/{chat_id}", headers=auth_headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# --- Chat Limit ---
|
||||
|
||||
async def test_chat_limit(client: AsyncClient):
|
||||
"""Create a user with max_chats=2 and verify limit enforcement."""
|
||||
resp = await client.post("/api/v1/auth/register", json={
|
||||
"email": "limited@example.com",
|
||||
"username": "limiteduser",
|
||||
"password": "testpass123",
|
||||
})
|
||||
token = resp.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# The default max_chats is 10, create 10 chats
|
||||
for i in range(10):
|
||||
resp = await client.post("/api/v1/chats/", json={"title": f"Chat {i}"}, headers=headers)
|
||||
assert resp.status_code == 201
|
||||
|
||||
# 11th should fail
|
||||
resp = await client.post("/api/v1/chats/", json={"title": "Over limit"}, headers=headers)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# --- Ownership Isolation ---
|
||||
|
||||
async def test_cannot_access_other_users_chat(client: AsyncClient, auth_headers: dict, chat_id: str):
|
||||
# Register another user
|
||||
resp = await client.post("/api/v1/auth/register", json={
|
||||
"email": "other@example.com",
|
||||
"username": "otheruser",
|
||||
"password": "testpass123",
|
||||
})
|
||||
other_token = resp.json()["access_token"]
|
||||
other_headers = {"Authorization": f"Bearer {other_token}"}
|
||||
|
||||
# Try to access first user's chat
|
||||
resp = await client.get(f"/api/v1/chats/{chat_id}", headers=other_headers)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# --- Messages ---
|
||||
|
||||
async def test_get_messages_empty(client: AsyncClient, auth_headers: dict, chat_id: str):
|
||||
resp = await client.get(f"/api/v1/chats/{chat_id}/messages", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["messages"] == []
|
||||
|
||||
|
||||
# --- Admin Context ---
|
||||
|
||||
async def test_get_context_unauthenticated(client: AsyncClient, auth_headers: dict):
|
||||
resp = await client.get("/api/v1/admin/context", headers=auth_headers)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_admin_context_crud(client: AsyncClient):
|
||||
"""Test context CRUD with a direct DB admin (simplified: register + test endpoint access)."""
|
||||
# Note: This tests the endpoint structure. Full admin test would require
|
||||
# setting the user role to admin in the DB.
|
||||
resp = await client.post("/api/v1/auth/register", json={
|
||||
"email": "ctxadmin@example.com",
|
||||
"username": "ctxadmin",
|
||||
"password": "testpass123",
|
||||
})
|
||||
token = resp.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# Regular user should get 403
|
||||
resp = await client.get("/api/v1/admin/context", headers=headers)
|
||||
assert resp.status_code == 403
|
||||
|
||||
resp = await client.put("/api/v1/admin/context", json={"content": "test"}, headers=headers)
|
||||
assert resp.status_code == 403
|
||||
2299
frontend/package-lock.json
generated
2299
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,30 +9,32 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.60.0",
|
||||
"axios": "^1.7.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.0",
|
||||
"i18next": "^24.0.0",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"i18next-http-backend": "^3.0.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"axios": "^1.7.0",
|
||||
"zustand": "^5.0.0",
|
||||
"@tanstack/react-query": "^5.60.0",
|
||||
"i18next": "^24.0.0",
|
||||
"react-i18next": "^15.1.0",
|
||||
"i18next-http-backend": "^3.0.0",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
"clsx": "^2.1.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^1.7.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"sonner": "^1.7.0"
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.6.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"autoprefixer": "^10.4.0"
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,28 @@
|
||||
"welcome": "Welcome, {{name}}",
|
||||
"subtitle": "Your personal AI health assistant"
|
||||
},
|
||||
"chat": {
|
||||
"new_chat": "New Chat",
|
||||
"no_chats": "No chats yet. Create one to get started.",
|
||||
"no_messages": "Start a conversation...",
|
||||
"select_chat": "Select a chat to start messaging",
|
||||
"type_message": "Type a message...",
|
||||
"send": "Send",
|
||||
"archive": "Archive",
|
||||
"unarchive": "Unarchive",
|
||||
"delete_confirm": "Are you sure you want to delete this chat?",
|
||||
"limit_reached": "Chat limit reached",
|
||||
"streaming": "AI is thinking..."
|
||||
},
|
||||
"admin": {
|
||||
"context_editor": "Primary Context Editor",
|
||||
"context_placeholder": "Enter the system prompt for the AI assistant...",
|
||||
"save": "Save",
|
||||
"saved": "Saved",
|
||||
"unsaved_changes": "Unsaved changes",
|
||||
"version": "Version",
|
||||
"characters": "characters"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"error": "An error occurred",
|
||||
|
||||
@@ -42,6 +42,28 @@
|
||||
"welcome": "Добро пожаловать, {{name}}",
|
||||
"subtitle": "Ваш персональный ИИ-ассистент по здоровью"
|
||||
},
|
||||
"chat": {
|
||||
"new_chat": "Новый чат",
|
||||
"no_chats": "Пока нет чатов. Создайте первый.",
|
||||
"no_messages": "Начните разговор...",
|
||||
"select_chat": "Выберите чат для начала общения",
|
||||
"type_message": "Введите сообщение...",
|
||||
"send": "Отправить",
|
||||
"archive": "Архивировать",
|
||||
"unarchive": "Разархивировать",
|
||||
"delete_confirm": "Вы уверены, что хотите удалить этот чат?",
|
||||
"limit_reached": "Достигнут лимит чатов",
|
||||
"streaming": "ИИ думает..."
|
||||
},
|
||||
"admin": {
|
||||
"context_editor": "Редактор основного контекста",
|
||||
"context_placeholder": "Введите системный промпт для ИИ-ассистента...",
|
||||
"save": "Сохранить",
|
||||
"saved": "Сохранено",
|
||||
"unsaved_changes": "Несохранённые изменения",
|
||||
"version": "Версия",
|
||||
"characters": "символов"
|
||||
},
|
||||
"common": {
|
||||
"loading": "Загрузка...",
|
||||
"error": "Произошла ошибка",
|
||||
|
||||
21
frontend/src/api/admin.ts
Normal file
21
frontend/src/api/admin.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import api from "./client";
|
||||
|
||||
export interface ContextFile {
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
version: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export async function getPrimaryContext(): Promise<ContextFile | null> {
|
||||
const { data } = await api.get<ContextFile | null>("/admin/context");
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updatePrimaryContext(
|
||||
content: string
|
||||
): Promise<ContextFile> {
|
||||
const { data } = await api.put<ContextFile>("/admin/context", { content });
|
||||
return data;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export interface UserResponse {
|
||||
full_name: string | null;
|
||||
role: string;
|
||||
is_active: boolean;
|
||||
max_chats: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
147
frontend/src/api/chats.ts
Normal file
147
frontend/src/api/chats.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import api from "./client";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
|
||||
export interface Chat {
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
skill_id: string | null;
|
||||
is_archived: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
chat_id: string;
|
||||
role: "user" | "assistant" | "system" | "tool";
|
||||
content: string;
|
||||
metadata: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ChatListResponse {
|
||||
chats: Chat[];
|
||||
}
|
||||
|
||||
export interface MessageListResponse {
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
export async function createChat(title?: string): Promise<Chat> {
|
||||
const { data } = await api.post<Chat>("/chats/", { title });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getChats(archived?: boolean): Promise<Chat[]> {
|
||||
const params = archived !== undefined ? { archived } : {};
|
||||
const { data } = await api.get<ChatListResponse>("/chats/", { params });
|
||||
return data.chats;
|
||||
}
|
||||
|
||||
export async function getChat(chatId: string): Promise<Chat> {
|
||||
const { data } = await api.get<Chat>(`/chats/${chatId}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateChat(
|
||||
chatId: string,
|
||||
updates: { title?: string; is_archived?: boolean }
|
||||
): Promise<Chat> {
|
||||
const { data } = await api.patch<Chat>(`/chats/${chatId}`, updates);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteChat(chatId: string): Promise<void> {
|
||||
await api.delete(`/chats/${chatId}`);
|
||||
}
|
||||
|
||||
export async function getMessages(
|
||||
chatId: string,
|
||||
limit = 50,
|
||||
before?: string
|
||||
): Promise<Message[]> {
|
||||
const params: Record<string, unknown> = { limit };
|
||||
if (before) params.before = before;
|
||||
const { data } = await api.get<MessageListResponse>(
|
||||
`/chats/${chatId}/messages`,
|
||||
{ params }
|
||||
);
|
||||
return data.messages;
|
||||
}
|
||||
|
||||
export interface SSECallbacks {
|
||||
onStart?: (messageId: string) => void;
|
||||
onDelta?: (delta: string) => void;
|
||||
onComplete?: (messageId: string, metadata: Record<string, unknown>) => void;
|
||||
onError?: (detail: string) => void;
|
||||
}
|
||||
|
||||
export function sendMessage(
|
||||
chatId: string,
|
||||
content: string,
|
||||
callbacks: SSECallbacks,
|
||||
signal?: AbortSignal
|
||||
): void {
|
||||
const token = useAuthStore.getState().accessToken;
|
||||
|
||||
fetch(`/api/v1/chats/${chatId}/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ content }),
|
||||
signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ detail: "Request failed" }));
|
||||
callbacks.onError?.(err.detail || "Request failed");
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) return;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
let currentEvent = "";
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("event: ")) {
|
||||
currentEvent = line.slice(7).trim();
|
||||
} else if (line.startsWith("data: ")) {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
switch (currentEvent) {
|
||||
case "message_start":
|
||||
callbacks.onStart?.(data.message_id);
|
||||
break;
|
||||
case "content_delta":
|
||||
callbacks.onDelta?.(data.delta);
|
||||
break;
|
||||
case "message_end":
|
||||
callbacks.onComplete?.(data.message_id, data.metadata);
|
||||
break;
|
||||
case "error":
|
||||
callbacks.onError?.(data.detail);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name !== "AbortError") {
|
||||
callbacks.onError?.(err.message || "Network error");
|
||||
}
|
||||
});
|
||||
}
|
||||
92
frontend/src/components/admin/context-editor.tsx
Normal file
92
frontend/src/components/admin/context-editor.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getPrimaryContext, updatePrimaryContext } from "@/api/admin";
|
||||
import { Save } from "lucide-react";
|
||||
|
||||
export function ContextEditor() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [content, setContent] = useState("");
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["admin-context"],
|
||||
queryFn: getPrimaryContext,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data) {
|
||||
setContent(query.data.content);
|
||||
setHasChanges(false);
|
||||
}
|
||||
}, [query.data]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (text: string) => updatePrimaryContext(text),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin-context"] });
|
||||
setHasChanges(false);
|
||||
},
|
||||
});
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setContent(value);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
mutation.mutate(content);
|
||||
};
|
||||
|
||||
// Warn on unsaved changes
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
if (hasChanges) e.preventDefault();
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, [hasChanges]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
{t("admin.context_editor")}
|
||||
</h1>
|
||||
{query.data && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("admin.version")}: {query.data.version}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasChanges && (
|
||||
<span className="text-sm text-amber-500">{t("admin.unsaved_changes")}</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || mutation.isPending}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{mutation.isPending ? t("common.loading") : t("admin.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
rows={20}
|
||||
className="w-full resize-y rounded-lg border border-input bg-background px-4 py-3 text-sm font-mono ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
placeholder={t("admin.context_placeholder")}
|
||||
/>
|
||||
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{content.length.toLocaleString()} {t("admin.characters")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
frontend/src/components/chat/chat-list.tsx
Normal file
84
frontend/src/components/chat/chat-list.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Plus, Archive, Trash2, MessageSquare } from "lucide-react";
|
||||
import { useChatStore } from "@/stores/chat-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ChatListProps {
|
||||
onCreateChat: () => void;
|
||||
onDeleteChat: (chatId: string) => void;
|
||||
onArchiveChat: (chatId: string, archived: boolean) => void;
|
||||
limitReached?: boolean;
|
||||
}
|
||||
|
||||
export function ChatList({
|
||||
onCreateChat,
|
||||
onDeleteChat,
|
||||
onArchiveChat,
|
||||
limitReached,
|
||||
}: ChatListProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { chats, currentChatId } = useChatStore();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b p-3">
|
||||
<h2 className="text-sm font-semibold">{t("layout.chats")}</h2>
|
||||
<button
|
||||
onClick={onCreateChat}
|
||||
disabled={limitReached}
|
||||
title={limitReached ? t("chat.limit_reached") : t("chat.new_chat")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{chats.length === 0 && (
|
||||
<p className="px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
{t("chat.no_chats")}
|
||||
</p>
|
||||
)}
|
||||
{chats.map((chat) => (
|
||||
<div
|
||||
key={chat.id}
|
||||
onClick={() => navigate(`/chat/${chat.id}`)}
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-lg px-3 py-2 text-sm cursor-pointer transition-colors",
|
||||
chat.id === currentChatId
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 truncate">{chat.title}</span>
|
||||
<div className="hidden gap-1 group-hover:flex">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onArchiveChat(chat.id, !chat.is_archived);
|
||||
}}
|
||||
className="rounded p-1 hover:bg-accent"
|
||||
title={chat.is_archived ? t("chat.unarchive") : t("chat.archive")}
|
||||
>
|
||||
<Archive className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteChat(chat.id);
|
||||
}}
|
||||
className="rounded p-1 hover:bg-destructive/10 hover:text-destructive"
|
||||
title={t("common.delete")}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
frontend/src/components/chat/chat-window.tsx
Normal file
57
frontend/src/components/chat/chat-window.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MessageBubble } from "./message-bubble";
|
||||
import { MessageInput } from "./message-input";
|
||||
import { useChatStore } from "@/stores/chat-store";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
|
||||
interface ChatWindowProps {
|
||||
onSendMessage: (content: string) => void;
|
||||
}
|
||||
|
||||
export function ChatWindow({ onSendMessage }: ChatWindowProps) {
|
||||
const { t } = useTranslation();
|
||||
const { messages, isStreaming, streamingContent, currentChatId } = useChatStore();
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages, streamingContent]);
|
||||
|
||||
if (!currentChatId) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<MessageSquare className="h-12 w-12 mb-4 opacity-30" />
|
||||
<p className="text-lg">{t("chat.select_chat")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{messages.length === 0 && !isStreaming && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground">{t("chat.no_messages")}</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
role={msg.role as "user" | "assistant"}
|
||||
content={msg.content}
|
||||
/>
|
||||
))}
|
||||
{isStreaming && streamingContent && (
|
||||
<MessageBubble
|
||||
role="assistant"
|
||||
content={streamingContent}
|
||||
isStreaming
|
||||
/>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
<MessageInput onSend={onSendMessage} disabled={isStreaming} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/chat/message-bubble.tsx
Normal file
46
frontend/src/components/chat/message-bubble.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { User, Bot } from "lucide-react";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export function MessageBubble({ role, content, isStreaming }: MessageBubbleProps) {
|
||||
const isUser = role === "user";
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-3", isUser && "flex-row-reverse")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-full",
|
||||
isUser ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isUser ? <User className="h-4 w-4" /> : <Bot className="h-4 w-4" />}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[80%] rounded-xl px-4 py-3 text-sm",
|
||||
isUser
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-foreground"
|
||||
)}
|
||||
>
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap">{content}</p>
|
||||
) : (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{content}</Markdown>
|
||||
{isStreaming && (
|
||||
<span className="inline-block h-4 w-1 animate-pulse bg-foreground/50 ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
frontend/src/components/chat/message-input.tsx
Normal file
61
frontend/src/components/chat/message-input.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SendHorizonal } from "lucide-react";
|
||||
|
||||
interface MessageInputProps {
|
||||
onSend: (content: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function MessageInput({ onSend, disabled }: MessageInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState("");
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || disabled) return;
|
||||
onSend(trimmed);
|
||||
setValue("");
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
}
|
||||
}, [value, disabled, onSend]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setValue(e.target.value);
|
||||
const el = e.target;
|
||||
el.style.height = "auto";
|
||||
el.style.height = Math.min(el.scrollHeight, 200) + "px";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-2 border-t bg-card p-4">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t("chat.type_message")}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-lg border border-input bg-background px-3 py-2.5 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
||||
maxLength={50000}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled || !value.trim()}
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<SendHorizonal className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{ key: "dashboard", to: "/", icon: LayoutDashboard, enabled: true },
|
||||
{ key: "chats", to: "/chat", icon: MessageSquare, enabled: false },
|
||||
{ key: "chats", to: "/chat", icon: MessageSquare, enabled: true },
|
||||
{ key: "documents", to: "/documents", icon: FileText, enabled: false },
|
||||
{ key: "memory", to: "/memory", icon: Brain, enabled: false },
|
||||
{ key: "notifications", to: "/notifications", icon: Bell, enabled: false },
|
||||
@@ -83,15 +83,21 @@ export function Sidebar() {
|
||||
|
||||
{user?.role === "admin" && (
|
||||
<div className="border-t p-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-muted-foreground/50 cursor-not-allowed",
|
||||
!sidebarOpen && "justify-center px-2"
|
||||
)}
|
||||
<NavLink
|
||||
to="/admin/context"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||||
!sidebarOpen && "justify-center px-2"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Shield className="h-5 w-5 shrink-0" />
|
||||
{sidebarOpen && <span>{t("layout.admin")}</span>}
|
||||
</div>
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
168
frontend/src/hooks/use-chat.ts
Normal file
168
frontend/src/hooks/use-chat.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useChatStore } from "@/stores/chat-store";
|
||||
import {
|
||||
getChats,
|
||||
getMessages,
|
||||
createChat,
|
||||
updateChat,
|
||||
deleteChat,
|
||||
sendMessage,
|
||||
type Message,
|
||||
} from "@/api/chats";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
|
||||
export function useChat() {
|
||||
const queryClient = useQueryClient();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const {
|
||||
currentChatId,
|
||||
setChats,
|
||||
setCurrentChat,
|
||||
addChat,
|
||||
removeChat,
|
||||
updateChatInList,
|
||||
setMessages,
|
||||
appendMessage,
|
||||
setStreaming,
|
||||
appendStreamingContent,
|
||||
finalizeStreamingMessage,
|
||||
clearStreamingContent,
|
||||
} = useChatStore();
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Fetch chats
|
||||
const chatsQuery = useQuery({
|
||||
queryKey: ["chats"],
|
||||
queryFn: () => getChats(false),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (chatsQuery.data) {
|
||||
setChats(chatsQuery.data);
|
||||
}
|
||||
}, [chatsQuery.data, setChats]);
|
||||
|
||||
// Fetch messages for current chat
|
||||
const messagesQuery = useQuery({
|
||||
queryKey: ["messages", currentChatId],
|
||||
queryFn: () => getMessages(currentChatId!, 200),
|
||||
enabled: !!currentChatId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesQuery.data) {
|
||||
setMessages(messagesQuery.data);
|
||||
}
|
||||
}, [messagesQuery.data, setMessages]);
|
||||
|
||||
// Create chat
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (title?: string) => createChat(title),
|
||||
onSuccess: (chat) => {
|
||||
addChat(chat);
|
||||
queryClient.invalidateQueries({ queryKey: ["chats"] });
|
||||
},
|
||||
});
|
||||
|
||||
// Delete chat
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (chatId: string) => deleteChat(chatId),
|
||||
onSuccess: (_, chatId) => {
|
||||
removeChat(chatId);
|
||||
if (currentChatId === chatId) {
|
||||
setCurrentChat(null);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["chats"] });
|
||||
},
|
||||
});
|
||||
|
||||
// Archive/unarchive
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: ({ chatId, archived }: { chatId: string; archived: boolean }) =>
|
||||
updateChat(chatId, { is_archived: archived }),
|
||||
onSuccess: (chat) => {
|
||||
if (chat.is_archived) {
|
||||
removeChat(chat.id);
|
||||
} else {
|
||||
updateChatInList(chat);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["chats"] });
|
||||
},
|
||||
});
|
||||
|
||||
// Send message with SSE
|
||||
const handleSendMessage = useCallback(
|
||||
(content: string) => {
|
||||
if (!currentChatId) return;
|
||||
|
||||
// Add user message optimistically
|
||||
const tempUserMsg: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
chat_id: currentChatId,
|
||||
role: "user",
|
||||
content,
|
||||
metadata: null,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
appendMessage(tempUserMsg);
|
||||
setStreaming(true);
|
||||
|
||||
// Abort previous stream if any
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
sendMessage(currentChatId, content, {
|
||||
onDelta: (delta) => appendStreamingContent(delta),
|
||||
onComplete: (messageId, metadata) => {
|
||||
const finalContent = useChatStore.getState().streamingContent;
|
||||
finalizeStreamingMessage(messageId, finalContent, metadata);
|
||||
queryClient.invalidateQueries({ queryKey: ["chats"] });
|
||||
},
|
||||
onError: (detail) => {
|
||||
clearStreamingContent();
|
||||
console.error("SSE error:", detail);
|
||||
},
|
||||
}, controller.signal);
|
||||
},
|
||||
[
|
||||
currentChatId,
|
||||
appendMessage,
|
||||
setStreaming,
|
||||
appendStreamingContent,
|
||||
finalizeStreamingMessage,
|
||||
clearStreamingContent,
|
||||
queryClient,
|
||||
]
|
||||
);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Cleanup on chat switch
|
||||
useEffect(() => {
|
||||
abortRef.current?.abort();
|
||||
clearStreamingContent();
|
||||
}, [currentChatId, clearStreamingContent]);
|
||||
|
||||
const limitReached = chatsQuery.data
|
||||
? chatsQuery.data.length >= (user?.max_chats ?? 10) && user?.role !== "admin"
|
||||
: false;
|
||||
|
||||
return {
|
||||
chatsQuery,
|
||||
messagesQuery,
|
||||
createChat: createMutation.mutateAsync,
|
||||
deleteChat: deleteMutation.mutate,
|
||||
archiveChat: (chatId: string, archived: boolean) =>
|
||||
archiveMutation.mutate({ chatId, archived }),
|
||||
sendMessage: handleSendMessage,
|
||||
limitReached,
|
||||
};
|
||||
}
|
||||
13
frontend/src/pages/admin/context.tsx
Normal file
13
frontend/src/pages/admin/context.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { ContextEditor } from "@/components/admin/context-editor";
|
||||
|
||||
export function AdminContextPage() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
if (user?.role !== "admin") {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <ContextEditor />;
|
||||
}
|
||||
43
frontend/src/pages/chat.tsx
Normal file
43
frontend/src/pages/chat.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { ChatList } from "@/components/chat/chat-list";
|
||||
import { ChatWindow } from "@/components/chat/chat-window";
|
||||
import { useChatStore } from "@/stores/chat-store";
|
||||
import { useChat } from "@/hooks/use-chat";
|
||||
|
||||
export function ChatPage() {
|
||||
const { chatId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const setCurrentChat = useChatStore((s) => s.setCurrentChat);
|
||||
const { createChat, deleteChat, archiveChat, sendMessage, limitReached } = useChat();
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentChat(chatId || null);
|
||||
}, [chatId, setCurrentChat]);
|
||||
|
||||
const handleCreateChat = async () => {
|
||||
const chat = await createChat(undefined);
|
||||
navigate(`/chat/${chat.id}`);
|
||||
};
|
||||
|
||||
const handleDeleteChat = (id: string) => {
|
||||
deleteChat(id);
|
||||
if (chatId === id) navigate("/chat");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full -m-6">
|
||||
<div className="w-64 shrink-0 border-r bg-card">
|
||||
<ChatList
|
||||
onCreateChat={handleCreateChat}
|
||||
onDeleteChat={handleDeleteChat}
|
||||
onArchiveChat={archiveChat}
|
||||
limitReached={limitReached}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<ChatWindow onSendMessage={sendMessage} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { AppLayout } from "@/components/layout/app-layout";
|
||||
import { LoginPage } from "@/pages/login";
|
||||
import { RegisterPage } from "@/pages/register";
|
||||
import { DashboardPage } from "@/pages/dashboard";
|
||||
import { ChatPage } from "@/pages/chat";
|
||||
import { AdminContextPage } from "@/pages/admin/context";
|
||||
import { NotFoundPage } from "@/pages/not-found";
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
@@ -22,6 +24,9 @@ export const router = createBrowserRouter([
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
{ path: "chat", element: <ChatPage /> },
|
||||
{ path: "chat/:chatId", element: <ChatPage /> },
|
||||
{ path: "admin/context", element: <AdminContextPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
60
frontend/src/stores/chat-store.ts
Normal file
60
frontend/src/stores/chat-store.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { create } from "zustand";
|
||||
import type { Chat, Message } from "@/api/chats";
|
||||
|
||||
interface ChatState {
|
||||
chats: Chat[];
|
||||
currentChatId: string | null;
|
||||
messages: Message[];
|
||||
isStreaming: boolean;
|
||||
streamingContent: string;
|
||||
setChats: (chats: Chat[]) => void;
|
||||
setCurrentChat: (chatId: string | null) => void;
|
||||
addChat: (chat: Chat) => void;
|
||||
removeChat: (chatId: string) => void;
|
||||
updateChatInList: (chat: Chat) => void;
|
||||
setMessages: (messages: Message[]) => void;
|
||||
appendMessage: (message: Message) => void;
|
||||
setStreaming: (streaming: boolean) => void;
|
||||
appendStreamingContent: (delta: string) => void;
|
||||
finalizeStreamingMessage: (messageId: string, content: string, metadata: Record<string, unknown> | null) => void;
|
||||
clearStreamingContent: () => void;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>()((set) => ({
|
||||
chats: [],
|
||||
currentChatId: null,
|
||||
messages: [],
|
||||
isStreaming: false,
|
||||
streamingContent: "",
|
||||
|
||||
setChats: (chats) => set({ chats }),
|
||||
setCurrentChat: (chatId) => set({ currentChatId: chatId, messages: [], streamingContent: "" }),
|
||||
addChat: (chat) => set((s) => ({ chats: [chat, ...s.chats] })),
|
||||
removeChat: (chatId) => set((s) => ({ chats: s.chats.filter((c) => c.id !== chatId) })),
|
||||
updateChatInList: (chat) =>
|
||||
set((s) => ({
|
||||
chats: s.chats.map((c) => (c.id === chat.id ? chat : c)),
|
||||
})),
|
||||
setMessages: (messages) => set({ messages }),
|
||||
appendMessage: (message) => set((s) => ({ messages: [...s.messages, message] })),
|
||||
setStreaming: (streaming) => set({ isStreaming: streaming }),
|
||||
appendStreamingContent: (delta) =>
|
||||
set((s) => ({ streamingContent: s.streamingContent + delta })),
|
||||
finalizeStreamingMessage: (messageId, content, metadata) =>
|
||||
set((s) => ({
|
||||
messages: [
|
||||
...s.messages,
|
||||
{
|
||||
id: messageId,
|
||||
chat_id: s.currentChatId || "",
|
||||
role: "assistant" as const,
|
||||
content,
|
||||
metadata,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
streamingContent: "",
|
||||
isStreaming: false,
|
||||
})),
|
||||
clearStreamingContent: () => set({ streamingContent: "", isStreaming: false }),
|
||||
}));
|
||||
@@ -21,13 +21,16 @@ server {
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# API and WebSocket
|
||||
# API (with SSE support)
|
||||
location /api/ {
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_read_timeout 300s;
|
||||
}
|
||||
|
||||
location /ws/ {
|
||||
|
||||
252
plans/phase-2-chat-ai.md
Normal file
252
plans/phase-2-chat-ai.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# Phase 2: Chat & AI Core — Subplan
|
||||
|
||||
## Goal
|
||||
|
||||
Deliver a working AI chat system where users can create conversations, send messages, and receive streamed Claude API responses via SSE, with admin-editable primary context and per-user chat limits enforced.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Phase 1 completed: auth endpoints, frontend shell with login/register/dashboard
|
||||
- Anthropic API key available (`ANTHROPIC_API_KEY` env var)
|
||||
|
||||
---
|
||||
|
||||
## Database Schema (Phase 2)
|
||||
|
||||
### `chats` table
|
||||
|
||||
| Column | Type | Constraints |
|
||||
|---|---|---|
|
||||
| id | UUID | PK, default uuid4 (inherited from Base) |
|
||||
| user_id | UUID | FK -> users.id ON DELETE CASCADE, NOT NULL, indexed |
|
||||
| title | VARCHAR(255) | NOT NULL, default 'New Chat' |
|
||||
| skill_id | UUID | NULL (unused until Phase 3, added now for schema stability) |
|
||||
| is_archived | BOOLEAN | NOT NULL, default false |
|
||||
| created_at | TIMESTAMPTZ | inherited from Base |
|
||||
| updated_at | TIMESTAMPTZ | NOT NULL, default now(), onupdate now() |
|
||||
|
||||
### `messages` table
|
||||
|
||||
| Column | Type | Constraints |
|
||||
|---|---|---|
|
||||
| id | UUID | PK, default uuid4 (inherited from Base) |
|
||||
| chat_id | UUID | FK -> chats.id ON DELETE CASCADE, NOT NULL, indexed |
|
||||
| role | VARCHAR(20) | NOT NULL, CHECK IN ('user','assistant','system','tool') |
|
||||
| content | TEXT | NOT NULL |
|
||||
| metadata | JSONB | NULL (token counts, model info) |
|
||||
| created_at | TIMESTAMPTZ | inherited from Base |
|
||||
|
||||
### `context_files` table
|
||||
|
||||
| Column | Type | Constraints |
|
||||
|---|---|---|
|
||||
| id | UUID | PK, default uuid4 (inherited from Base) |
|
||||
| type | VARCHAR(20) | NOT NULL, CHECK IN ('primary','personal') |
|
||||
| user_id | UUID | FK -> users.id ON DELETE CASCADE, NULL (NULL for primary) |
|
||||
| content | TEXT | NOT NULL, default '' |
|
||||
| version | INTEGER | NOT NULL, default 1 |
|
||||
| updated_by | UUID | FK -> users.id ON DELETE SET NULL, NULL |
|
||||
| created_at | TIMESTAMPTZ | inherited from Base |
|
||||
| updated_at | TIMESTAMPTZ | NOT NULL, default now(), onupdate now() |
|
||||
|
||||
UNIQUE constraint: `(type, user_id)`
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints (Phase 2)
|
||||
|
||||
### Chat CRUD — `/api/v1/chats/`
|
||||
|
||||
| Method | Path | Request | Response | Notes |
|
||||
|---|---|---|---|---|
|
||||
| POST | `/` | `{ title?: string }` | `ChatResponse` 201 | Enforces `user.max_chats` on non-archived chats |
|
||||
| GET | `/` | Query: `archived?: bool` | `ChatListResponse` | User's chats, ordered by updated_at desc |
|
||||
| GET | `/{chat_id}` | — | `ChatResponse` | 404 if not owned |
|
||||
| PATCH | `/{chat_id}` | `{ title?, is_archived? }` | `ChatResponse` | Ownership check |
|
||||
| DELETE | `/{chat_id}` | — | 204 | Cascades messages |
|
||||
|
||||
### Messages — `/api/v1/chats/{chat_id}/messages`
|
||||
|
||||
| Method | Path | Request | Response | Notes |
|
||||
|---|---|---|---|---|
|
||||
| GET | `/` | Query: `limit?=50, before?: UUID` | `MessageListResponse` | Cursor-paginated |
|
||||
| POST | `/` | `{ content: string }` | SSE stream | Saves user msg, streams Claude response, saves assistant msg |
|
||||
|
||||
### Admin Context — `/api/v1/admin/context`
|
||||
|
||||
| Method | Path | Request | Response | Notes |
|
||||
|---|---|---|---|---|
|
||||
| GET | `/` | — | `ContextFileResponse` | Primary context content + version |
|
||||
| PUT | `/` | `{ content: string }` | `ContextFileResponse` | Upsert, increments version |
|
||||
|
||||
### SSE Event Format
|
||||
|
||||
```
|
||||
event: message_start
|
||||
data: {"message_id": "<uuid>"}
|
||||
|
||||
event: content_delta
|
||||
data: {"delta": "partial text..."}
|
||||
|
||||
event: message_end
|
||||
data: {"message_id": "<uuid>", "metadata": {"model": "...", "input_tokens": N, "output_tokens": N}}
|
||||
|
||||
event: error
|
||||
data: {"detail": "Error description"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Context Assembly (Phase 2)
|
||||
|
||||
1. Primary context file (admin-edited) → Claude `system` parameter
|
||||
2. Conversation history → Claude `messages` array
|
||||
3. Current user message → appended as final user message
|
||||
|
||||
Hardcoded default system prompt used when no primary context file exists.
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### A. Backend Database & Models (Tasks 1–4)
|
||||
|
||||
- [x] **- [x] **A1.** Add `ANTHROPIC_API_KEY` and `CLAUDE_MODEL` (default `claude-sonnet-4-20250514`) to `backend/app/config.py`. Add `anthropic` to `backend/pyproject.toml`. Add vars to `.env.example`.
|
||||
|
||||
- [x] **- [x] **A2.** Create `backend/app/models/chat.py`: Chat model per schema. Relationships: `messages`, `user`. Add `chats` relationship on User model.
|
||||
|
||||
- [x] **- [x] **A3.** Create `backend/app/models/message.py`: Message model per schema. Relationship: `chat`.
|
||||
|
||||
- [x] **- [x] **A4.** Create `backend/app/models/context_file.py`: ContextFile model per schema. UNIQUE(type, user_id). Update `backend/app/models/__init__.py`. Create Alembic migration `002_create_chats_messages_context_files.py`.
|
||||
|
||||
### B. Backend Schemas (Task 5)
|
||||
|
||||
- [x] **- [x] **B5.** Create `backend/app/schemas/chat.py`: `CreateChatRequest`, `UpdateChatRequest`, `SendMessageRequest`, `ChatResponse`, `ChatListResponse`, `MessageResponse`, `MessageListResponse`, `ContextFileResponse`, `UpdateContextRequest`.
|
||||
|
||||
### C. Backend Services (Tasks 6–8)
|
||||
|
||||
- [x] **- [x] **C6.** Create `backend/app/services/chat_service.py`: `create_chat`, `get_user_chats`, `get_chat`, `update_chat`, `delete_chat`, `get_messages`, `save_message`. Chat limit enforcement: count non-archived chats vs `user.max_chats`.
|
||||
|
||||
- [x] **- [x] **C7.** Create `backend/app/services/ai_service.py`: `assemble_context` (loads primary context + history), `stream_ai_response` (async generator: saves user msg → calls Claude streaming API → yields SSE events → saves assistant msg). Uses `anthropic.AsyncAnthropic().messages.stream()`.
|
||||
|
||||
- [x] **- [x] **C8.** Create `backend/app/services/context_service.py`: `get_primary_context`, `upsert_primary_context`. Default system prompt constant.
|
||||
|
||||
### D. Backend API Endpoints (Tasks 9–11)
|
||||
|
||||
- [x] **- [x] **D9.** Create `backend/app/api/v1/chats.py`: Chat CRUD + message list + SSE streaming endpoint. Returns `StreamingResponse(media_type="text/event-stream")` with no-cache headers.
|
||||
|
||||
- [x] **- [x] **D10.** Create `backend/app/api/v1/admin.py`: `GET /admin/context`, `PUT /admin/context`. Protected by `require_admin`.
|
||||
|
||||
- [x] **- [x] **D11.** Register routers in `backend/app/api/v1/router.py`. Add `proxy_buffering off;` to nginx `/api/` location for SSE.
|
||||
|
||||
### E. Frontend API & Store (Tasks 12–14)
|
||||
|
||||
- [x] **- [x] **E12.** Create `frontend/src/api/chats.ts`: typed API functions + `sendMessage` using `fetch()` + `ReadableStream` for SSE parsing.
|
||||
|
||||
- [x] **- [x] **E13.** Create `frontend/src/api/admin.ts`: `getPrimaryContext()`, `updatePrimaryContext(content)`.
|
||||
|
||||
- [x] **- [x] **E14.** Create `frontend/src/stores/chat-store.ts` (Zustand, not persisted): chats, currentChatId, messages, isStreaming, streamingContent, actions.
|
||||
|
||||
### F. Frontend Chat Components (Tasks 15–20)
|
||||
|
||||
- [x] **- [x] **F15.** Create `frontend/src/components/chat/chat-list.tsx`: chat sidebar with list, new chat button, archive/delete actions. TanStack Query for fetching.
|
||||
|
||||
- [x] **- [x] **F16.** Create `frontend/src/components/chat/message-bubble.tsx`: role-based styling (user right/blue, assistant left/gray). Markdown rendering for assistant messages (`react-markdown` + `remark-gfm`). Add deps to `package.json`.
|
||||
|
||||
- [x] **- [x] **F17.** Create `frontend/src/components/chat/message-input.tsx`: auto-growing textarea, Enter to send (Shift+Enter newline), disabled while streaming, send icon button.
|
||||
|
||||
- [x] **- [x] **F18.** Create `frontend/src/components/chat/chat-window.tsx`: message list (auto-scroll) + input. Loads messages via TanStack Query. Handles streaming flow. Empty state.
|
||||
|
||||
- [x] **- [x] **F19.** Create `frontend/src/pages/chat.tsx`: chat list panel (left) + chat window (right). Routes: `/chat` and `/chat/:chatId`.
|
||||
|
||||
- [x] **- [x] **F20.** Create `frontend/src/hooks/use-chat.ts`: encapsulates TanStack queries, mutations, SSE send flow with store updates, abort on unmount.
|
||||
|
||||
### G. Frontend Admin Context Editor (Tasks 21–22)
|
||||
|
||||
- [x] **- [x] **G21.** Create `frontend/src/components/admin/context-editor.tsx`: textarea editor, load/save, version display, unsaved changes warning.
|
||||
|
||||
- [x] **- [x] **G22.** Create `frontend/src/pages/admin/context.tsx`: page wrapper, admin role protected.
|
||||
|
||||
### H. Frontend Routing & Navigation (Tasks 23–24)
|
||||
|
||||
- [x] **- [x] **H23.** Update `frontend/src/routes.tsx`: add `/chat`, `/chat/:chatId`, `/admin/context` routes.
|
||||
|
||||
- [x] **- [x] **H24.** Update `frontend/src/components/layout/sidebar.tsx`: enable Chats and Admin nav items.
|
||||
|
||||
### I. i18n (Task 25)
|
||||
|
||||
- [x] **- [x] **I25.** Update `en/translation.json` and `ru/translation.json` with chat and admin keys.
|
||||
|
||||
### J. Backend Tests (Task 26)
|
||||
|
||||
- [x] **- [x] **J26.** Create `backend/tests/test_chats.py`: chat CRUD, limit enforcement, message pagination, SSE streaming format, admin context CRUD, ownership isolation.
|
||||
|
||||
---
|
||||
|
||||
## Files to Create
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `backend/app/models/chat.py` | Chat ORM model |
|
||||
| `backend/app/models/message.py` | Message ORM model |
|
||||
| `backend/app/models/context_file.py` | ContextFile ORM model |
|
||||
| `backend/alembic/versions/002_create_chats_messages_context_files.py` | Migration |
|
||||
| `backend/app/schemas/chat.py` | Request/response schemas |
|
||||
| `backend/app/services/chat_service.py` | Chat + message business logic |
|
||||
| `backend/app/services/ai_service.py` | Claude API integration + SSE streaming |
|
||||
| `backend/app/services/context_service.py` | Primary context CRUD |
|
||||
| `backend/app/api/v1/chats.py` | Chat + message endpoints |
|
||||
| `backend/app/api/v1/admin.py` | Admin context endpoints |
|
||||
| `backend/tests/test_chats.py` | Tests |
|
||||
| `frontend/src/api/chats.ts` | Chat API client + SSE consumer |
|
||||
| `frontend/src/api/admin.ts` | Admin API client |
|
||||
| `frontend/src/stores/chat-store.ts` | Chat UI state |
|
||||
| `frontend/src/hooks/use-chat.ts` | Chat data + streaming hook |
|
||||
| `frontend/src/components/chat/chat-list.tsx` | Chat list sidebar |
|
||||
| `frontend/src/components/chat/message-bubble.tsx` | Message display |
|
||||
| `frontend/src/components/chat/message-input.tsx` | Message input |
|
||||
| `frontend/src/components/chat/chat-window.tsx` | Main chat area |
|
||||
| `frontend/src/pages/chat.tsx` | Chat page |
|
||||
| `frontend/src/components/admin/context-editor.tsx` | Context editor |
|
||||
| `frontend/src/pages/admin/context.tsx` | Admin context page |
|
||||
|
||||
## Files to Modify
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `backend/pyproject.toml` | Add `anthropic` dependency |
|
||||
| `backend/app/config.py` | Add `ANTHROPIC_API_KEY`, `CLAUDE_MODEL` |
|
||||
| `backend/app/models/__init__.py` | Import Chat, Message, ContextFile |
|
||||
| `backend/app/models/user.py` | Add `chats` relationship |
|
||||
| `backend/app/api/v1/router.py` | Include chats + admin routers |
|
||||
| `nginx/nginx.conf` | Add `proxy_buffering off;` for SSE |
|
||||
| `.env.example` | Add `ANTHROPIC_API_KEY`, `CLAUDE_MODEL` |
|
||||
| `frontend/package.json` | Add `react-markdown`, `remark-gfm` |
|
||||
| `frontend/src/routes.tsx` | Add chat + admin routes |
|
||||
| `frontend/src/components/layout/sidebar.tsx` | Enable chat + admin nav |
|
||||
| `frontend/public/locales/en/translation.json` | Chat + admin keys |
|
||||
| `frontend/public/locales/ru/translation.json` | Chat + admin keys |
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. Migration creates `chats`, `messages`, `context_files` tables correctly
|
||||
2. `POST /chats/` enforces max_chats limit (403 when exceeded)
|
||||
3. Chat CRUD works with ownership isolation (user A can't access user B's chats)
|
||||
4. `POST /chats/{id}/messages` returns SSE stream with `message_start`, `content_delta`, `message_end` events
|
||||
5. Both user and assistant messages are persisted after streaming completes
|
||||
6. Context assembly includes primary context file as system prompt
|
||||
7. Admin context GET/PUT works with version incrementing, protected by admin role
|
||||
8. Frontend: `/chat` shows chat list, create/select/archive/delete chats
|
||||
9. Frontend: sending a message streams the AI response character-by-character
|
||||
10. Frontend: admin can edit the primary context at `/admin/context`
|
||||
11. Sidebar shows enabled Chat and Admin navigation
|
||||
12. All new UI text works in both English and Russian
|
||||
13. All backend tests pass
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
**COMPLETED** — All code written. TypeScript compiles clean. Vite builds successfully. Docker smoke test deferred (Docker not installed).
|
||||
Reference in New Issue
Block a user