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

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