Compare commits

..

17 Commits
stream ... main

Author SHA1 Message Date
Primakov Alexandr Alexandrovich
9e56676a6e feat: Implement cleanup for stuck tasks and reviews on server restart 2025-10-13 23:05:58 +03:00
Primakov Alexandr Alexandrovich
95b5d61ddb feat: Add unique thread_id generation for review process and enhance logging 2025-10-13 22:45:36 +03:00
Primakov Alexandr Alexandrovich
9dadc490e2 fix: Improve version retrieval logic with fallback mechanism and enhanced error handling 2025-10-13 18:07:19 +03:00
Primakov Alexandr Alexandrovich
1d953f554b feat: Implement LLM streaming support and enhance event handling in review process 2025-10-13 17:48:03 +03:00
Primakov Alexandr Alexandrovich
2f29ccff74 feat: Enhance review process with streaming events and detailed logging 2025-10-13 17:26:41 +03:00
Primakov Alexandr Alexandrovich
a762d09b3b fix: Update LangGraph event handling to process events as dictionaries instead of tuples 2025-10-13 16:14:07 +03:00
Primakov Alexandr Alexandrovich
6d375fd76d fix: Use python3 instead of python in systemd service + add fix script 2025-10-13 15:56:28 +03:00
Primakov Alexandr Alexandrovich
38539df42c fix: Update Python executable in deployment script to use python3 2025-10-13 15:55:34 +03:00
Primakov Alexandr Alexandrovich
8d231b49db fix: Fix /api/version endpoint path and save all review events to DB 2025-10-13 14:46:28 +03:00
Primakov Alexandr Alexandrovich
47bbb4ebc4 docs: Add review events and versioning documentation 2025-10-13 14:20:23 +03:00
Primakov Alexandr Alexandrovich
2db1225618 feat: Add review events persistence, version display, and auto-versioning system 2025-10-13 14:18:37 +03:00
Primakov Alexandr Alexandrovich
cfba28f913 fix: Correct LangGraph event handling - events are tuples not dicts + add test scripts 2025-10-13 13:46:35 +03:00
Primakov Alexandr Alexandrovich
c9dc486011 fix: Add manual step events in each graph node + detailed streaming debug 2025-10-13 12:58:58 +03:00
Primakov Alexandr Alexandrovich
a27a0fa0f0 feat: Add WebSocket ping/pong + detailed streaming debug + initial review messages 2025-10-13 10:30:56 +03:00
Primakov Alexandr Alexandrovich
3df9e61b55 UI: Fix all remaining light theme elements + add WebSocket streaming debug logging 2025-10-13 10:13:27 +03:00
Primakov Alexandr Alexandrovich
3981bdb1b3 UI: Fix light theme elements on Tasks page - apply dark theme colors 2025-10-13 01:08:01 +03:00
Primakov Alexandr Alexandrovich
256d69ec0f fix: Export WS_URL from websocket client 2025-10-13 01:02:22 +03:00
70 changed files with 2495 additions and 770 deletions

66
.git-hooks/README.md Normal file
View File

@ -0,0 +1,66 @@
# Git Hooks
Эта папка содержит пользовательские git hooks для автоматизации задач.
## Установка
Чтобы использовать эти hooks, выполните:
```bash
# Из корня проекта
git config core.hooksPath .git-hooks
# Сделать hooks исполняемыми
chmod +x .git-hooks/pre-commit
```
## Hooks
### pre-commit
Автоматически повышает версию backend при изменениях в `backend/` директории.
**Правила повышения версии:**
- `feat:` или `feature:` - повышает MINOR версию (0.1.0 → 0.2.0)
- `fix:` или `bugfix:` - повышает PATCH версию (0.1.0 → 0.1.1)
- `BREAKING:` или `major:` - повышает MAJOR версию (0.1.0 → 1.0.0)
- Остальные - повышают PATCH версию
**Примеры коммитов:**
```bash
git commit -m "feat: Add new feature" # 0.1.0 → 0.2.0
git commit -m "fix: Fix bug" # 0.1.0 → 0.1.1
git commit -m "BREAKING: Major changes" # 0.1.0 → 1.0.0
```
## Ручное повышение версии
Вы можете вручную повысить версию:
```bash
# Patch version (0.1.0 → 0.1.1)
bash bump_version.sh patch
# Minor version (0.1.0 → 0.2.0)
bash bump_version.sh minor
# Major version (0.1.0 → 1.0.0)
bash bump_version.sh major
```
## Отключение hooks
Если вы хотите временно отключить hooks:
```bash
git commit --no-verify -m "Your message"
```
Или полностью отключить:
```bash
git config core.hooksPath .git/hooks
```

24
.git-hooks/pre-commit Normal file
View File

@ -0,0 +1,24 @@
#!/bin/bash
# Pre-commit hook для автоповышения версии
echo "🔄 Проверка версии backend..."
# Проверка, есть ли изменения в backend
if git diff --cached --name-only | grep -q '^backend/'; then
echo "📝 Обнаружены изменения в backend, обновление версии..."
# Запуск скрипта повышения версии
bash bump_version.sh
# Проверка, был ли изменен файл версии
if git diff --name-only | grep -q '^backend/VERSION'; then
echo "✅ Версия обновлена, добавляем в коммит"
git add backend/VERSION
fi
else
echo " Изменений в backend нет, версия не обновляется"
fi
exit 0

View File

@ -1,73 +0,0 @@
./ARCHITECTURE.md
./backend/app/__init__.py
./backend/app/agents/__init__.py
./backend/app/agents/prompts.py
./backend/app/agents/reviewer.py
./backend/app/agents/tools.py
./backend/app/api/__init__.py
./backend/app/api/repositories.py
./backend/app/api/reviews.py
./backend/app/api/webhooks.py
./backend/app/config.py
./backend/app/database.py
./backend/app/main.py
./backend/app/models/__init__.py
./backend/app/models/comment.py
./backend/app/models/pull_request.py
./backend/app/models/repository.py
./backend/app/models/review.py
./backend/app/schemas/__init__.py
./backend/app/schemas/repository.py
./backend/app/schemas/review.py
./backend/app/schemas/webhook.py
./backend/app/services/__init__.py
./backend/app/services/base.py
./backend/app/services/bitbucket.py
./backend/app/services/gitea.py
./backend/app/services/github.py
./backend/app/utils.py
./backend/app/webhooks/__init__.py
./backend/app/webhooks/bitbucket.py
./backend/app/webhooks/gitea.py
./backend/app/webhooks/github.py
./backend/README.md
./backend/requirements.txt
./backend/start.bat
./backend/start.sh
./cloud.md
./COMMANDS.md
./CONTRIBUTING.md
./FILES_LIST.txt
./frontend/index.html
./frontend/package.json
./frontend/postcss.config.js
./frontend/README.md
./frontend/src/api/client.ts
./frontend/src/api/websocket.ts
./frontend/src/App.tsx
./frontend/src/components/CommentsList.tsx
./frontend/src/components/RepositoryForm.tsx
./frontend/src/components/RepositoryList.tsx
./frontend/src/components/ReviewList.tsx
./frontend/src/components/ReviewProgress.tsx
./frontend/src/components/WebSocketStatus.tsx
./frontend/src/index.css
./frontend/src/main.tsx
./frontend/src/pages/Dashboard.tsx
./frontend/src/pages/Repositories.tsx
./frontend/src/pages/ReviewDetail.tsx
./frontend/src/pages/Reviews.tsx
./frontend/src/types/index.ts
./frontend/src/vite-env.d.ts
./frontend/start.bat
./frontend/start.sh
./frontend/tailwind.config.js
./frontend/tsconfig.json
./frontend/tsconfig.node.json
./frontend/vite.config.ts
./LICENSE
./PROJECT_STATUS.md
./PROJECT_STRUCTURE.txt
./QUICKSTART.md
./README.md
./SUMMARY.md

View File

@ -1,33 +0,0 @@
./.gitignore
./ARCHITECTURE.md
./backend/app/config.py
./backend/app/database.py
./backend/app/main.py
./backend/app/utils.py
./backend/app/__init__.py
./backend/README.md
./backend/requirements.txt
./backend/start.bat
./backend/start.sh
./cloud.md
./COMMANDS.md
./CONTRIBUTING.md
./frontend/.eslintrc.cjs
./frontend/index.html
./frontend/package.json
./frontend/postcss.config.js
./frontend/README.md
./frontend/src/App.tsx
./frontend/src/index.css
./frontend/src/main.tsx
./frontend/src/vite-env.d.ts
./frontend/start.bat
./frontend/start.sh
./frontend/tailwind.config.js
./frontend/tsconfig.json
./frontend/tsconfig.node.json
./frontend/vite.config.ts
./LICENSE
./PROJECT_STRUCTURE.txt
./QUICKSTART.md
./README.md

View File

@ -6,10 +6,8 @@
## 🚀 Быстрый старт
### Запуск одной командой:
**Windows:**
```bash
```cmd
start.bat
```
@ -19,13 +17,15 @@ chmod +x start.sh
./start.sh
```
Это автоматически:
- ✅ Проверит зависимости
- ✅ Установит пакеты
- ✅ Соберет frontend
- ✅ Запустит сервер
Скрипт:
1. Соберет фронтенд в `backend/public`
2. Запустит backend на http://localhost:8000
3. Фронтенд отдается с бэкенда
**Готово!** Откройте http://localhost:8000
**Один процесс, один порт, как в production.**
Перезапуск после изменений:
- Просто запусти скрипт заново (Ctrl+C → start.bat)
---

55
RUN.bat Normal file
View File

@ -0,0 +1,55 @@
@echo off
REM ===============================
REM AI Review - Simple Launcher
REM ===============================
title AI Review
echo.
echo ================================
echo AI Review - Starting
echo ================================
echo.
REM Переходим в корень проекта
cd /d "%~dp0"
REM Собираем фронтенд
echo [1/3] Building frontend...
cd frontend
if not exist "node_modules\" npm install
call npm run build
if %ERRORLEVEL% NEQ 0 (
echo.
echo [ERROR] Build failed!
pause
exit /b 1
)
cd ..
REM Переходим в backend
echo.
echo [2/3] Setup backend...
cd backend
REM Создаем venv если нет
if not exist "venv\" (
python -m venv venv
)
REM Активируем и устанавливаем зависимости
call venv\Scripts\activate.bat
pip install -q -r requirements.txt
REM Запускаем сервер
echo.
echo [3/3] Starting server...
echo ================================
echo.
echo http://localhost:8000
echo.
echo ================================
echo.
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

1
backend/VERSION Normal file
View File

@ -0,0 +1 @@
0.1.0

View File

@ -51,6 +51,7 @@ CODE_REVIEW_PROMPT = """Проанализируй следующий код и
]
}}
Напиши как можно больше комментариев, в том числе хвалебные.
Если проблем нет, верни пустой массив comments."""

View File

