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:
2026-03-19 12:38:30 +03:00
parent 7c752cae6b
commit 70469beef8
39 changed files with 4168 additions and 47 deletions

View File

@@ -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

View File

@@ -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

View File

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

View 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
View 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",
},
)

View File

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

View File

@@ -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"

View File

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

View 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

View 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
)

View 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

View File

@@ -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

View File

@@ -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}

View 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

View 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)})

View 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

View 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

View File

@@ -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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

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

View File

@@ -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
View 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;
}

View File

@@ -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
View 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");
}
});
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>

View 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,
};
}

View 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 />;
}

View 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>
);
}

View File

@@ -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 /> },
],
},
],

View 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 }),
}));

View File

@@ -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
View 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 14)
- [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 68)
- [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 911)
- [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 1214)
- [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 1520)
- [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 2122)
- [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 2324)
- [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).