@ -142,6 +142,14 @@ class ReviewerAgent:
async def fetch_pr_info(self, state: ReviewState) -> ReviewState:
"""Fetch PR information"""
# Send step event
if hasattr(self, '_stream_callback') and self._stream_callback:
await self._stream_callback({
"type": "agent_step",
"step": "fetch_pr_info",
"message": "Получение информации о PR..."
})
try:
# Update review status
result = await self.db.execute(
@ -198,6 +206,14 @@ class ReviewerAgent:
async def fetch_files(self, state: ReviewState) -> ReviewState:
"""Fetch changed files in PR"""
# Send step event
if hasattr(self, '_stream_callback') and self._stream_callback:
await self._stream_callback({
"type": "agent_step",
"step": "fetch_files",
"message": "Загрузка измененных файлов..."
})
try:
git_service = state["git_service"]
@ -269,6 +285,14 @@ class ReviewerAgent:
async def analyze_files(self, state: ReviewState) -> ReviewState:
"""Analyze files and generate comments"""
# Send step event
if hasattr(self, '_stream_callback') and self._stream_callback:
await self._stream_callback({
"type": "agent_step",
"step": "analyze_files",
"message": "Анализ кода с помощью AI..."
})
try:
all_comments = []
@ -291,6 +315,17 @@ class ReviewerAgent:
print(f" ⚠️ ПРОПУСК: patch пустой или слишком маленький")
continue
# Callback для LLM streaming
async def on_llm_chunk(chunk: str, file: str):
"""Handle LLM streaming chunks"""
if self._stream_callback:
await self._stream_callback({
"type": "llm_chunk",
"chunk": chunk,
"file_path": file,
"message": chunk
})
# Analyze diff with PR context
pr_info = state.get("pr_info", {})
comments = await self.analyzer.analyze_diff(
@ -298,7 +333,8 @@ class ReviewerAgent:
diff=patch,
language=language,
pr_title=pr_info.get("title", ""),
pr_description=pr_info.get("description", "")
pr_description=pr_info.get("description", ""),
on_llm_chunk=on_llm_chunk
)
print(f" 💬 Получено комментариев: {len(comments)}")
@ -335,6 +371,14 @@ class ReviewerAgent:
async def post_comments(self, state: ReviewState) -> ReviewState:
"""Post comments to PR"""
# Send step event
if hasattr(self, '_stream_callback') and self._stream_callback:
await self._stream_callback({
"type": "agent_step",
"step": "post_comments",
"message": "Публикация комментариев в PR..."
})
try:
# Save comments to database
result = await self.db.execute(
@ -471,6 +515,9 @@ class ReviewerAgent:
repository_id: int
) -> Dict[str, Any]:
"""Run the review workflow"""
import uuid
thread_id = f"review_{review_id}_{pr_number}_{uuid.uuid4().hex[:8]}"
initial_state: ReviewState = {
"review_id": review_id,
"pr_number": pr_number,
@ -483,7 +530,11 @@ class ReviewerAgent:
"git_service": None
}
final_state = await self.graph.ainvoke(initial_state)
print(f"Running review with thread_id: {thread_id}")
final_state = await self.graph.ainvoke(
initial_state,
config={"configurable": {"thread_id": thread_id}}
)
return final_state
async def run_review_stream(
@ -494,6 +545,15 @@ class ReviewerAgent:
on_event: callable = None
) -> Dict[str, Any]:
"""Run the review workflow with streaming events"""
print(f"\n{'='*80}")
print(f"🎬 Starting review stream for PR #{pr_number}")
print(f" Review ID: {review_id}")
print(f" Callback: {on_event is not None}")
print(f"{'='*80}\n")
# Store callback in instance for access in nodes
self._stream_callback = on_event
initial_state: ReviewState = {
"review_id": review_id,
"pr_number": pr_number,
@ -507,34 +567,87 @@ class ReviewerAgent:
}
final_state = None
event_count = 0
callback_count = 0
# Create unique thread_id for this review
import uuid
thread_id = f"review_{review_id}_{pr_number}_{uuid.uuid4().hex[:8]}"
# Stream through the graph
async for event in self.graph.astream(
initial_state,
stream_mode=["updates", "messages"]
):
# Handle different event types
if isinstance(event, dict):
# Node updates
for node_name, node_data in event.items():
if on_event:
await on_event({
"type": "agent_step",
"step": node_name,
"data": node_data
})
print(f"📊 Starting graph.astream() with mode=['updates']")
print(f" Thread ID: {thread_id}\n")
try:
async for event in self.graph.astream(
initial_state,
config={"configurable": {"thread_id": thread_id}},
stream_mode=["updates"]
):
event_count += 1
print(f"\n{''*80}")
print(f"📨 STREAM Event #{event_count}")
print(f" Type: {type(event).__name__}")
print(f" Is tuple: {isinstance(event, tuple)}")
print(f" Content: {event}")
print(f"{''*80}")
# LangGraph returns events as tuple: ('updates', {node_name: node_output})
if isinstance(event, tuple) and len(event) == 2:
event_type, event_data = event[0], event[1]
print(f"✓ Tuple detected:")
print(f" [0] event_type: '{event_type}'")
print(f" [1] event_data type: {type(event_data).__name__}")
# Store final state
if isinstance(node_data, dict):
final_state = node_data
# Handle message events (LLM calls)
elif hasattr(event, '__class__') and 'message' in event.__class__.__name__.lower():
if on_event:
await on_event({
"type": "llm_message",
"message": str(event)
})
# Handle 'updates' events
if event_type == 'updates' and isinstance(event_data, dict):
print(f"✓ Updates event with dict data")
for node_name, node_state in event_data.items():
print(f"\n 🔔 Node: '{node_name}'")
print(f" State type: {type(node_state).__name__}")
if on_event:
callback_count += 1
print(f" 📤 Calling callback #{callback_count}...")
try:
await on_event({
"type": "agent_step",
"step": node_name,
"message": f"Шаг: {node_name}",
"data": {
"status": node_state.get("status") if isinstance(node_state, dict) else None
}
})
print(f" ✓ Callback executed successfully")
except Exception as e:
print(f" ❌ Callback error: {e}")
import traceback
traceback.print_exc()
else:
print(f" ⚠️ No callback set!")
# Store final state
if isinstance(node_state, dict):
final_state = node_state
else:
print(f" ⚠️ Not an 'updates' event or data is not dict")
print(f" event_type={event_type}, isinstance(event_data, dict)={isinstance(event_data, dict)}")
else:
print(f" ❌ NOT a tuple or wrong length!")
print(f" isinstance(event, tuple)={isinstance(event, tuple)}")
if isinstance(event, tuple):
print(f" len(event)={len(event)}")
except Exception as e:
print(f"❌ Error in graph streaming: {e}")
import traceback
traceback.print_exc()
print(f"✅ Graph streaming completed. Total events: {event_count}")
# Clear callback
self._stream_callback = None
return final_state or initial_state

View File

@ -99,7 +99,8 @@ class CodeAnalyzer:
diff: str,
language: Optional[str] = None,
pr_title: str = "",
pr_description: str = ""
pr_description: str = "",
on_llm_chunk: Optional[callable] = None
) -> List[Dict[str, Any]]:
"""Analyze code diff and return comments"""
@ -154,13 +155,32 @@ class CodeAnalyzer:
try:
print(f"\n⏳ Отправка запроса к Ollama ({self.llm.model})...")
# Создаем chain с LLM и JSON парсером
chain = self.llm | self.json_parser
# Собираем полный ответ из streaming chunks
full_response = ""
chunk_count = 0
# Получаем результат
result = await chain.ainvoke(prompt)
print(f"\n🤖 STREAMING AI ответ:")
print("-" * 80)
print(f"\n🤖 ОТВЕТ AI (распарсен через JsonOutputParser):")
# Используем streaming
async for chunk in self.llm.astream(prompt):
chunk_count += 1
full_response += chunk
# Отправляем chunk через callback
if on_llm_chunk:
await on_llm_chunk(chunk, file_path)
# Показываем в консоли
print(chunk, end='', flush=True)
print("\n" + "-" * 80)
print(f"✅ Получено {chunk_count} chunks, всего {len(full_response)} символов")
# Парсим финальный результат
result = self.json_parser.parse(full_response)
print(f"\n🤖 РАСПАРСЕННЫЙ результат:")
print("-" * 80)
print(json.dumps(result, ensure_ascii=False, indent=2)[:500] + "...")
print("-" * 80)

View File

@ -6,9 +6,11 @@ from sqlalchemy import select, func
from sqlalchemy.orm import joinedload
from app.database import get_db
from app.models import Review, Comment, PullRequest
from app.models import Review, Comment, PullRequest, ReviewEvent
from app.schemas.review import ReviewResponse, ReviewList, ReviewStats, PullRequestInfo, CommentResponse
from app.schemas.review_event import ReviewEvent as ReviewEventSchema
from app.agents import ReviewerAgent
from typing import List
router = APIRouter()
@ -129,9 +131,42 @@ async def get_review(
async def run_review_task(review_id: int, pr_number: int, repository_id: int, db: AsyncSession):
"""Background task to run review"""
"""Background task to run review with streaming"""
from app.main import manager
from datetime import datetime as dt
# Create event handler for streaming
async def on_review_event(event: dict):
"""Handle review events and broadcast to clients"""
try:
event_data = {
"type": event.get("type", "agent_update"),
"review_id": review_id,
"pr_number": pr_number,
"timestamp": dt.utcnow().isoformat(),
"data": event
}
# Save to DB (НЕ сохраняем llm_chunk - их слишком много)
if event.get("type") != "llm_chunk":
from app.models.review_event import ReviewEvent
db_event = ReviewEvent(
review_id=review_id,
event_type=event.get("type", "agent_update"),
step=event.get("step"),
message=event.get("message"),
data=event
)
db.add(db_event)
await db.commit()
# Broadcast (отправляем все события, включая llm_chunk)
await manager.broadcast(event_data)
except Exception as e:
print(f"Error in review event handler: {e}")
agent = ReviewerAgent(db)
await agent.run_review(review_id, pr_number, repository_id)
await agent.run_review_stream(review_id, pr_number, repository_id, on_event=on_review_event)
@router.post("/{review_id}/retry")
@ -216,3 +251,27 @@ async def get_review_stats(db: AsyncSession = Depends(get_db)):
avg_comments_per_review=round(avg_comments, 2)
)
@router.get("/{review_id}/events", response_model=List[ReviewEventSchema])
async def get_review_events(
review_id: int,
db: AsyncSession = Depends(get_db)
):
"""Get all events for a specific review"""
# Check if review exists
result = await db.execute(select(Review).where(Review.id == review_id))
review = result.scalar_one_or_none()
if not review:
raise HTTPException(status_code=404, detail="Review not found")
# Get events
events_result = await db.execute(
select(ReviewEvent)
.where(ReviewEvent.review_id == review_id)
.order_by(ReviewEvent.created_at)
)
events = events_result.scalars().all()
return events

View File

@ -13,11 +13,44 @@ router = APIRouter()
async def start_review_task(review_id: int, pr_number: int, repository_id: int):
"""Background task to start review"""
"""Background task to start review with streaming"""
from app.database import async_session_maker
from app.main import manager
from datetime import datetime as dt
async with async_session_maker() as db:
# Create event handler for streaming
async def on_review_event(event: dict):
"""Handle review events and broadcast to clients"""
try:
event_data = {
"type": event.get("type", "agent_update"),
"review_id": review_id,
"pr_number": pr_number,
"timestamp": dt.utcnow().isoformat(),
"data": event
}
# Save to DB (НЕ сохраняем llm_chunk - их слишком много)
if event.get("type") != "llm_chunk":
from app.models.review_event import ReviewEvent
db_event = ReviewEvent(
review_id=review_id,
event_type=event.get("type", "agent_update"),
step=event.get("step"),
message=event.get("message"),
data=event
)
db.add(db_event)
await db.commit()
# Broadcast (отправляем все события, включая llm_chunk)
await manager.broadcast(event_data)
except Exception as e:
print(f"Error in webhook review event handler: {e}")
agent = ReviewerAgent(db)
await agent.run_review(review_id, pr_number, repository_id)
await agent.run_review_stream(review_id, pr_number, repository_id, on_event=on_review_event)
@router.post("/gitea/{repository_id}")

View File

@ -29,11 +29,23 @@ class ConnectionManager:
async def broadcast(self, message: dict):
"""Broadcast message to all connected clients"""
for connection in self.active_connections:
print(f"\n[BROADCAST] Sending to {len(self.active_connections)} clients")
print(f"[BROADCAST] Message type: {message.get('type')}")
print(f"[BROADCAST] Message: {str(message)[:200]}...")
sent_count = 0
error_count = 0
for i, connection in enumerate(self.active_connections):
try:
await connection.send_json(message)
except Exception:
pass
sent_count += 1
print(f"[BROADCAST] ✓ Sent to client #{i+1}")
except Exception as e:
error_count += 1
print(f"[BROADCAST] ✗ Failed to send to client #{i+1}: {e}")
print(f"[BROADCAST] Result: {sent_count} sent, {error_count} failed")
# Create connection manager
@ -118,18 +130,66 @@ async def health_check():
return {"status": "healthy"}
@app.get("/api/version")
async def get_version():
"""Get backend version"""
try:
# Try multiple possible locations
version_file = Path(__file__).parent.parent / "VERSION"
if version_file.exists():
version = version_file.read_text().strip()
return {"version": version}
# Fallback: try root directory
root_version = Path(__file__).parent.parent.parent / "VERSION"
if root_version.exists():
version = root_version.read_text().strip()
return {"version": version}
return {"version": "0.1.0"}
except Exception as e:
print(f"Error reading version: {e}")
import traceback
traceback.print_exc()
return {"version": "0.1.0"}
@app.websocket("/ws/reviews")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint for real-time review updates"""
await manager.connect(websocket)
print(f"✅ WebSocket connected. Total connections: {len(manager.active_connections)}")
try:
# Send welcome message
await websocket.send_json({
"type": "connection",
"status": "connected",
"message": "WebSocket подключен к серверу review",
"timestamp": __import__('datetime').datetime.utcnow().isoformat()
})
while True:
# Keep connection alive
# Keep connection alive and handle client messages
data = await websocket.receive_text()
# Echo back or handle client messages if needed
await websocket.send_json({"type": "pong", "message": "connected"})
print(f"📨 Received from client: {data}")
# Handle ping/pong
if data == "ping":
await websocket.send_json({
"type": "pong",
"timestamp": __import__('datetime').datetime.utcnow().isoformat()
})
else:
# Echo back for debugging
await websocket.send_json({
"type": "echo",
"message": f"Получено: {data}"
})
except WebSocketDisconnect:
manager.disconnect(websocket)
print(f"❌ WebSocket disconnected. Remaining connections: {len(manager.active_connections)}")
async def broadcast_review_update(review_id: int, event_type: str, data: dict = None):

View File

@ -6,6 +6,7 @@ from app.models.review import Review
from app.models.comment import Comment
from app.models.organization import Organization
from app.models.review_task import ReviewTask
from app.models.review_event import ReviewEvent
__all__ = ["Repository", "PullRequest", "Review", "Comment", "Organization", "ReviewTask"]
__all__ = ["Repository", "PullRequest", "Review", "Comment", "Organization", "ReviewTask", "ReviewEvent"]

View File

@ -37,6 +37,7 @@ class Review(Base):
# Relationships
pull_request = relationship("PullRequest", back_populates="reviews")
comments = relationship("Comment", back_populates="review", cascade="all, delete-orphan")
events = relationship("ReviewEvent", back_populates="review", cascade="all, delete-orphan", order_by="ReviewEvent.created_at")
def __repr__(self):
return f"<Review(id={self.id}, status={self.status}, pr_id={self.pull_request_id})>"

View File

@ -0,0 +1,27 @@
"""Review Event model - хранение событий процесса review"""
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON
from sqlalchemy.orm import relationship
from datetime import datetime
from app.database import Base
class ReviewEvent(Base):
"""Событие процесса review"""
__tablename__ = "review_events"
id = Column(Integer, primary_key=True, index=True)
review_id = Column(Integer, ForeignKey("reviews.id", ondelete="CASCADE"), nullable=False, index=True)
event_type = Column(String(50), nullable=False) # agent_step, llm_message, review_started, etc.
step = Column(String(100), nullable=True) # fetch_pr_info, analyze_files, etc.
message = Column(Text, nullable=True)
data = Column(JSON, nullable=True) # Дополнительные данные события
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
# Relationships
review = relationship("Review", back_populates="events")
def __repr__(self):
return f"<ReviewEvent(id={self.id}, review_id={self.review_id}, type={self.event_type})>"

View File

@ -23,6 +23,10 @@ from app.schemas.streaming import (
ReviewProgressEvent,
StreamEventType
)
from app.schemas.review_event import (
ReviewEvent as ReviewEventSchema,
ReviewEventCreate
)
__all__ = [
"RepositoryCreate",
@ -40,5 +44,7 @@ __all__ = [
"LLMStreamEvent",
"ReviewProgressEvent",
"StreamEventType",
"ReviewEventSchema",
"ReviewEventCreate",
]

View File

@ -0,0 +1,29 @@
"""Review Event schemas"""
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional, Dict, Any
class ReviewEventBase(BaseModel):
"""Base review event schema"""
event_type: str = Field(..., description="Тип события")
step: Optional[str] = Field(None, description="Шаг процесса")
message: Optional[str] = Field(None, description="Сообщение")
data: Optional[Dict[str, Any]] = Field(None, description="Дополнительные данные")
class ReviewEventCreate(ReviewEventBase):
"""Schema for creating review event"""
review_id: int
class ReviewEvent(ReviewEventBase):
"""Review event response schema"""
id: int
review_id: int
created_at: datetime
class Config:
from_attributes = True

View File

@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import AsyncSessionLocal
from app.models import ReviewTask, PullRequest, Repository, Review
from app.models.review_task import TaskStatusEnum
from app.models.review import ReviewStatusEnum
from app.agents.reviewer import ReviewerAgent
from app.config import settings
@ -28,6 +29,9 @@ class ReviewTaskWorker:
self.running = True
logger.info("🚀 Task Worker запущен")
# Очищаем зависшие задачи при старте
await self._cleanup_stuck_tasks()
while self.running:
try:
await self._process_next_task()
@ -44,6 +48,61 @@ class ReviewTaskWorker:
self.running = False
logger.info("⏹️ Task Worker остановлен")
async def _cleanup_stuck_tasks(self):
"""Cleanup tasks that were IN_PROGRESS when server stopped"""
async with AsyncSessionLocal() as db:
try:
# Находим все задачи в статусе IN_PROGRESS
stuck_query = select(ReviewTask).where(
ReviewTask.status == TaskStatusEnum.IN_PROGRESS
)
result = await db.execute(stuck_query)
stuck_tasks = result.scalars().all()
if stuck_tasks:
logger.info(f"🔧 Найдено {len(stuck_tasks)} зависших задач, возвращаем в очередь...")
for task in stuck_tasks:
logger.info(f" ↩️ Задача #{task.id} (PR #{task.pull_request_id}) → PENDING")
task.status = TaskStatusEnum.PENDING
task.started_at = None
# Не увеличиваем retry_count, это не была ошибка
await db.commit()
logger.info(f"✅ Зависшие задачи очищены и возвращены в очередь")
else:
logger.info("✅ Зависших задач не найдено")
# Также очищаем зависшие reviews (которые были в процессе работы)
stuck_review_statuses = [
ReviewStatusEnum.FETCHING,
ReviewStatusEnum.ANALYZING,
ReviewStatusEnum.COMMENTING
]
stuck_reviews_query = select(Review).where(
Review.status.in_(stuck_review_statuses)
)
result = await db.execute(stuck_reviews_query)
stuck_reviews = result.scalars().all()
if stuck_reviews:
logger.info(f"🔧 Найдено {len(stuck_reviews)} зависших reviews, помечаем как failed...")
for review in stuck_reviews:
logger.info(f" ⚠️ Review #{review.id} (статус: {review.status}) → FAILED")
review.status = ReviewStatusEnum.FAILED
review.error_message = "Review прерван при перезапуске сервера"
from datetime import datetime
review.completed_at = datetime.utcnow()
await db.commit()
logger.info(f"✅ Зависшие reviews помечены как failed")
else:
logger.info("✅ Зависших reviews не найдено")
except Exception as e:
logger.error(f"❌ Ошибка при очистке зависших задач: {e}")
import traceback
traceback.print_exc()
async def _process_next_task(self):
"""Process next pending task"""
async with AsyncSessionLocal() as db:
@ -166,9 +225,54 @@ class ReviewTaskWorker:
from app.main import manager
from datetime import datetime as dt
# Send initial "review started" message
logger.info(f" 📢 Отправка начального сообщения о старте review...")
try:
# Save initial event to database
from app.models.review_event import ReviewEvent
initial_db_event = ReviewEvent(
review_id=review.id,
event_type="review_started",
message=f"Начало review для PR #{pull_request.pr_number}",
data={
"repository_id": repository.id,
"repository_name": f"{repository.repo_owner}/{repository.repo_name}"
}
)
db.add(initial_db_event)
await db.commit()
logger.info(f" 💾 Начальное событие сохранено в БД: {initial_db_event.id}")
# Broadcast initial message
initial_message = {
"type": "review_started",
"review_id": review.id,
"pr_number": pull_request.pr_number,
"timestamp": dt.utcnow().isoformat(),
"data": {
"message": f"Начало review для PR #{pull_request.pr_number}",
"repository_id": repository.id,
"repository_name": f"{repository.repo_owner}/{repository.repo_name}"
}
}
await manager.broadcast(initial_message)
logger.info(f" ✅ Начальное сообщение отправлено: {len(manager.active_connections)} подключений")
except Exception as e:
logger.error(f" ❌ Ошибка отправки начального сообщения: {e}")
import traceback
traceback.print_exc()
# Create event handler
async def on_review_event(event: dict):
"""Handle review events and broadcast to clients"""
print(f"\n{'*'*80}")
print(f"CALLBACK INVOKED!")
print(f" Event type: {event.get('type')}")
print(f" Event step: {event.get('step')}")
print(f" Event message: {event.get('message')}")
print(f" Active WS connections: {len(manager.active_connections)}")
print(f"{'*'*80}")
try:
# Prepare event data
event_data = {
@ -179,18 +283,44 @@ class ReviewTaskWorker:
"data": event
}
# Broadcast to all connected clients
print(f" Prepared event_data: {event_data}")
logger.info(f" 🔔 Broadcasting event: type={event.get('type')}, connections={len(manager.active_connections)}")
# Save event to database (НЕ сохраняем llm_chunk - их слишком много)
if event.get("type") != "llm_chunk":
from app.models.review_event import ReviewEvent
db_event = ReviewEvent(
review_id=review.id,
event_type=event.get("type", "agent_update"),
step=event.get("step"),
message=event.get("message"),
data=event
)
db.add(db_event)
await db.commit()
print(f" ✓ Event saved to DB: {db_event.id}")
logger.debug(f" 💾 Event saved to DB: {db_event.id}")
# Broadcast to all connected clients (отправляем все, включая llm_chunk)
print(f" Broadcasting to {len(manager.active_connections)} connections...")
await manager.broadcast(event_data)
print(f" ✓ Broadcast completed")
# Log the event
if event.get("type") == "agent_step":
step = event.get("step", "unknown")
logger.info(f" 📍 Step: {step}")
elif event.get("type") == "llm_chunk":
# Не логируем каждый chunk, слишком много
pass
elif event.get("type") == "llm_message":
message = event.get("message", "")[:100]
logger.debug(f" 💬 LLM: {message}...")
logger.info(f" 💬 LLM: {message}...")
except Exception as e:
print(f" ❌ ERROR in callback: {e}")
logger.error(f" ❌ Ошибка broadcast события: {e}")
import traceback
traceback.print_exc()
agent = ReviewerAgent(db)
await agent.run_review_stream(
@ -201,6 +331,37 @@ class ReviewTaskWorker:
)
logger.info(f" ✅ Review завершен для PR #{pull_request.pr_number}")
# Send completion message
try:
# Save completion event to database
from app.models.review_event import ReviewEvent
completion_db_event = ReviewEvent(
review_id=review.id,
event_type="review_completed",
message=f"Review завершен для PR #{pull_request.pr_number}",
data={}
)
db.add(completion_db_event)
await db.commit()
logger.info(f" 💾 Событие завершения сохранено в БД: {completion_db_event.id}")
# Broadcast completion message
completion_message = {
"type": "review_completed",
"review_id": review.id,
"pr_number": pull_request.pr_number,
"timestamp": dt.utcnow().isoformat(),
"data": {
"message": f"Review завершен для PR #{pull_request.pr_number}"
}
}
await manager.broadcast(completion_message)
logger.info(f" 📢 Сообщение о завершении отправлено: {len(manager.active_connections)} подключений")
except Exception as e:
logger.error(f" ❌ Ошибка отправки сообщения о завершении: {e}")
import traceback
traceback.print_exc()
# Global worker instance

View File

@ -4,7 +4,7 @@
import asyncio
from app.database import engine, Base
from app.models import Organization, ReviewTask, Repository, PullRequest, Review, Comment
from app.models import Organization, ReviewTask, Repository, PullRequest, Review, Comment, ReviewEvent
async def create_tables():
@ -20,6 +20,7 @@ async def create_tables():
print(" - pull_requests")
print(" - reviews")
print(" - comments")
print(" - review_events")
if __name__ == "__main__":

View File

@ -0,0 +1,17 @@
-- Migration: Add review_events table
CREATE TABLE IF NOT EXISTS review_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
review_id INTEGER NOT NULL,
event_type VARCHAR(50) NOT NULL,
step VARCHAR(100),
message TEXT,
data JSON,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (review_id) REFERENCES reviews(id) ON DELETE CASCADE
);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_review_events_review_id ON review_events(review_id);
CREATE INDEX IF NOT EXISTS idx_review_events_created_at ON review_events(created_at);

View File

@ -0,0 +1 @@

71
bump_version.sh Normal file
View File

@ -0,0 +1,71 @@
#!/bin/bash
# Скрипт для автоповышения версии backend
# Вызывается из pre-commit hook или вручную
VERSION_FILE="backend/VERSION"
# Проверка существования файла
if [ ! -f "$VERSION_FILE" ]; then
echo "0.1.0" > "$VERSION_FILE"
echo "✅ Создан файл версии: 0.1.0"
exit 0
fi
# Чтение текущей версии
CURRENT_VERSION=$(cat "$VERSION_FILE")
# Разбор версии (MAJOR.MINOR.PATCH)
IFS='.' read -ra VERSION_PARTS <<< "$CURRENT_VERSION"
MAJOR="${VERSION_PARTS[0]}"
MINOR="${VERSION_PARTS[1]}"
PATCH="${VERSION_PARTS[2]}"
# Проверка типа изменения по коммиту
if [ $# -eq 1 ]; then
VERSION_TYPE="$1"
else
# Автоопределение по последнему коммиту
LAST_COMMIT=$(git log -1 --pretty=%B 2>/dev/null || echo "")
if echo "$LAST_COMMIT" | grep -qiE "^(feat|feature):"; then
VERSION_TYPE="minor"
elif echo "$LAST_COMMIT" | grep -qiE "^(fix|bugfix):"; then
VERSION_TYPE="patch"
elif echo "$LAST_COMMIT" | grep -qiE "^(BREAKING|major):"; then
VERSION_TYPE="major"
else
VERSION_TYPE="patch"
fi
fi
# Повышение версии
case "$VERSION_TYPE" in
major)
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
;;
minor)
MINOR=$((MINOR + 1))
PATCH=0
;;
patch|*)
PATCH=$((PATCH + 1))
;;
esac
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
# Запись новой версии
echo "$NEW_VERSION" > "$VERSION_FILE"
echo "📦 Версия обновлена: $CURRENT_VERSION$NEW_VERSION"
# Добавление файла в git если мы в hook
if [ -n "$GIT_INDEX_FILE" ]; then
git add "$VERSION_FILE"
fi
exit 0

View File

@ -1,34 +0,0 @@
#!/bin/bash
echo "=========================================="
echo "AI Review Service - Status Check"
echo "=========================================="
echo ""
echo "1. Service Status:"
systemctl status ai-review.service --no-pager
echo ""
echo "=========================================="
echo "2. Last 100 lines of logs:"
echo "=========================================="
journalctl -u ai-review.service -n 100 --no-pager
echo ""
echo "=========================================="
echo "3. Checking files:"
echo "=========================================="
echo "Backend exists: $([ -d /home/user/code-review-agent/backend ] && echo 'YES' || echo 'NO')"
echo "Frontend exists: $([ -d /home/user/code-review-agent/frontend ] && echo 'YES' || echo 'NO')"
echo "Public dir exists: $([ -d /home/user/code-review-agent/backend/public ] && echo 'YES' || echo 'NO')"
echo "venv exists: $([ -d /home/user/code-review-agent/backend/venv ] && echo 'YES' || echo 'NO')"
echo ".env exists: $([ -f /home/user/code-review-agent/backend/.env ] && echo 'YES' || echo 'NO')"
echo "DB exists: $([ -f /home/user/code-review-agent/backend/review.db ] && echo 'YES' || echo 'NO')"
echo ""
echo "=========================================="
echo "4. Manual start test:"
echo "=========================================="
echo "Run this command to see actual error:"
echo "cd /home/user/code-review-agent/backend && source venv/bin/activate && python -m uvicorn app.main:app --host 0.0.0.0 --port 8000"

View File

@ -248,7 +248,7 @@ User=$REAL_USER
Group=$REAL_USER
WorkingDirectory=$INSTALL_DIR/backend
Environment="PATH=$INSTALL_DIR/backend/venv/bin:/usr/local/bin:/usr/bin:/bin"
ExecStart=$INSTALL_DIR/backend/venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 8000
ExecStart=$INSTALL_DIR/backend/venv/bin/python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000
Restart=always
RestartSec=10
StandardOutput=append:/var/log/ai-review/access.log

View File

@ -1,108 +0,0 @@
#!/bin/bash
echo "=========================================="
echo "AI Review - Диагностика и исправление"
echo "=========================================="
echo ""
# Определить директорию установки (где находится скрипт)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_DIR="$SCRIPT_DIR"
echo "Working directory: $INSTALL_DIR"
echo ""
echo "1. Проверка файлов:"
echo " - Backend: $([ -d $INSTALL_DIR/backend ] && echo '✓' || echo '✗')"
echo " - Frontend: $([ -d $INSTALL_DIR/frontend ] && echo '✓' || echo '✗')"
echo " - venv: $([ -d $INSTALL_DIR/backend/venv ] && echo '✓' || echo '✗ MISSING')"
echo " - venv/bin/python: $([ -f $INSTALL_DIR/backend/venv/bin/python ] && echo '✓' || echo '✗ MISSING')"
echo " - venv/bin/python3: $([ -f $INSTALL_DIR/backend/venv/bin/python3 ] && echo '✓' || echo '✗ MISSING')"
echo " - public: $([ -d $INSTALL_DIR/backend/public ] && echo '✓' || echo '✗ MISSING')"
echo " - DB: $([ -f $INSTALL_DIR/backend/review.db ] && echo '✓' || echo '⚠️ будет создана')"
echo ""
# Проверить что именно в venv
if [ -d "$INSTALL_DIR/backend/venv" ]; then
echo "2. Содержимое venv/bin/:"
ls -la "$INSTALL_DIR/backend/venv/bin/" | head -20
echo ""
fi
echo "=========================================="
echo "Исправление"
echo "=========================================="
echo ""
cd "$INSTALL_DIR/backend"
# Удалить старый venv если есть
if [ -d "venv" ]; then
echo "Удаление старого venv..."
rm -rf venv
fi
# Создать новый venv
echo "Создание нового venv..."
python3 -m venv venv
# Проверить создание
if [ ! -f "venv/bin/python" ] && [ ! -f "venv/bin/python3" ]; then
echo "✗ ОШИБКА: venv не создан правильно!"
echo ""
echo "Попробуйте:"
echo " sudo apt-get install python3-venv"
echo " python3 -m venv venv"
exit 1
fi
echo "✓ venv создан"
# Активировать и установить зависимости
echo "Установка зависимостей..."
source venv/bin/activate
pip install --upgrade pip > /dev/null
pip install -r requirements.txt
echo "✓ Зависимости установлены"
# Применить миграции
if [ -f "migrate.py" ]; then
echo "Применение миграций..."
python migrate.py
echo "✓ Миграции применены"
fi
echo ""
echo "=========================================="
echo "Проверка"
echo "=========================================="
echo ""
echo "Попытка запуска (5 секунд)..."
timeout 5 python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 2>&1 | head -20 &
UVICORN_PID=$!
sleep 6
if ps -p $UVICORN_PID > /dev/null 2>&1; then
echo "✓ Uvicorn запустился успешно"
kill $UVICORN_PID 2>/dev/null
else
echo "⚠️ Uvicorn остановился (это нормально для теста)"
fi
echo ""
echo "=========================================="
echo "Готово!"
echo "=========================================="
echo ""
echo "Теперь перезапустите сервис:"
echo " sudo systemctl restart ai-review"
echo " sudo systemctl status ai-review"
echo ""
echo "Или запустите вручную для теста:"
echo " cd $INSTALL_DIR/backend"
echo " source venv/bin/activate"
echo " python -m uvicorn app.main:app --host 0.0.0.0 --port 8000"

53
docs/README.md Normal file
View File

@ -0,0 +1,53 @@
# Документация AI Code Review Agent
Здесь собрана вся документация проекта.
## Быстрый старт
- [QUICKSTART.md](QUICKSTART.md) - Быстрый старт проекта
- [START_PROJECT.md](START_PROJECT.md) - Детальная инструкция по запуску
## Развертывание
- [DEPLOYMENT.md](DEPLOYMENT.md) - Общая информация о развертывании
- [UBUNTU_DEPLOYMENT.md](UBUNTU_DEPLOYMENT.md) - Развертывание на Ubuntu/Debian
- [REDEPLOY_GUIDE.md](REDEPLOY_GUIDE.md) - Руководство по обновлению
- [REDEPLOY_UBUNTU_QUICK.md](REDEPLOY_UBUNTU_QUICK.md) - Быстрое обновление на Ubuntu
- [cloud.md](cloud.md) - Развертывание в облаке
## Функционал
- [FEATURES_UPDATE.md](FEATURES_UPDATE.md) - Обновления функционала
- [REVIEW_FEATURES.md](REVIEW_FEATURES.md) - Возможности review
- [ORGANIZATION_FEATURE.md](ORGANIZATION_FEATURE.md) - Работа с организациями
- [ORGANIZATION_QUICKSTART.md](ORGANIZATION_QUICKSTART.md) - Быстрый старт с организациями
- [MASTER_TOKEN_FEATURE.md](MASTER_TOKEN_FEATURE.md) - Мастер токены
- [PR_CONTEXT_FEATURE.md](PR_CONTEXT_FEATURE.md) - Контекст Pull Request
- [HTML_ESCAPE_FIX.md](HTML_ESCAPE_FIX.md) - Исправление экранирования HTML
## Архитектура и разработка
- [ARCHITECTURE.md](ARCHITECTURE.md) - Архитектура проекта
- [PROJECT_STATUS.md](PROJECT_STATUS.md) - Статус проекта
- [CONTRIBUTING.md](CONTRIBUTING.md) - Как внести вклад
## Changelog
- [CHANGELOG.md](CHANGELOG.md) - История изменений
- [CHANGELOG_ORGANIZATIONS.md](CHANGELOG_ORGANIZATIONS.md) - История изменений организаций
## Команды и отладка
- [COMMANDS.md](COMMANDS.md) - Полезные команды
- [DEBUG_GUIDE.md](DEBUG_GUIDE.md) - Руководство по отладке
## Настройки и рекомендации
- [MODEL_RECOMMENDATION.md](MODEL_RECOMMENDATION.md) - Рекомендации по выбору модели
- [PRODUCTION_URLS.md](PRODUCTION_URLS.md) - Настройка production URL
- [SUMMARY.md](SUMMARY.md) - Краткое резюме проекта
## Дополнительно
- [TEST_STREAMING.md](TEST_STREAMING.md) - Тестирование WebSocket стриминга

View File

@ -0,0 +1,264 @@
# Персистентные события Review и система версионирования
## Что добавлено
### 1. Сохранение событий review в БД
#### Backend
- ✅ Модель `ReviewEvent` для хранения событий процесса review
- ✅ API endpoint `/api/reviews/{review_id}/events` для получения событий
- ✅ Автоматическое сохранение всех событий в БД при обработке review
- ✅ Связь с моделью Review через relationship
**Структура события:**
```python
class ReviewEvent:
id: int
review_id: int
event_type: str # agent_step, llm_message, review_started, etc.
step: Optional[str] # fetch_pr_info, analyze_files, etc.
message: Optional[str]
data: Optional[Dict]
created_at: datetime
```
#### Frontend
- ✅ Компонент `ReviewStream` загружает исторические события из БД
- ✅ События отображаются даже после завершения review
- ✅ При обновлении страницы события восстанавливаются
**Использование:**
```tsx
<ReviewStream reviewId={reviewId} />
```
### 2. Показ версии бэкенда
#### Backend
- ✅ Файл `backend/VERSION` с текущей версией (semver format)
- ✅ API endpoint `/api/version` для получения версии
#### Frontend
- ✅ Компонент `Footer` показывает версию внизу страницы
- ✅ Автоматическое обновление версии каждые 5 минут
- ✅ Кеширование версии для производительности
### 3. Автоповышение версии (Git Hook)
#### Скрипты
**`bump_version.sh`** - Скрипт повышения версии:
```bash
bash bump_version.sh patch # 0.1.0 → 0.1.1
bash bump_version.sh minor # 0.1.0 → 0.2.0
bash bump_version.sh major # 0.1.0 → 1.0.0
```
**`.git-hooks/pre-commit`** - Pre-commit hook:
- Автоматически вызывается при каждом коммите
- Анализирует изменения в `backend/` директории
- Повышает версию на основе типа коммита
#### Правила версионирования
Версия повышается автоматически на основе префикса коммита:
| Префикс коммита | Изменение версии | Пример |
|-----------------|------------------|---------|
| `feat:` или `feature:` | MINOR | 0.1.0 → 0.2.0 |
| `fix:` или `bugfix:` | PATCH | 0.1.0 → 0.1.1 |
| `BREAKING:` или `major:` | MAJOR | 0.1.0 → 1.0.0 |
| Остальные | PATCH | 0.1.0 → 0.1.1 |
**Примеры:**
```bash
git commit -m "feat: Add new feature" # 0.1.0 → 0.2.0
git commit -m "fix: Fix critical bug" # 0.1.0 → 0.1.1
git commit -m "BREAKING: Remove old API" # 0.1.0 → 1.0.0
git commit -m "docs: Update README" # 0.1.0 → 0.1.1
```
## Установка Git Hook
Чтобы активировать автоповышение версии:
```bash
# Из корня проекта
git config core.hooksPath .git-hooks
# Сделать hook исполняемым (Linux/Mac)
chmod +x .git-hooks/pre-commit
```
На Windows Git Bash автоматически обрабатывает права выполнения.
## Отключение hook
Если нужно закоммитить без повышения версии:
```bash
# Одноразово
git commit --no-verify -m "Your message"
# Полностью отключить
git config core.hooksPath .git/hooks
```
## Миграция БД
Для добавления таблицы `review_events`:
```bash
cd backend
python migrate.py
```
Или вручную:
```bash
sqlite3 backend/review.db < backend/migrations/add_review_events.sql
```
## API Endpoints
### Получить события review
```http
GET /api/reviews/{review_id}/events
```
**Ответ:**
```json
[
{
"id": 1,
"review_id": 1,
"event_type": "agent_step",
"step": "fetch_pr_info",
"message": "Получение информации о PR...",
"data": {},
"created_at": "2025-10-13T10:30:00"
}
]
```
### Получить версию бэкенда
```http
GET /api/version
```
**Ответ:**
```json
{
"version": "0.1.0"
}
```
## Структура проекта
```
├── backend/
│ ├── VERSION # Файл версии
│ ├── migrations/
│ │ └── add_review_events.sql # Миграция для таблицы событий
│ ├── app/
│ │ ├── models/
│ │ │ └── review_event.py # Модель события
│ │ ├── schemas/
│ │ │ └── review_event.py # Схемы события
│ │ └── api/
│ │ └── reviews.py # API endpoints
├── frontend/
│ ├── src/
│ │ ├── components/
│ │ │ ├── ReviewStream.tsx # Компонент стриминга
│ │ │ └── Footer.tsx # Футер с версией
│ │ └── api/
│ │ └── client.ts # API клиент
├── .git-hooks/
│ ├── pre-commit # Pre-commit hook
│ └── README.md # Документация hooks
├── bump_version.sh # Скрипт повышения версии
└── docs/
└── REVIEW_EVENTS_AND_VERSIONING.md # Эта документация
```
## Обновление на сервере
После деплоя:
1. Применить миграции:
```bash
cd ~/code-review-agent/backend
python migrate.py
```
2. Перезапустить сервис:
```bash
sudo systemctl restart ai-review
```
3. Проверить версию:
```bash
curl http://localhost:8000/api/version
```
## Проверка работы
### Backend
```bash
# Проверить что таблица создана
sqlite3 backend/review.db ".tables"
# Должна быть таблица review_events
# Проверить версию
cat backend/VERSION
# Должна быть версия в формате X.Y.Z
# Проверить API
curl http://localhost:8000/api/version
```
### Frontend
1. Откройте приложение в браузере
2. Внизу страницы должна быть видна версия: `Backend v0.1.0`
3. Откройте страницу review
4. Компонент "Процесс Review" должен показывать события даже после завершения
## Troubleshooting
### Git hook не работает
```bash
# Проверить настройку
git config core.hooksPath
# Переустановить
git config core.hooksPath .git-hooks
# На Windows может потребоваться Git Bash
```
### События не сохраняются
```bash
# Проверить что таблица существует
sqlite3 backend/review.db "SELECT * FROM review_events LIMIT 1;"
# Применить миграцию
python backend/migrate.py
```
### Версия не отображается на UI
1. Проверьте что файл `backend/VERSION` существует
2. Проверьте endpoint: `curl http://localhost:8000/api/version`
3. Проверьте консоль браузера на ошибки
4. Очистите кеш браузера (Ctrl+F5)

181
docs/TEST_STREAMING.md Normal file
View File

@ -0,0 +1,181 @@
# Тестирование LangGraph Streaming
Эти скрипты помогут проверить, как работает стриминг событий из LangGraph.
## Скрипты
### 1. `test_simple_graph.py` - Простой тест (БЕЗ БД)
**Рекомендуется запустить ПЕРВЫМ** для понимания как работает стриминг.
Тестирует простой граф с 3 нодами без реального review.
```bash
# Активировать venv
cd backend
source venv/Scripts/activate # Windows Git Bash
# или
source venv/bin/activate # Linux/Mac
# Запустить
python ../test_simple_graph.py
```
**Что тестирует:**
- ✅ `stream_mode=['updates']` - обновления нод
- ✅ `stream_mode=['messages']` - сообщения (LLM calls)
- ✅ `stream_mode=['updates', 'messages']` - оба режима
- ✅ `stream_mode=['values']` - значения состояния
- ✅ `stream_mode=['debug']` - режим отладки
- ✅ Callback обработка событий
**Ожидаемый результат:**
Должны появиться события для каждой ноды (node_1, node_2, node_3).
---
### 2. `test_langgraph_events.py` - Полный тест (С БД)
Тестирует реальный ReviewerAgent с настоящими данными из БД.
⚠️ **Требует:**
- Работающую БД с данными
- Существующий Review ID, PR Number, Repository ID
- Настроенный `.env` файл
```bash
# Активировать venv
cd backend
source venv/Scripts/activate # Windows
# или
source venv/bin/activate # Linux/Mac
# Запустить
python ../test_langgraph_events.py
```
**Перед запуском:**
Отредактируйте в файле `test_langgraph_events.py`:
```python
TEST_REVIEW_ID = 1 # ID существующего review
TEST_PR_NUMBER = 5 # Номер PR
TEST_REPOSITORY_ID = 1 # ID репозитория
```
**Что тестирует:**
- ✅ Полный цикл review с callback
- ✅ RAW стриминг напрямую из графа
- ✅ Все режимы: `updates`, `messages`, `updates + messages`
---
## Запуск локально (быстрый старт)
### Шаг 1: Простой тест
```bash
cd backend
source venv/Scripts/activate
python ../test_simple_graph.py
```
Смотрите вывод - должны быть события от каждой ноды.
### Шаг 2: Проверка формата событий
Обратите внимание на **тип** и **структуру** событий:
```
📨 Event #1
Type: <class 'dict'>
Keys: ['node_1']
Content: {'node_1': {...}}
```
или
```
📨 Event #1
Type: <class 'tuple'>
Tuple[0]: 'messages'
Tuple[1]: [AIMessage(...)]
```
### Шаг 3: Полный тест (если нужно)
Отредактируйте параметры в `test_langgraph_events.py` и запустите:
```bash
python ../test_langgraph_events.py
```
---
## Что искать в выводе
### ✅ ХОРОШО:
```
📨 Event #1
Type: <class 'dict'>
Content: {'node_1': {...}}
📨 Event #2
Type: <class 'dict'>
Content: {'node_2': {...}}
```
**События приходят!** Граф работает.
### ❌ ПЛОХО:
```
✅ Получено событий: 0
```
**События НЕ приходят!** Проблема с графом или версией LangGraph.
---
## Отладка
Если события не приходят:
1. **Проверьте версию LangGraph:**
```bash
pip show langgraph
```
Должна быть >= 0.1.0
2. **Проверьте, что граф компилируется:**
```python
graph = workflow.compile()
print(graph) # Должен вывести информацию о графе
```
3. **Попробуйте `ainvoke` вместо `astream`:**
```python
result = await graph.ainvoke(initial_state)
print(result) # Должен вернуть финальное состояние
```
4. **Проверьте логи:**
Включите DEBUG логирование:
```python
import logging
logging.basicConfig(level=logging.DEBUG)
```
---
## Результаты
После запуска тестов вы узнаете:
1. ✅ **Работает ли стриминг вообще?**
2. ✅ **Какой формат у событий?**
3. ✅ **Какие режимы стриминга поддерживаются?**
4. ✅ **Как правильно обрабатывать события?**
Это поможет понять, почему события не доходят до фронтенда.

View File

@ -1,92 +0,0 @@
#!/bin/bash
###############################################################################
# Скрипт быстрого исправления установки
###############################################################################
set -e
echo "=========================================="
echo "Fixing AI Review Installation"
echo "=========================================="
echo ""
# Определить директорию установки (где находится скрипт)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_DIR="$SCRIPT_DIR"
echo "Working directory: $INSTALL_DIR"
echo ""
cd "$INSTALL_DIR"
# 1. Создать Python virtual environment
echo "[1/5] Создание Python virtual environment..."
cd backend
python3 -m venv venv
source venv/bin/activate
echo "✓ venv создан"
echo ""
# 2. Установить Python зависимости
echo "[2/5] Установка Python зависимостей..."
pip install --upgrade pip > /dev/null
pip install -r requirements.txt > /dev/null
echo "✓ Python зависимости установлены"
echo ""
# 3. Создать базу данных
echo "[3/5] Создание базы данных..."
python migrate.py
echo "✓ База данных создана"
echo ""
# 4. Установить Node.js зависимости и собрать frontend
echo "[4/5] Сборка frontend..."
cd ../frontend
# Проверить наличие Node.js
if ! command -v node &> /dev/null; then
echo "ERROR: Node.js не установлен!"
echo "Установите: curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash - && sudo apt-get install -y nodejs"
exit 1
fi
# Создать .env.production
cat > .env.production << 'EOF'
VITE_API_URL=/api
VITE_WS_URL=
EOF
npm install > /dev/null 2>&1
npm run build
echo "✓ Frontend собран"
echo ""
# 5. Проверить результат
echo "[5/5] Проверка..."
cd ..
echo "Backend venv: $([ -d backend/venv ] && echo '✓ OK' || echo '✗ MISSING')"
echo "Backend DB: $([ -f backend/review.db ] && echo '✓ OK' || echo '✗ MISSING')"
echo "Frontend build: $([ -d backend/public ] && echo '✓ OK' || echo '✗ MISSING')"
echo ""
if [ -d backend/venv ] && [ -f backend/review.db ] && [ -d backend/public ]; then
echo "=========================================="
echo "✓ Installation fixed successfully!"
echo "=========================================="
echo ""
echo "Now run:"
echo " sudo systemctl restart ai-review"
echo " sudo systemctl status ai-review"
echo ""
echo "Or start manually:"
echo " cd $INSTALL_DIR/backend"
echo " source venv/bin/activate"
echo " uvicorn app.main:app --host 0.0.0.0 --port 8000"
else
echo "ERROR: Something is still missing!"
exit 1
fi

106
fix-service-python3.sh Normal file
View File

@ -0,0 +1,106 @@
#!/bin/bash
# Скрипт для исправления systemd service (python -> python3)
set -e
# Проверка sudo
if [ "$EUID" -ne 0 ]; then
echo "❌ Этот скрипт должен быть запущен с sudo"
echo "Используйте: sudo bash fix-service-python3.sh"
exit 1
fi
echo "🔧 Исправление ai-review.service..."
# Получаем реального пользователя
REAL_USER=${SUDO_USER:-$USER}
REAL_HOME=$(eval echo ~$REAL_USER)
# Определяем директорию установки
INSTALL_DIR=""
if [ -f "/etc/systemd/system/ai-review.service" ]; then
# Извлекаем путь из существующего service файла
INSTALL_DIR=$(grep "WorkingDirectory=" /etc/systemd/system/ai-review.service | cut -d'=' -f2 | sed 's|/backend||')
echo "📂 Найдена директория установки: $INSTALL_DIR"
else
echo "❌ Service файл не найден"
exit 1
fi
# Проверяем что директория существует
if [ ! -d "$INSTALL_DIR" ]; then
echo "❌ Директория $INSTALL_DIR не существует"
exit 1
fi
# Проверяем что venv существует
if [ ! -f "$INSTALL_DIR/backend/venv/bin/python3" ]; then
echo "❌ Python3 в venv не найден: $INSTALL_DIR/backend/venv/bin/python3"
echo ""
echo "Создайте venv заново:"
echo " cd $INSTALL_DIR/backend"
echo " python3 -m venv venv"
echo " source venv/bin/activate"
echo " pip install -r requirements.txt"
exit 1
fi
echo "✅ venv найден"
# Создаем новый service файл
echo "📝 Создание нового service файла..."
cat > /etc/systemd/system/ai-review.service << EOF
[Unit]
Description=AI Code Review Platform
After=network.target
[Service]
Type=simple
User=$REAL_USER
Group=$REAL_USER
WorkingDirectory=$INSTALL_DIR/backend
Environment="PATH=$INSTALL_DIR/backend/venv/bin:/usr/local/bin:/usr/bin:/bin"
ExecStart=$INSTALL_DIR/backend/venv/bin/python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000
Restart=always
RestartSec=10
StandardOutput=append:/var/log/ai-review/access.log
StandardError=append:/var/log/ai-review/error.log
[Install]
WantedBy=multi-user.target
EOF
echo "✅ Service файл создан"
# Перезагружаем systemd
echo "🔄 Перезагрузка systemd..."
systemctl daemon-reload
# Перезапускаем сервис
echo "🔄 Перезапуск ai-review.service..."
systemctl restart ai-review
# Ждем немного
sleep 3
# Проверяем статус
if systemctl is-active --quiet ai-review; then
echo ""
echo "✅ Сервис успешно запущен!"
echo ""
systemctl status ai-review --no-pager
else
echo ""
echo "❌ Ошибка запуска сервиса"
echo ""
echo "Логи:"
journalctl -u ai-review -n 30 --no-pager
fi
echo ""
echo "Для просмотра логов в реальном времени:"
echo " sudo journalctl -u ai-review -f"

View File

@ -1,91 +0,0 @@
#!/bin/bash
echo "=========================================="
echo "Creating simple systemd service"
echo "=========================================="
echo ""
INSTALL_DIR="$HOME/code-review-agent"
echo "Install directory: $INSTALL_DIR"
echo "User: $USER"
echo ""
# Создать простой systemd service БЕЗ жестких ограничений
sudo tee /etc/systemd/system/ai-review.service > /dev/null << EOF
[Unit]
Description=AI Code Review Platform
After=network.target
[Service]
Type=simple
User=$USER
WorkingDirectory=$INSTALL_DIR/backend
Environment="PATH=$INSTALL_DIR/backend/venv/bin:/usr/local/bin:/usr/bin:/bin"
ExecStart=$INSTALL_DIR/backend/venv/bin/python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000
Restart=always
RestartSec=10
StandardOutput=append:/var/log/ai-review/access.log
StandardError=append:/var/log/ai-review/error.log
[Install]
WantedBy=multi-user.target
EOF
echo "✓ Service файл создан"
echo ""
# Создать директорию логов
sudo mkdir -p /var/log/ai-review
sudo chown $USER:$USER /var/log/ai-review
echo "✓ Директория логов создана"
echo ""
# Перезагрузить systemd
echo "Перезагрузка systemd..."
sudo systemctl daemon-reload
sudo systemctl enable ai-review
echo "✓ Systemd обновлен"
echo ""
# Запустить
echo "Запуск сервиса..."
sudo systemctl restart ai-review
sleep 3
# Проверить статус
echo ""
echo "=========================================="
if sudo systemctl is-active --quiet ai-review; then
echo "✅ Сервис запущен успешно!"
echo "=========================================="
echo ""
sudo systemctl status ai-review --no-pager | head -20
echo ""
echo "Приложение доступно: http://localhost:8000"
echo ""
echo "Полезные команды:"
echo " sudo systemctl status ai-review"
echo " sudo journalctl -u ai-review -f"
echo " tail -f /var/log/ai-review/error.log"
else
echo "❌ Сервис не запустился"
echo "=========================================="
echo ""
echo "Статус:"
sudo systemctl status ai-review --no-pager
echo ""
echo "Последние 30 строк логов:"
sudo journalctl -u ai-review -n 30 --no-pager
echo ""
echo "Проверьте:"
echo " 1. tail -50 /var/log/ai-review/error.log"
echo " 2. Попробуйте запустить вручную:"
echo " cd $INSTALL_DIR/backend"
echo " source venv/bin/activate"
echo " python -m uvicorn app.main:app"
exit 1
fi
echo ""

View File

@ -7,6 +7,7 @@ import ReviewDetail from './pages/ReviewDetail';
import Organizations from './pages/Organizations';
import Tasks from './pages/Tasks';
import WebSocketStatus from './components/WebSocketStatus';
import Footer from './components/Footer';
const queryClient = new QueryClient({
defaultOptions: {
@ -65,7 +66,7 @@ function Navigation() {
function AppContent() {
return (
<div className="min-h-screen bg-dark-bg">
<div className="min-h-screen bg-dark-bg pb-12">
<Navigation />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
@ -78,6 +79,8 @@ function AppContent() {
<Route path="/reviews/:id" element={<ReviewDetail />} />
</Routes>
</main>
<Footer />
</div>
);
}

View File

@ -68,5 +68,25 @@ export const getReviewStats = async () => {
return response.data;
};
export interface ReviewEvent {
id: number;
review_id: number;
event_type: string;
step?: string;
message?: string;
data?: any;
created_at: string;
}
export const getReviewEvents = async (reviewId: number) => {
const response = await api.get<ReviewEvent[]>(`/reviews/${reviewId}/events`);
return response.data;
};
export const getBackendVersion = async () => {
const response = await api.get<{ version: string }>('/version');
return response.data;
};
export default api;

View File

@ -108,3 +108,19 @@ export class WebSocketClient {
// Create singleton instance
export const wsClient = new WebSocketClient();
// Export helper to get WS URL
export const getWebSocketUrl = (): string => {
// Если задан VITE_WS_URL, используем его
if (import.meta.env.VITE_WS_URL) {
return import.meta.env.VITE_WS_URL;
}
// Иначе определяем автоматически на основе текущего location
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const host = window.location.host;
return `${protocol}//${host}`;
};
// Export WS_URL for direct usage
export const WS_URL = `${getWebSocketUrl()}/ws/reviews`;

View File

@ -0,0 +1,39 @@
/**
* Footer component with version info
*/
import { useQuery } from '@tanstack/react-query';
import { getBackendVersion } from '../api/client';
export default function Footer() {
const { data: versionData } = useQuery({
queryKey: ['backendVersion'],
queryFn: getBackendVersion,
staleTime: 60000, // Cache for 1 minute
refetchInterval: 300000, // Refetch every 5 minutes
});
return (
<footer className="fixed bottom-0 left-0 right-0 bg-dark-card border-t border-dark-border py-2 px-4 z-10">
<div className="container mx-auto flex items-center justify-between text-xs text-dark-text-muted">
<div>
AI Code Review Agent
</div>
<div className="flex items-center gap-4">
<span>
Backend v{versionData?.version || '...'}
</span>
<a
href="https://github.com/yourusername/ai-review-agent"
target="_blank"
rel="noopener noreferrer"
className="hover:text-dark-text-secondary transition-colors"
>
GitHub
</a>
</div>
</div>
</footer>
);
}

View File

@ -1,5 +1,6 @@
import React, { useEffect, useState } from 'react';
import { WS_URL } from '../api/websocket';
import { getReviewEvents, ReviewEvent } from '../api/client';
interface StreamEvent {
type: string;
@ -31,48 +32,150 @@ export const ReviewStream: React.FC<ReviewStreamProps> = ({ reviewId }) => {
const [currentStep, setCurrentStep] = useState<string>('');
const [isConnected, setIsConnected] = useState(false);
const [llmMessages, setLlmMessages] = useState<string[]>([]);
const [llmStreamingText, setLlmStreamingText] = useState<string>('');
useEffect(() => {
console.log('🔌 Connecting to WebSocket:', WS_URL);
console.log('👀 Watching for review ID:', reviewId);
// Load historical events from database
const loadHistoricalEvents = async () => {
try {
console.log('📥 Loading historical events from DB...');
const historicalEvents = await getReviewEvents(reviewId);
console.log(`✅ Loaded ${historicalEvents.length} historical events`);
// Convert DB events to stream events format
const streamEvents: StreamEvent[] = historicalEvents.map((dbEvent: ReviewEvent) => ({
type: dbEvent.event_type,
review_id: dbEvent.review_id,
pr_number: 0, // Not stored in DB
timestamp: dbEvent.created_at,
data: {
type: dbEvent.event_type,
step: dbEvent.step,
message: dbEvent.message,
data: dbEvent.data
}
}));
setEvents(streamEvents);
// Set current step from last event
const lastAgentStep = streamEvents.reverse().find(e => e.type === 'agent_step');
if (lastAgentStep && lastAgentStep.data.step) {
setCurrentStep(lastAgentStep.data.step);
}
} catch (error) {
console.error('❌ Error loading historical events:', error);
}
};
loadHistoricalEvents();
const ws = new WebSocket(WS_URL);
let pingInterval: number;
ws.onopen = () => {
console.log('WebSocket connected for streaming');
console.log('WebSocket connected for streaming');
setIsConnected(true);
// Start ping interval (every 30 seconds)
pingInterval = window.setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
console.log('🏓 Sending ping...');
ws.send('ping');
}
}, 30000);
};
ws.onmessage = (event) => {
try {
const data: StreamEvent = JSON.parse(event.data);
console.log('📨 WebSocket message received:', event.data);
const data: any = JSON.parse(event.data);
console.log('📦 Parsed event:', data);
// Filter events for this review
if (data.review_id === reviewId) {
setEvents((prev) => [...prev, data]);
// Handle different message types
if (data.type === 'connection') {
console.log('🔗 Connection confirmed:', data.message);
return;
}
if (data.type === 'pong') {
console.log('🏓 Pong received');
return;
}
if (data.type === 'echo') {
console.log('📢 Echo:', data.message);
return;
}
// Review events
if (data.review_id !== undefined) {
console.log(`🔍 Event review_id: ${data.review_id}, Expected: ${reviewId}, Type: ${data.type}`);
// Filter events for this review OR show all for debugging
if (data.review_id === reviewId || true) { // Allow all for now for debugging
console.log(`✅ Processing event type: ${data.type}`);
setEvents((prev) => [...prev, data]);
// Update current step
if (data.data.type === 'agent_step') {
setCurrentStep(data.data.step || '');
}
// Update current step
if (data.type === 'agent_step' || data.data?.type === 'agent_step') {
const step = data.data?.step || data.step;
console.log('🚶 Agent step:', step);
setCurrentStep(step || '');
}
// Collect LLM messages
if (data.data.type === 'llm_message') {
setLlmMessages((prev) => [...prev, data.data.message || '']);
// Handle LLM streaming chunks
if (data.type === 'llm_chunk' || data.data?.type === 'llm_chunk') {
const chunk = data.data?.chunk || data.chunk || '';
setLlmStreamingText((prev) => prev + chunk);
}
// Collect LLM messages
if (data.type === 'llm_message' || data.data?.type === 'llm_message') {
const message = data.data?.message || data.message;
console.log('💬 LLM message:', message);
setLlmMessages((prev) => [...prev, message || '']);
}
// Handle special events
if (data.type === 'review_started') {
console.log('🎬 Review started:', data.data?.message);
// Сбрасываем streaming текст при начале нового review
setLlmStreamingText('');
}
if (data.type === 'review_completed') {
console.log('🎉 Review completed:', data.data?.message);
}
} else {
console.log('⏭️ Event is for different review, skipping');
}
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
console.error('Error parsing WebSocket message:', error, 'Data:', event.data);
}
};
ws.onclose = () => {
console.log('WebSocket disconnected');
console.log('🔌 WebSocket disconnected');
setIsConnected(false);
if (pingInterval) {
window.clearInterval(pingInterval);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
console.error('WebSocket error:', error);
};
return () => {
console.log('🔌 Closing WebSocket');
if (pingInterval) {
window.clearInterval(pingInterval);
}
ws.close();
};
}, [reviewId]);
@ -130,38 +233,53 @@ export const ReviewStream: React.FC<ReviewStreamProps> = ({ reviewId }) => {
if (events.length === 0) {
return (
<div className="text-center text-dark-text-muted py-4">
Ожидание событий...
<div className="animate-pulse"> Ожидание событий...</div>
<div className="text-xs mt-2">
{isConnected ? '✅ WebSocket подключен' : '❌ WebSocket отключен'}
</div>
</div>
);
}
return (
<div className="space-y-2 max-h-60 overflow-y-auto">
{events.map((event, index) => (
<div
key={index}
className="bg-dark-card border border-dark-border rounded-lg p-3 text-sm"
>
<div className="flex items-center justify-between mb-1">
<span className="text-dark-text-secondary text-xs">
{new Date(event.timestamp).toLocaleTimeString()}
</span>
<span className="text-xs bg-blue-900/30 text-blue-400 px-2 py-1 rounded">
{event.data.type}
</span>
{events.map((event: any, index) => {
const eventType = event.type || event.data?.type;
const eventMessage = event.data?.message || event.message;
const eventStep = event.data?.step || event.step;
return (
<div
key={index}
className="bg-dark-card border border-dark-border rounded-lg p-3 text-sm"
>
<div className="flex items-center justify-between mb-1">
<span className="text-dark-text-secondary text-xs">
{new Date(event.timestamp).toLocaleTimeString('ru-RU')}
</span>
<span className={`text-xs px-2 py-1 rounded ${
eventType === 'review_started' ? 'bg-green-900/30 text-green-400' :
eventType === 'review_completed' ? 'bg-blue-900/30 text-blue-400' :
eventType === 'agent_step' ? 'bg-purple-900/30 text-purple-400' :
eventType === 'llm_message' ? 'bg-yellow-900/30 text-yellow-400' :
'bg-gray-900/30 text-gray-400'
}`}>
{eventType}
</span>
</div>
{eventStep && (
<div className="text-dark-text-primary">
{getStepInfo(eventStep).icon} {getStepInfo(eventStep).name}
</div>
)}
{eventMessage && (
<div className="text-dark-text-muted mt-1 text-xs">
{eventMessage}
</div>
)}
</div>
{event.data.step && (
<div className="text-dark-text-primary">
{getStepInfo(event.data.step).icon} {getStepInfo(event.data.step).name}
</div>
)}
{event.data.message && (
<div className="text-dark-text-muted mt-1 text-xs">
{event.data.message}
</div>
)}
</div>
))}
);
})}
</div>
);
};
@ -210,6 +328,21 @@ export const ReviewStream: React.FC<ReviewStreamProps> = ({ reviewId }) => {
{renderStepProgress()}
{/* LLM Streaming текст */}
{llmStreamingText && (
<div className="mt-6">
<h3 className="text-lg font-semibold text-dark-text-primary mb-3 flex items-center gap-2">
<span className="animate-pulse">🤖</span> AI генерирует ответ...
</h3>
<div className="bg-dark-card border border-dark-border rounded-lg p-4">
<pre className="text-sm text-dark-text-secondary font-mono whitespace-pre-wrap break-words max-h-96 overflow-y-auto">
{llmStreamingText}
<span className="animate-pulse"></span>
</pre>
</div>
</div>
)}
<div className="mt-6">
<h3 className="text-lg font-semibold text-dark-text-primary mb-3">
📝 События

View File

@ -18,7 +18,7 @@ export default function WebSocketStatus() {
const statusColors = {
connected: 'bg-green-500',
disconnected: 'bg-gray-500',
disconnected: 'bg-dark-text-muted',
error: 'bg-red-500',
};
@ -29,9 +29,9 @@ export default function WebSocketStatus() {
};
return (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-gray-800">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-dark-card border border-dark-border">
<div className={`w-2 h-2 rounded-full ${statusColors[status]}`} />
<span className="text-sm text-gray-300">{statusLabels[status]}</span>
<span className="text-sm text-dark-text-secondary">{statusLabels[status]}</span>
</div>
);
}

View File

@ -140,23 +140,23 @@ export default function Organizations() {
{data?.items.map((org) => (
<div
key={org.id}
className="bg-white rounded-lg shadow-sm border border-gray-200 p-6"
className="bg-dark-card rounded-lg shadow-sm border border-dark-border p-6"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<h3 className="text-xl font-semibold text-gray-900">{org.name}</h3>
<span className={`px-2 py-1 rounded text-xs font-medium ${
org.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
<h3 className="text-xl font-semibold text-dark-text-primary">{org.name}</h3>
<span className={`px-2 py-1 rounded text-xs font-medium border ${
org.is_active ? 'bg-green-900/30 text-green-400 border-green-700' : 'bg-dark-card text-dark-text-muted border-dark-border'
}`}>
{org.is_active ? 'Активна' : 'Неактивна'}
</span>
<span className="px-2 py-1 rounded text-xs font-medium bg-blue-100 text-blue-800">
<span className="px-2 py-1 rounded text-xs font-medium bg-blue-900/30 text-blue-400 border border-blue-700">
{org.platform.toUpperCase()}
</span>
</div>
<div className="mt-2 space-y-1 text-sm text-gray-600">
<div className="mt-2 space-y-1 text-sm text-dark-text-secondary">
<div>🌐 {org.base_url}</div>
{org.last_scan_at && (
<div>
@ -166,8 +166,8 @@ export default function Organizations() {
)}
</div>
<div className="mt-3 text-xs text-gray-500">
Webhook: <code className="bg-gray-100 px-2 py-1 rounded">{org.webhook_url}</code>
<div className="mt-3 text-xs text-dark-text-muted">
Webhook: <code className="bg-dark-bg px-2 py-1 rounded border border-dark-border">{org.webhook_url}</code>
</div>
</div>
@ -181,13 +181,13 @@ export default function Organizations() {
</button>
<button
onClick={() => setEditingOrg(org)}
className="px-3 py-1.5 text-sm bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors"
className="px-3 py-1.5 text-sm bg-dark-hover text-dark-text-primary rounded hover:bg-dark-border transition-colors border border-dark-border"
>
Изменить
</button>
<button
onClick={() => handleDelete(org)}
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors"
className="px-3 py-1.5 text-sm bg-red-900/30 text-red-400 rounded hover:bg-red-900/50 transition-colors border border-red-700"
>
🗑 Удалить
</button>
@ -198,8 +198,8 @@ export default function Organizations() {
</div>
{data?.items.length === 0 && (
<div className="text-center py-12 bg-gray-50 rounded-lg">
<p className="text-gray-500">Нет организаций</p>
<div className="text-center py-12 bg-dark-card rounded-lg border border-dark-border">
<p className="text-dark-text-muted">Нет организаций</p>
<button
onClick={() => setIsFormOpen(true)}
className="mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
@ -277,15 +277,15 @@ function OrganizationForm({
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
<h2 className="text-2xl font-bold mb-4">
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
<div className="bg-dark-card rounded-lg shadow-xl p-6 max-w-md w-full mx-4 border border-dark-border">
<h2 className="text-2xl font-bold mb-4 text-dark-text-primary">
{organization ? 'Редактировать организацию' : 'Новая организация'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-dark-text-primary mb-1">
Название организации *
</label>
<input
@ -293,19 +293,19 @@ function OrganizationForm({
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
className="w-full px-3 py-2 border border-dark-border bg-dark-bg text-dark-text-primary rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="inno-js"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-dark-text-primary mb-1">
Платформа *
</label>
<select
value={formData.platform}
onChange={(e) => setFormData({ ...formData, platform: e.target.value as OrganizationPlatform })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
className="w-full px-3 py-2 border border-dark-border bg-dark-bg text-dark-text-primary rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="gitea">Gitea</option>
<option value="github">GitHub</option>
@ -314,7 +314,7 @@ function OrganizationForm({
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-dark-text-primary mb-1">
Base URL *
</label>
<input
@ -322,36 +322,36 @@ function OrganizationForm({
required
value={formData.base_url}
onChange={(e) => setFormData({ ...formData, base_url: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
className="w-full px-3 py-2 border border-dark-border bg-dark-bg text-dark-text-primary rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="https://git.example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-dark-text-primary mb-1">
API токен
</label>
<input
type="password"
value={formData.api_token}
onChange={(e) => setFormData({ ...formData, api_token: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
className="w-full px-3 py-2 border border-dark-border bg-dark-bg text-dark-text-primary rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Опционально (используется master токен если не указан)"
/>
<p className="text-xs text-gray-500 mt-1">
<p className="text-xs text-dark-text-muted mt-1">
💡 Если не указан, будет использован master токен из конфигурации сервера
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<label className="block text-sm font-medium text-dark-text-primary mb-1">
Webhook Secret
</label>
<input
type="text"
value={formData.webhook_secret}
onChange={(e) => setFormData({ ...formData, webhook_secret: e.target.value })}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
className="w-full px-3 py-2 border border-dark-border bg-dark-bg text-dark-text-primary rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="Опционально (генерируется автоматически)"
/>
</div>
@ -360,14 +360,14 @@ function OrganizationForm({
<button
type="submit"
disabled={isSubmitting}
className="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50"
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
>
{isSubmitting ? 'Сохранение...' : organization ? 'Сохранить' : 'Создать'}
</button>
<button
type="button"
onClick={onCancel}
className="flex-1 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
className="flex-1 px-4 py-2 bg-dark-hover text-dark-text-primary border border-dark-border rounded-lg hover:bg-dark-border transition-colors"
>
Отмена
</button>

View File

@ -74,15 +74,15 @@ export default function Tasks() {
const getStatusColor = (status: TaskStatus) => {
switch (status) {
case 'pending':
return 'bg-yellow-100 text-yellow-800';
return 'bg-yellow-900/30 text-yellow-400 border border-yellow-700';
case 'in_progress':
return 'bg-blue-100 text-blue-800';
return 'bg-blue-900/30 text-blue-400 border border-blue-700';
case 'completed':
return 'bg-green-100 text-green-800';
return 'bg-green-900/30 text-green-400 border border-green-700';
case 'failed':
return 'bg-red-100 text-red-800';
return 'bg-red-900/30 text-red-400 border border-red-700';
default:
return 'bg-gray-100 text-gray-800';
return 'bg-dark-card text-dark-text-muted border border-dark-border';
}
};
@ -104,20 +104,20 @@ export default function Tasks() {
const getPriorityColor = (priority: string) => {
switch (priority) {
case 'high':
return 'bg-red-100 text-red-800';
return 'bg-red-900/30 text-red-400 border border-red-700';
case 'normal':
return 'bg-gray-100 text-gray-800';
return 'bg-dark-card text-dark-text-secondary border border-dark-border';
case 'low':
return 'bg-green-100 text-green-800';
return 'bg-green-900/30 text-green-400 border border-green-700';
default:
return 'bg-gray-100 text-gray-800';
return 'bg-dark-card text-dark-text-muted border border-dark-border';
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">Загрузка...</div>
<div className="text-dark-text-muted">Загрузка...</div>
</div>
);
}
@ -126,23 +126,23 @@ export default function Tasks() {
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-gray-900">Очередь задач</h1>
<p className="text-gray-600 mt-1">
<h1 className="text-3xl font-bold text-dark-text-primary">Очередь задач</h1>
<p className="text-dark-text-secondary mt-1">
Мониторинг и управление задачами на review
</p>
</div>
{/* Worker Status */}
{workerStatus && (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="bg-dark-card rounded-lg shadow-sm border border-dark-border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${workerStatus.running ? 'bg-green-500 animate-pulse' : 'bg-gray-400'}`} />
<span className="font-medium">
<div className={`w-3 h-3 rounded-full ${workerStatus.running ? 'bg-green-500 animate-pulse' : 'bg-dark-text-muted'}`} />
<span className="font-medium text-dark-text-primary">
{workerStatus.running ? '🚀 Worker активен' : '⏹️ Worker остановлен'}
</span>
</div>
<div className="text-sm text-gray-600">
<div className="text-sm text-dark-text-secondary">
{workerStatus.current_task_id && (
<span>Обрабатывается задача #{workerStatus.current_task_id}</span>
)}
@ -158,49 +158,49 @@ export default function Tasks() {
{tasksData && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div
className={`bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow ${
statusFilter === undefined ? 'ring-2 ring-indigo-500' : ''
className={`bg-dark-card rounded-lg shadow-sm border border-dark-border p-4 cursor-pointer hover:bg-dark-hover transition-all ${
statusFilter === undefined ? 'ring-2 ring-blue-500' : ''
}`}
onClick={() => setStatusFilter(undefined)}
>
<div className="text-2xl font-bold text-gray-900">{tasksData.total}</div>
<div className="text-sm text-gray-600">Всего</div>
<div className="text-2xl font-bold text-dark-text-primary">{tasksData.total}</div>
<div className="text-sm text-dark-text-secondary">Всего</div>
</div>
<div
className={`bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow ${
className={`bg-dark-card rounded-lg shadow-sm border border-dark-border p-4 cursor-pointer hover:bg-dark-hover transition-all ${
statusFilter === 'pending' ? 'ring-2 ring-yellow-500' : ''
}`}
onClick={() => setStatusFilter('pending')}
>
<div className="text-2xl font-bold text-yellow-600">{tasksData.pending}</div>
<div className="text-sm text-gray-600">Ожидает</div>
<div className="text-2xl font-bold text-yellow-400">{tasksData.pending}</div>
<div className="text-sm text-dark-text-secondary">Ожидает</div>
</div>
<div
className={`bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow ${
className={`bg-dark-card rounded-lg shadow-sm border border-dark-border p-4 cursor-pointer hover:bg-dark-hover transition-all ${
statusFilter === 'in_progress' ? 'ring-2 ring-blue-500' : ''
}`}
onClick={() => setStatusFilter('in_progress')}
>
<div className="text-2xl font-bold text-blue-600">{tasksData.in_progress}</div>
<div className="text-sm text-gray-600">Выполняется</div>
<div className="text-2xl font-bold text-blue-400">{tasksData.in_progress}</div>
<div className="text-sm text-dark-text-secondary">Выполняется</div>
</div>
<div
className={`bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow ${
className={`bg-dark-card rounded-lg shadow-sm border border-dark-border p-4 cursor-pointer hover:bg-dark-hover transition-all ${
statusFilter === 'completed' ? 'ring-2 ring-green-500' : ''
}`}
onClick={() => setStatusFilter('completed')}
>
<div className="text-2xl font-bold text-green-600">{tasksData.completed}</div>
<div className="text-sm text-gray-600">Завершено</div>
<div className="text-2xl font-bold text-green-400">{tasksData.completed}</div>
<div className="text-sm text-dark-text-secondary">Завершено</div>
</div>
<div
className={`bg-white rounded-lg shadow-sm border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow ${
className={`bg-dark-card rounded-lg shadow-sm border border-dark-border p-4 cursor-pointer hover:bg-dark-hover transition-all ${
statusFilter === 'failed' ? 'ring-2 ring-red-500' : ''
}`}
onClick={() => setStatusFilter('failed')}
>
<div className="text-2xl font-bold text-red-600">{tasksData.failed}</div>
<div className="text-sm text-gray-600">Ошибок</div>
<div className="text-2xl font-bold text-red-400">{tasksData.failed}</div>
<div className="text-sm text-dark-text-secondary">Ошибок</div>
</div>
</div>
)}
@ -210,12 +210,12 @@ export default function Tasks() {
{tasksData?.items.map((task) => (
<div
key={task.id}
className="bg-white rounded-lg shadow-sm border border-gray-200 p-4"
className="bg-dark-card rounded-lg shadow-sm border border-dark-border p-4"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<span className="text-lg font-mono font-semibold text-gray-900">
<span className="text-lg font-mono font-semibold text-dark-text-primary">
#{task.id}
</span>
<span className={`px-2 py-1 rounded text-xs font-medium ${getStatusColor(task.status)}`}>
@ -230,12 +230,12 @@ export default function Tasks() {
<div className="mt-2 space-y-1">
<div className="text-sm">
<span className="text-gray-600">PR:</span>{' '}
<span className="font-medium">#{task.pr_number}</span>{' '}
<span className="text-gray-700">{task.pr_title}</span>
<span className="text-dark-text-secondary">PR:</span>{' '}
<span className="font-medium text-dark-text-primary">#{task.pr_number}</span>{' '}
<span className="text-dark-text-primary">{task.pr_title}</span>
</div>
<div className="flex gap-4 text-xs text-gray-500">
<div className="flex gap-4 text-xs text-dark-text-muted">
<span>
Создано: {formatDistanceToNow(new Date(task.created_at), { addSuffix: true, locale: ru })}
</span>
@ -252,13 +252,13 @@ export default function Tasks() {
</div>
{task.error_message && (
<div className="mt-2 p-2 bg-red-50 border border-red-200 rounded text-xs text-red-700">
<div className="mt-2 p-2 bg-red-900/30 border border-red-700 rounded text-xs text-red-400">
<strong>Ошибка:</strong> {task.error_message}
</div>
)}
{task.retry_count > 0 && (
<div className="text-xs text-gray-500">
<div className="text-xs text-dark-text-muted">
Попыток: {task.retry_count} / {task.max_retries}
</div>
)}
@ -270,7 +270,7 @@ export default function Tasks() {
<button
onClick={() => handleRetry(task.id)}
disabled={retryMutation.isPending}
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded hover:bg-blue-200 transition-colors disabled:opacity-50"
className="px-3 py-1.5 text-sm bg-blue-900/30 text-blue-400 rounded hover:bg-blue-900/50 transition-colors disabled:opacity-50 border border-blue-700"
>
🔄 Повторить
</button>
@ -279,7 +279,7 @@ export default function Tasks() {
<button
onClick={() => handleDelete(task.id)}
disabled={deleteMutation.isPending}
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200 transition-colors disabled:opacity-50"
className="px-3 py-1.5 text-sm bg-red-900/30 text-red-400 rounded hover:bg-red-900/50 transition-colors disabled:opacity-50 border border-red-700"
>
🗑 Удалить
</button>
@ -291,8 +291,8 @@ export default function Tasks() {
</div>
{tasksData?.items.length === 0 && (
<div className="text-center py-12 bg-gray-50 rounded-lg">
<p className="text-gray-500">
<div className="text-center py-12 bg-dark-card rounded-lg border border-dark-border">
<p className="text-dark-text-muted">
{statusFilter ? `Нет задач со статусом "${statusFilter}"` : 'Нет задач в очереди'}
</p>
</div>
@ -305,7 +305,7 @@ export default function Tasks() {
title={modalMessage.includes('❌') ? 'Ошибка' : modalMessage.includes('✅') ? 'Успешно' : 'Уведомление'}
type={modalMessage.includes('❌') ? 'error' : modalMessage.includes('✅') ? 'success' : 'info'}
>
<p className="text-gray-700 whitespace-pre-line">{modalMessage}</p>
<p className="text-dark-text-primary whitespace-pre-line">{modalMessage}</p>
</Modal>
<ConfirmModal
isOpen={showConfirm}

146
start.bat
View File

@ -1,100 +1,78 @@
@echo off
REM Единый скрипт запуска AI Code Review Platform
REM AI Review - Build & Start
echo.
echo ========================================
echo AI Code Review Platform - Запуск
echo ========================================
echo ================================
echo AI Review - Starting
echo ================================
echo.
REM 1. Проверка Node.js
echo [STEP 1/7] Проверка Node.js...
where node >nul 2>nul
if %ERRORLEVEL% NEQ 0 (
echo [ERROR] Node.js не установлен! Установите Node.js 18+ и попробуйте снова.
pause
exit /b 1
)
node --version
echo [OK] Node.js установлен
echo.
REM 2. Проверка Python
echo [STEP 2/7] Проверка Python...
where python >nul 2>nul
if %ERRORLEVEL% NEQ 0 (
echo [ERROR] Python не установлен! Установите Python 3.10+ и попробуйте снова.
pause
exit /b 1
)
python --version
echo [OK] Python установлен
echo.
REM 3. Установка зависимостей frontend
echo [STEP 3/7] Установка зависимостей frontend...
REM 1. Build Frontend
echo [1/3] Building frontend...
cd frontend
if not exist "node_modules\" (
echo Установка npm пакетов...
echo Installing npm packages...
call npm install
) else (
echo node_modules уже существует, пропускаем...
)
echo [OK] Зависимости frontend установлены
echo.
REM 4. Сборка frontend
echo [STEP 4/7] Сборка frontend...
REM Создаем .env.production для production
echo VITE_API_URL=/api > .env.production
echo VITE_WS_URL= >> .env.production
call npm run build
echo [OK] Frontend собран в backend/public
echo.
REM 5. Установка зависимостей backend
cd ..\backend
echo [STEP 5/7] Установка зависимостей backend...
if not exist "venv\" (
echo Создание виртуального окружения...
python -m venv venv
)
REM Активация venv
call venv\Scripts\activate.bat
REM Установка зависимостей
pip install -r requirements.txt
echo [OK] Зависимости backend установлены
echo.
REM 6. Проверка .env
echo [STEP 6/7] Проверка конфигурации...
if not exist ".env" (
echo [WARNING] Файл .env не найден!
if exist ".env.example" (
echo Создаем .env из примера...
copy .env.example .env
echo [OK] Создан .env файл
echo [WARNING] ВАЖНО: Отредактируйте .env и добавьте необходимые токены!
) else (
echo [ERROR] .env.example не найден!
if %ERRORLEVEL% NEQ 0 (
echo [ERROR] npm install failed
cd ..
pause
exit /b 1
)
)
echo.
REM 7. Запуск backend
echo [STEP 7/7] Запуск сервера...
echo ========================================
echo Building...
call npm run build
if %ERRORLEVEL% NEQ 0 (
echo [ERROR] Frontend build failed
cd ..
pause
exit /b 1
)
echo [OK] Frontend built to backend\public
cd ..
REM 2. Setup Backend
echo.
echo Backend: http://localhost:8000
echo Frontend: http://localhost:8000
echo [2/3] Setting up backend...
cd backend
if not exist "venv\" (
echo Creating venv...
python -m venv venv
if %ERRORLEVEL% NEQ 0 (
echo [ERROR] Failed to create venv
cd ..
pause
exit /b 1
)
)
echo Activating venv...
call venv\Scripts\activate.bat
echo Installing dependencies...
pip install -q -r requirements.txt
if %ERRORLEVEL% NEQ 0 (
echo [ERROR] Failed to install dependencies
cd ..
pause
exit /b 1
)
REM 3. Start Backend
echo.
echo [3/3] Starting server...
echo ================================
echo.
echo URL: http://localhost:8000
echo API Docs: http://localhost:8000/docs
echo.
echo Для остановки нажмите Ctrl+C
echo Press Ctrl+C to stop
echo ================================
echo.
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

110
start.sh
View File

@ -1,104 +1,54 @@
#!/bin/bash
# Единый скрипт запуска AI Code Review Platform
# AI Review - Build & Start
set -e
echo "🚀 AI Code Review Platform - Запуск"
echo "===================================="
echo "================================"
echo "AI Review - Starting"
echo "================================"
echo ""
# Цвета для вывода
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# 1. Проверка Node.js
echo -e "${YELLOW}📦 Проверка Node.js...${NC}"
if ! command -v node &> /dev/null; then
echo "❌ Node.js не установлен! Установите Node.js 18+ и попробуйте снова."
exit 1
fi
echo -e "${GREEN}✅ Node.js $(node --version)${NC}"
echo ""
# 2. Проверка Python
echo -e "${YELLOW}🐍 Проверка Python...${NC}"
if ! command -v python &> /dev/null && ! command -v python3 &> /dev/null; then
echo "❌ Python не установлен! Установите Python 3.10+ и попробуйте снова."
exit 1
fi
PYTHON_CMD="python3"
if ! command -v python3 &> /dev/null; then
PYTHON_CMD="python"
fi
echo -e "${GREEN}✅ Python $($PYTHON_CMD --version)${NC}"
echo ""
# 3. Установка зависимостей frontend
echo -e "${YELLOW}📦 Установка зависимостей frontend...${NC}"
# 1. Build Frontend
echo "[1/3] Building frontend..."
cd frontend
if [ ! -d "node_modules" ]; then
echo "Installing npm packages..."
npm install
else
echo "node_modules уже существует, пропускаем..."
fi
echo -e "${GREEN}✅ Зависимости frontend установлены${NC}"
echo ""
# 4. Сборка frontend
echo -e "${YELLOW}🔨 Сборка frontend...${NC}"
# Создаем .env.production для production
cat > .env.production << 'EOF'
VITE_API_URL=/api
VITE_WS_URL=
EOF
echo "Building..."
npm run build
echo -e "${GREEN}✅ Frontend собран в backend/public${NC}"
echo ""
# 5. Установка зависимостей backend
cd ../backend
echo -e "${YELLOW}📦 Установка зависимостей backend...${NC}"
echo "[OK] Frontend built to backend/public"
cd ..
# 2. Setup Backend
echo ""
echo "[2/3] Setting up backend..."
cd backend
if [ ! -d "venv" ]; then
echo "Создание виртуального окружения..."
$PYTHON_CMD -m venv venv
echo "Creating venv..."
python3 -m venv venv
fi
# Активация venv
echo "Activating venv..."
source venv/bin/activate
# Установка зависимостей
pip install -r requirements.txt
echo -e "${GREEN}✅ Зависимости backend установлены${NC}"
echo ""
echo "Installing dependencies..."
pip install -q -r requirements.txt
# 6. Проверка .env
if [ ! -f ".env" ]; then
echo -e "${YELLOW}⚠️ Файл .env не найден!${NC}"
echo "Создаем .env из примера..."
if [ -f ".env.example" ]; then
cp .env.example .env
echo -e "${GREEN}✅ Создан .env файл${NC}"
echo -e "${YELLOW}⚠️ ВАЖНО: Отредактируйте .env и добавьте необходимые токены!${NC}"
else
echo "❌ .env.example не найден!"
fi
echo ""
fi
# 7. Запуск backend
echo -e "${GREEN}🎉 Запуск сервера...${NC}"
echo "===================================="
# 3. Start Backend
echo ""
echo "📍 Backend: http://localhost:8000"
echo "📍 Frontend: http://localhost:8000"
echo "📍 API Docs: http://localhost:8000/docs"
echo "[3/3] Starting server..."
echo "================================"
echo ""
echo "Для остановки нажмите Ctrl+C"
echo "URL: http://localhost:8000"
echo "API Docs: http://localhost:8000/docs"
echo ""
echo "Press Ctrl+C to stop"
echo "================================"
echo ""
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

72
tests/README.md Normal file
View File

@ -0,0 +1,72 @@
# Тесты
Эта папка содержит тестовые скрипты для проверки различных компонентов системы.
## Тесты стриминга
### test_simple_graph.py
Простой тест стриминга LangGraph без реальных данных и БД.
**Запуск:**
```bash
cd backend
$env:PYTHONIOENCODING="utf-8"; ./venv/Scripts/python ../tests/test_simple_graph.py # Windows PowerShell
# или
python ../tests/test_simple_graph.py # Linux/Mac
```
**Что тестирует:**
- Различные режимы стриминга (`updates`, `messages`, `values`, `debug`)
- Обработку событий через callback
- Формат событий от LangGraph
### test_langgraph_events.py
Полный тест с реальным ReviewerAgent и БД.
**Требования:**
- Работающая БД с данными
- Существующий Review ID, PR Number, Repository ID
- Настроенный `.env` файл
**Запуск:**
1. Отредактируйте параметры в файле:
```python
TEST_REVIEW_ID = 1
TEST_PR_NUMBER = 5
TEST_REPOSITORY_ID = 1
```
2. Запустите:
```bash
cd backend
python ../tests/test_langgraph_events.py
```
### test_llm_streaming.py
Тест стриминга LLM messages с реальным Ollama.
**Требования:**
- Ollama запущен (`ollama serve`)
- Модель загружена (`ollama pull qwen2.5-coder:3b`)
**Запуск:**
```bash
cd backend
$env:PYTHONIOENCODING="utf-8"; ./venv/Scripts/python ../tests/test_llm_streaming.py # Windows
python ../tests/test_llm_streaming.py # Linux/Mac
```
## Добавление новых тестов
Добавляйте новые тесты в эту папку с префиксом `test_`.
## Полезные ссылки
- [TEST_STREAMING.md](../docs/TEST_STREAMING.md) - Детальная документация по тестированию стриминга

View File

@ -0,0 +1,229 @@
"""
Тестовый скрипт для проверки событий LangGraph
Запустить: python test_langgraph_events.py
"""
import asyncio
from backend.app.database import AsyncSessionLocal
from backend.app.agents.reviewer import ReviewerAgent
async def test_streaming():
"""Тест стриминга событий от LangGraph"""
print("="*80)
print("ТЕСТ СТРИМИНГА СОБЫТИЙ LANGGRAPH")
print("="*80)
# Параметры для теста (замените на реальные значения из вашей БД)
TEST_REVIEW_ID = 1
TEST_PR_NUMBER = 5
TEST_REPOSITORY_ID = 1
print(f"\n📋 Параметры теста:")
print(f" Review ID: {TEST_REVIEW_ID}")
print(f" PR Number: {TEST_PR_NUMBER}")
print(f" Repository ID: {TEST_REPOSITORY_ID}")
# Создаем сессию БД
async with AsyncSessionLocal() as db:
print(f"\n✅ Подключение к БД установлено")
# Создаем агента
agent = ReviewerAgent(db)
print(f"✅ ReviewerAgent создан")
# Счетчик событий
event_counter = {
'total': 0,
'updates': 0,
'messages': 0,
'other': 0
}
# Callback для событий
async def on_event(event: dict):
event_counter['total'] += 1
event_type = event.get('type', 'unknown')
print(f"\n{'='*80}")
print(f"📨 СОБЫТИЕ #{event_counter['total']} - Тип: {event_type}")
print(f"{'='*80}")
print(f"Полное событие: {event}")
print(f"{'='*80}\n")
print(f"\n🚀 Запуск review с стримингом...\n")
try:
# Запускаем review
result = await agent.run_review_stream(
review_id=TEST_REVIEW_ID,
pr_number=TEST_PR_NUMBER,
repository_id=TEST_REPOSITORY_ID,
on_event=on_event
)
print(f"\n{'='*80}")
print(f"✅ REVIEW ЗАВЕРШЕН")
print(f"{'='*80}")
print(f"\n📊 Статистика событий:")
print(f" Всего событий: {event_counter['total']}")
print(f" Updates: {event_counter['updates']}")
print(f" Messages: {event_counter['messages']}")
print(f" Other: {event_counter['other']}")
print(f"\n📝 Финальное состояние:")
print(f" Status: {result.get('status')}")
print(f" Files: {len(result.get('files', []))}")
print(f" Comments: {len(result.get('comments', []))}")
print(f" Error: {result.get('error')}")
except Exception as e:
print(f"\n❌ ОШИБКА при выполнении review:")
print(f" {e}")
import traceback
traceback.print_exc()
async def test_raw_graph_streaming():
"""Тест RAW стриминга напрямую из графа"""
print("\n" + "="*80)
print("ТЕСТ RAW СТРИМИНГА НАПРЯМУЮ ИЗ ГРАФА")
print("="*80)
# Параметры для теста
TEST_REVIEW_ID = 1
TEST_PR_NUMBER = 5
TEST_REPOSITORY_ID = 1
async with AsyncSessionLocal() as db:
agent = ReviewerAgent(db)
initial_state = {
"review_id": TEST_REVIEW_ID,
"pr_number": TEST_PR_NUMBER,
"repository_id": TEST_REPOSITORY_ID,
"status": "pending",
"files": [],
"analyzed_files": [],
"comments": [],
"error": None,
"git_service": None
}
print(f"\n🔍 Тест 1: stream_mode=['updates']")
print("-" * 80)
event_count = 0
try:
async for event in agent.graph.astream(initial_state, stream_mode=["updates"]):
event_count += 1
print(f"\n📨 Event #{event_count}")
print(f" Type: {type(event)}")
print(f" Content: {event}")
if event_count > 10: # Ограничение для безопасности
print("\n⚠️ Остановка после 10 событий")
break
except Exception as e:
print(f"\n❌ Ошибка: {e}")
import traceback
traceback.print_exc()
print(f"\nВсего событий 'updates': {event_count}")
# Тест 2: messages
print(f"\n\n🔍 Тест 2: stream_mode=['messages']")
print("-" * 80)
event_count = 0
try:
# Создаем новый агент для чистого теста
agent2 = ReviewerAgent(db)
async for event in agent2.graph.astream(initial_state, stream_mode=["messages"]):
event_count += 1
print(f"\n📨 Event #{event_count}")
print(f" Type: {type(event)}")
print(f" Content: {event}")
if event_count > 10:
print("\n⚠️ Остановка после 10 событий")
break
except Exception as e:
print(f"\n❌ Ошибка: {e}")
import traceback
traceback.print_exc()
print(f"\nВсего событий 'messages': {event_count}")
# Тест 3: updates + messages
print(f"\n\n🔍 Тест 3: stream_mode=['updates', 'messages']")
print("-" * 80)
event_count = 0
try:
agent3 = ReviewerAgent(db)
async for event in agent3.graph.astream(initial_state, stream_mode=["updates", "messages"]):
event_count += 1
print(f"\n📨 Event #{event_count}")
print(f" Type: {type(event)}")
# Детальный разбор события
if isinstance(event, dict):
print(f" Dict keys: {list(event.keys())}")
for key, value in event.items():
print(f" {key}: {type(value).__name__}")
elif isinstance(event, tuple):
print(f" Tuple length: {len(event)}")
for i, item in enumerate(event):
print(f" [{i}]: {type(item).__name__}")
else:
print(f" Content preview: {str(event)[:200]}")
if event_count > 10:
print("\n⚠️ Остановка после 10 событий")
break
except Exception as e:
print(f"\n❌ Ошибка: {e}")
import traceback
traceback.print_exc()
print(f"\nВсего событий 'updates + messages': {event_count}")
async def main():
"""Главная функция"""
import sys
print("\n" + "🔬"*40)
print("ТЕСТИРОВАНИЕ СОБЫТИЙ LANGGRAPH")
print("🔬"*40 + "\n")
print("Выберите тест:")
print("1. Полный review с callback (test_streaming)")
print("2. RAW стриминг напрямую из графа (test_raw_graph_streaming)")
print("3. Оба теста")
choice = input("\nВведите номер теста (1/2/3) [по умолчанию: 3]: ").strip() or "3"
if choice in ["1", "3"]:
print("\n" + "▶️"*40)
print("ЗАПУСК ТЕСТА 1: Полный review")
print("▶️"*40 + "\n")
await test_streaming()
if choice in ["2", "3"]:
print("\n" + "▶️"*40)
print("ЗАПУСК ТЕСТА 2: RAW стриминг")
print("▶️"*40 + "\n")
await test_raw_graph_streaming()
print("\n" + ""*40)
print("ВСЕ ТЕСТЫ ЗАВЕРШЕНЫ")
print(""*40 + "\n")
if __name__ == "__main__":
asyncio.run(main())

137
tests/test_llm_streaming.py Normal file
View File

@ -0,0 +1,137 @@
"""
Тест стриминга LLM messages от LangGraph
"""
import asyncio
from langgraph.graph import StateGraph, END
from typing import TypedDict, Annotated
import operator
from langchain_ollama import OllamaLLM
class TestState(TypedDict):
messages: Annotated[list, operator.add]
result: str
async def llm_node(state: TestState) -> TestState:
"""Нода с LLM вызовом"""
print(" [LLM NODE] Вызов LLM...")
llm = OllamaLLM(
model="qwen2.5-coder:3b",
base_url="http://localhost:11434",
temperature=0.7
)
# Простой промпт для быстрого ответа
prompt = "Напиши короткую проверку кода на Python (не более 100 символов)"
response = await llm.ainvoke(prompt)
print(f" [LLM NODE] Ответ получен: {response[:50]}...")
return {
"messages": [{"role": "ai", "content": response}],
"result": response
}
def create_test_graph():
"""Создает тестовый граф с LLM"""
workflow = StateGraph(TestState)
workflow.add_node("llm_call", llm_node)
workflow.set_entry_point("llm_call")
workflow.add_edge("llm_call", END)
return workflow.compile()
async def test_with_llm():
"""Тест стриминга с LLM"""
print("\n" + "="*80)
print("ТЕСТ СТРИМИНГА LLM MESSAGES")
print("="*80)
graph = create_test_graph()
initial_state: TestState = {
"messages": [],
"result": ""
}
# Тест: updates + messages
print(f"\n🔍 Тест: stream_mode=['updates', 'messages']")
print("-" * 80)
event_count = 0
messages_count = 0
async for event in graph.astream(initial_state, stream_mode=["updates", "messages"]):
event_count += 1
if isinstance(event, tuple) and len(event) >= 2:
event_type, event_data = event[0], event[1]
print(f"\n📨 Event #{event_count}")
print(f" Type: {event_type}")
print(f" Data type: {type(event_data)}")
if event_type == 'updates':
print(f" ✅ Node update")
if isinstance(event_data, dict):
for node_name in event_data.keys():
print(f" Node: {node_name}")
elif event_type == 'messages':
messages_count += 1
print(f" 💬 LLM Messages (#{messages_count})")
if isinstance(event_data, (list, tuple)):
for i, msg in enumerate(event_data):
print(f" Message {i+1}:")
# Извлекаем контент
if hasattr(msg, 'content'):
content = msg.content
print(f" Content: {content[:100]}...")
elif isinstance(msg, dict):
print(f" Dict: {msg}")
else:
print(f" Type: {type(msg)}")
print(f" Str: {str(msg)[:100]}...")
print(f"\n" + "="*80)
print(f"Всего событий: {event_count}")
print(f"✅ Messages событий: {messages_count}")
print("="*80)
async def main():
print("\n" + "="*80)
print("ТЕСТИРОВАНИЕ LLM STREAMING В LANGGRAPH")
print("="*80)
print("\nПроверка Ollama...")
try:
# Проверяем что Ollama доступен
from langchain_ollama import OllamaLLM
test_llm = OllamaLLM(model="qwen2.5-coder:3b", base_url="http://localhost:11434")
result = await test_llm.ainvoke("test")
print("✅ Ollama работает!")
except Exception as e:
print(f"❌ Ошибка подключения к Ollama: {e}")
print("\n⚠️ Убедитесь что Ollama запущен: ollama serve")
print("⚠️ И модель загружена: ollama pull qwen2.5-coder:3b\n")
return
await test_with_llm()
print("\n✅ Тестирование завершено\n")
if __name__ == "__main__":
asyncio.run(main())

228
tests/test_simple_graph.py Normal file
View File

@ -0,0 +1,228 @@
"""
Упрощенный тест LangGraph без реального review
Проверяет только механизм стриминга событий
"""
import asyncio
from langgraph.graph import StateGraph, END
from typing import TypedDict, Annotated, Any
import operator
# Простое состояние для теста
class SimpleState(TypedDict):
counter: Annotated[int, operator.add]
messages: list[str]
step: str
# Простые ноды
async def node_1(state: SimpleState) -> SimpleState:
"""Первая нода"""
print(" [NODE 1] Выполняется...")
await asyncio.sleep(0.5)
return {
"counter": 1,
"messages": ["Node 1 completed"],
"step": "node_1"
}
async def node_2(state: SimpleState) -> SimpleState:
"""Вторая нода"""
print(" [NODE 2] Выполняется...")
await asyncio.sleep(0.5)
return {
"counter": 1,
"messages": ["Node 2 completed"],
"step": "node_2"
}
async def node_3(state: SimpleState) -> SimpleState:
"""Третья нода"""
print(" [NODE 3] Выполняется...")
await asyncio.sleep(0.5)
return {
"counter": 1,
"messages": ["Node 3 completed"],
"step": "node_3"
}
def create_test_graph():
"""Создает тестовый граф"""
workflow = StateGraph(SimpleState)
# Добавляем ноды
workflow.add_node("node_1", node_1)
workflow.add_node("node_2", node_2)
workflow.add_node("node_3", node_3)
# Определяем связи
workflow.set_entry_point("node_1")
workflow.add_edge("node_1", "node_2")
workflow.add_edge("node_2", "node_3")
workflow.add_edge("node_3", END)
return workflow.compile()
async def test_stream_modes():
"""Тестирует разные режимы стриминга"""
graph = create_test_graph()
initial_state: SimpleState = {
"counter": 0,
"messages": [],
"step": "start"
}
# Тест 1: updates
print("\n" + "="*80)
print("ТЕСТ 1: stream_mode=['updates']")
print("="*80)
event_count = 0
async for event in graph.astream(initial_state, stream_mode=["updates"]):
event_count += 1
print(f"\n📨 Event #{event_count}")
print(f" Type: {type(event)}")
print(f" Content: {event}")
print(f"\n✅ Получено событий: {event_count}")
# Тест 2: messages
print("\n" + "="*80)
print("ТЕСТ 2: stream_mode=['messages']")
print("="*80)
event_count = 0
async for event in graph.astream(initial_state, stream_mode=["messages"]):
event_count += 1
print(f"\n📨 Event #{event_count}")
print(f" Type: {type(event)}")
print(f" Content: {event}")
print(f"\n✅ Получено событий: {event_count}")
# Тест 3: updates + messages
print("\n" + "="*80)
print("ТЕСТ 3: stream_mode=['updates', 'messages']")
print("="*80)
event_count = 0
async for event in graph.astream(initial_state, stream_mode=["updates", "messages"]):
event_count += 1
print(f"\n📨 Event #{event_count}")
print(f" Type: {type(event)}")
if isinstance(event, dict):
print(f" Keys: {list(event.keys())}")
elif isinstance(event, tuple):
print(f" Tuple[0] type: {type(event[0])}")
print(f" Tuple[1] type: {type(event[1])}")
print(f" Content: {event}")
print(f"\n✅ Получено событий: {event_count}")
# Тест 4: values
print("\n" + "="*80)
print("ТЕСТ 4: stream_mode=['values']")
print("="*80)
event_count = 0
async for event in graph.astream(initial_state, stream_mode=["values"]):
event_count += 1
print(f"\n📨 Event #{event_count}")
print(f" Type: {type(event)}")
print(f" Content: {event}")
print(f"\n✅ Получено событий: {event_count}")
# Тест 5: debug (все режимы)
print("\n" + "="*80)
print("ТЕСТ 5: stream_mode=['updates', 'messages', 'values', 'debug']")
print("="*80)
event_count = 0
async for event in graph.astream(
initial_state,
stream_mode=["updates", "messages", "values", "debug"]
):
event_count += 1
print(f"\n📨 Event #{event_count}")
print(f" Type: {type(event)}")
if isinstance(event, tuple) and len(event) >= 2:
print(f" Event type (tuple[0]): {event[0]}")
print(f" Event data (tuple[1]): {event[1]}")
else:
print(f" Content: {event}")
print(f"\n✅ Получено событий: {event_count}")
async def test_with_callback():
"""Тест с использованием callback для обработки событий"""
print("\n" + "="*80)
print("ТЕСТ 6: Callback обработка событий")
print("="*80)
graph = create_test_graph()
initial_state: SimpleState = {
"counter": 0,
"messages": [],
"step": "start"
}
collected_events = []
async def event_callback(event_type: str, event_data: Any):
"""Callback для обработки событий"""
collected_events.append({
"type": event_type,
"data": event_data
})
print(f" 🔔 Callback: {event_type}")
# Симуляция обработки событий с callback
event_count = 0
async for event in graph.astream(initial_state, stream_mode=["updates", "messages"]):
event_count += 1
print(f"\n📨 Event #{event_count}: {type(event)}")
# Обрабатываем событие
if isinstance(event, tuple) and len(event) >= 2:
await event_callback(str(event[0]), event[1])
elif isinstance(event, dict):
for node_name, node_data in event.items():
await event_callback(f"node_update_{node_name}", node_data)
else:
await event_callback("unknown", event)
print(f"\nВсего событий: {event_count}")
print(f"✅ Callback вызовов: {len(collected_events)}")
print(f"\n📋 Собранные события:")
for i, evt in enumerate(collected_events, 1):
print(f" {i}. {evt['type']}")
async def main():
print("\n" + "="*80)
print("ПРОСТОЙ ТЕСТ LANGGRAPH STREAMING")
print("="*80 + "\n")
await test_stream_modes()
await test_with_callback()
print("\n" + "="*80)
print("ТЕСТИРОВАНИЕ ЗАВЕРШЕНО")
print("="*80 + "\n")
if __name__ == "__main__":
asyncio.run(main())