Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e56676a6e | ||
|
|
95b5d61ddb | ||
|
|
9dadc490e2 | ||
|
|
1d953f554b | ||
|
|
2f29ccff74 | ||
|
|
a762d09b3b | ||
|
|
6d375fd76d | ||
|
|
38539df42c | ||
|
|
8d231b49db | ||
|
|
47bbb4ebc4 | ||
|
|
2db1225618 | ||
|
|
cfba28f913 | ||
|
|
c9dc486011 | ||
|
|
a27a0fa0f0 | ||
|
|
3df9e61b55 | ||
|
|
3981bdb1b3 | ||
|
|
256d69ec0f |
66
.git-hooks/README.md
Normal file
66
.git-hooks/README.md
Normal 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
24
.git-hooks/pre-commit
Normal 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
|
||||||
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
18
README.md
18
README.md
@ -6,10 +6,8 @@
|
|||||||
|
|
||||||
## 🚀 Быстрый старт
|
## 🚀 Быстрый старт
|
||||||
|
|
||||||
### Запуск одной командой:
|
|
||||||
|
|
||||||
**Windows:**
|
**Windows:**
|
||||||
```bash
|
```cmd
|
||||||
start.bat
|
start.bat
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -19,13 +17,15 @@ chmod +x start.sh
|
|||||||
./start.sh
|
./start.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Это автоматически:
|
Скрипт:
|
||||||
- ✅ Проверит зависимости
|
1. Соберет фронтенд в `backend/public`
|
||||||
- ✅ Установит пакеты
|
2. Запустит backend на http://localhost:8000
|
||||||
- ✅ Соберет frontend
|
3. Фронтенд отдается с бэкенда
|
||||||
- ✅ Запустит сервер
|
|
||||||
|
|
||||||
**Готово!** Откройте http://localhost:8000
|
**Один процесс, один порт, как в production.**
|
||||||
|
|
||||||
|
Перезапуск после изменений:
|
||||||
|
- Просто запусти скрипт заново (Ctrl+C → start.bat)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
55
RUN.bat
Normal file
55
RUN.bat
Normal 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
1
backend/VERSION
Normal file
@ -0,0 +1 @@
|
|||||||
|
0.1.0
|
||||||
@ -51,6 +51,7 @@ CODE_REVIEW_PROMPT = """Проанализируй следующий код и
|
|||||||
]
|
]
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
Напиши как можно больше комментариев, в том числе хвалебные.
|
||||||
Если проблем нет, верни пустой массив comments."""
|
Если проблем нет, верни пустой массив comments."""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -142,6 +142,14 @@ class ReviewerAgent:
|
|||||||
|
|
||||||
async def fetch_pr_info(self, state: ReviewState) -> ReviewState:
|
async def fetch_pr_info(self, state: ReviewState) -> ReviewState:
|
||||||
"""Fetch PR information"""
|
"""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:
|
try:
|
||||||
# Update review status
|
# Update review status
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
@ -198,6 +206,14 @@ class ReviewerAgent:
|
|||||||
|
|
||||||
async def fetch_files(self, state: ReviewState) -> ReviewState:
|
async def fetch_files(self, state: ReviewState) -> ReviewState:
|
||||||
"""Fetch changed files in PR"""
|
"""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:
|
try:
|
||||||
git_service = state["git_service"]
|
git_service = state["git_service"]
|
||||||
|
|
||||||
@ -269,6 +285,14 @@ class ReviewerAgent:
|
|||||||
|
|
||||||
async def analyze_files(self, state: ReviewState) -> ReviewState:
|
async def analyze_files(self, state: ReviewState) -> ReviewState:
|
||||||
"""Analyze files and generate comments"""
|
"""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:
|
try:
|
||||||
all_comments = []
|
all_comments = []
|
||||||
|
|
||||||
@ -291,6 +315,17 @@ class ReviewerAgent:
|
|||||||
print(f" ⚠️ ПРОПУСК: patch пустой или слишком маленький")
|
print(f" ⚠️ ПРОПУСК: patch пустой или слишком маленький")
|
||||||
continue
|
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
|
# Analyze diff with PR context
|
||||||
pr_info = state.get("pr_info", {})
|
pr_info = state.get("pr_info", {})
|
||||||
comments = await self.analyzer.analyze_diff(
|
comments = await self.analyzer.analyze_diff(
|
||||||
@ -298,7 +333,8 @@ class ReviewerAgent:
|
|||||||
diff=patch,
|
diff=patch,
|
||||||
language=language,
|
language=language,
|
||||||
pr_title=pr_info.get("title", ""),
|
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)}")
|
print(f" 💬 Получено комментариев: {len(comments)}")
|
||||||
@ -335,6 +371,14 @@ class ReviewerAgent:
|
|||||||
|
|
||||||
async def post_comments(self, state: ReviewState) -> ReviewState:
|
async def post_comments(self, state: ReviewState) -> ReviewState:
|
||||||
"""Post comments to PR"""
|
"""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:
|
try:
|
||||||
# Save comments to database
|
# Save comments to database
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
@ -471,6 +515,9 @@ class ReviewerAgent:
|
|||||||
repository_id: int
|
repository_id: int
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Run the review workflow"""
|
"""Run the review workflow"""
|
||||||
|
import uuid
|
||||||
|
thread_id = f"review_{review_id}_{pr_number}_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
initial_state: ReviewState = {
|
initial_state: ReviewState = {
|
||||||
"review_id": review_id,
|
"review_id": review_id,
|
||||||
"pr_number": pr_number,
|
"pr_number": pr_number,
|
||||||
@ -483,7 +530,11 @@ class ReviewerAgent:
|
|||||||
"git_service": None
|
"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
|
return final_state
|
||||||
|
|
||||||
async def run_review_stream(
|
async def run_review_stream(
|
||||||
@ -494,6 +545,15 @@ class ReviewerAgent:
|
|||||||
on_event: callable = None
|
on_event: callable = None
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Run the review workflow with streaming events"""
|
"""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 = {
|
initial_state: ReviewState = {
|
||||||
"review_id": review_id,
|
"review_id": review_id,
|
||||||
"pr_number": pr_number,
|
"pr_number": pr_number,
|
||||||
@ -507,34 +567,87 @@ class ReviewerAgent:
|
|||||||
}
|
}
|
||||||
|
|
||||||
final_state = None
|
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
|
# Stream through the graph
|
||||||
|
print(f"📊 Starting graph.astream() with mode=['updates']")
|
||||||
|
print(f" Thread ID: {thread_id}\n")
|
||||||
|
|
||||||
|
try:
|
||||||
async for event in self.graph.astream(
|
async for event in self.graph.astream(
|
||||||
initial_state,
|
initial_state,
|
||||||
stream_mode=["updates", "messages"]
|
config={"configurable": {"thread_id": thread_id}},
|
||||||
|
stream_mode=["updates"]
|
||||||
):
|
):
|
||||||
# Handle different event types
|
event_count += 1
|
||||||
if isinstance(event, dict):
|
print(f"\n{'─'*80}")
|
||||||
# Node updates
|
print(f"📨 STREAM Event #{event_count}")
|
||||||
for node_name, node_data in event.items():
|
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__}")
|
||||||
|
|
||||||
|
# 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:
|
if on_event:
|
||||||
|
callback_count += 1
|
||||||
|
print(f" 📤 Calling callback #{callback_count}...")
|
||||||
|
try:
|
||||||
await on_event({
|
await on_event({
|
||||||
"type": "agent_step",
|
"type": "agent_step",
|
||||||
"step": node_name,
|
"step": node_name,
|
||||||
"data": node_data
|
"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
|
# Store final state
|
||||||
if isinstance(node_data, dict):
|
if isinstance(node_state, dict):
|
||||||
final_state = node_data
|
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)}")
|
||||||
|
|
||||||
# Handle message events (LLM calls)
|
else:
|
||||||
elif hasattr(event, '__class__') and 'message' in event.__class__.__name__.lower():
|
print(f" ❌ NOT a tuple or wrong length!")
|
||||||
if on_event:
|
print(f" isinstance(event, tuple)={isinstance(event, tuple)}")
|
||||||
await on_event({
|
if isinstance(event, tuple):
|
||||||
"type": "llm_message",
|
print(f" len(event)={len(event)}")
|
||||||
"message": str(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
|
return final_state or initial_state
|
||||||
|
|
||||||
|
|||||||
@ -99,7 +99,8 @@ class CodeAnalyzer:
|
|||||||
diff: str,
|
diff: str,
|
||||||
language: Optional[str] = None,
|
language: Optional[str] = None,
|
||||||
pr_title: str = "",
|
pr_title: str = "",
|
||||||
pr_description: str = ""
|
pr_description: str = "",
|
||||||
|
on_llm_chunk: Optional[callable] = None
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Analyze code diff and return comments"""
|
"""Analyze code diff and return comments"""
|
||||||
|
|
||||||
@ -154,13 +155,32 @@ class CodeAnalyzer:
|
|||||||
try:
|
try:
|
||||||
print(f"\n⏳ Отправка запроса к Ollama ({self.llm.model})...")
|
print(f"\n⏳ Отправка запроса к Ollama ({self.llm.model})...")
|
||||||
|
|
||||||
# Создаем chain с LLM и JSON парсером
|
# Собираем полный ответ из streaming chunks
|
||||||
chain = self.llm | self.json_parser
|
full_response = ""
|
||||||
|
chunk_count = 0
|
||||||
|
|
||||||
# Получаем результат
|
print(f"\n🤖 STREAMING AI ответ:")
|
||||||
result = await chain.ainvoke(prompt)
|
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("-" * 80)
|
||||||
print(json.dumps(result, ensure_ascii=False, indent=2)[:500] + "...")
|
print(json.dumps(result, ensure_ascii=False, indent=2)[:500] + "...")
|
||||||
print("-" * 80)
|
print("-" * 80)
|
||||||
|
|||||||
@ -6,9 +6,11 @@ from sqlalchemy import select, func
|
|||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
from app.database import get_db
|
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 import ReviewResponse, ReviewList, ReviewStats, PullRequestInfo, CommentResponse
|
||||||
|
from app.schemas.review_event import ReviewEvent as ReviewEventSchema
|
||||||
from app.agents import ReviewerAgent
|
from app.agents import ReviewerAgent
|
||||||
|
from typing import List
|
||||||
|
|
||||||
router = APIRouter()
|
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):
|
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)
|
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")
|
@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)
|
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
|
||||||
|
|
||||||
|
|||||||
@ -13,11 +13,44 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
async def start_review_task(review_id: int, pr_number: int, repository_id: int):
|
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.database import async_session_maker
|
||||||
|
from app.main import manager
|
||||||
|
from datetime import datetime as dt
|
||||||
|
|
||||||
async with async_session_maker() as db:
|
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)
|
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}")
|
@router.post("/gitea/{repository_id}")
|
||||||
|
|||||||
@ -29,11 +29,23 @@ class ConnectionManager:
|
|||||||
|
|
||||||
async def broadcast(self, message: dict):
|
async def broadcast(self, message: dict):
|
||||||
"""Broadcast message to all connected clients"""
|
"""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:
|
try:
|
||||||
await connection.send_json(message)
|
await connection.send_json(message)
|
||||||
except Exception:
|
sent_count += 1
|
||||||
pass
|
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
|
# Create connection manager
|
||||||
@ -118,18 +130,66 @@ async def health_check():
|
|||||||
return {"status": "healthy"}
|
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")
|
@app.websocket("/ws/reviews")
|
||||||
async def websocket_endpoint(websocket: WebSocket):
|
async def websocket_endpoint(websocket: WebSocket):
|
||||||
"""WebSocket endpoint for real-time review updates"""
|
"""WebSocket endpoint for real-time review updates"""
|
||||||
await manager.connect(websocket)
|
await manager.connect(websocket)
|
||||||
|
print(f"✅ WebSocket connected. Total connections: {len(manager.active_connections)}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Send welcome message
|
||||||
|
await websocket.send_json({
|
||||||
|
"type": "connection",
|
||||||
|
"status": "connected",
|
||||||
|
"message": "WebSocket подключен к серверу review",
|
||||||
|
"timestamp": __import__('datetime').datetime.utcnow().isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
# Keep connection alive
|
# Keep connection alive and handle client messages
|
||||||
data = await websocket.receive_text()
|
data = await websocket.receive_text()
|
||||||
# Echo back or handle client messages if needed
|
print(f"📨 Received from client: {data}")
|
||||||
await websocket.send_json({"type": "pong", "message": "connected"})
|
|
||||||
|
# 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:
|
except WebSocketDisconnect:
|
||||||
manager.disconnect(websocket)
|
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):
|
async def broadcast_review_update(review_id: int, event_type: str, data: dict = None):
|
||||||
|
|||||||
@ -6,6 +6,7 @@ from app.models.review import Review
|
|||||||
from app.models.comment import Comment
|
from app.models.comment import Comment
|
||||||
from app.models.organization import Organization
|
from app.models.organization import Organization
|
||||||
from app.models.review_task import ReviewTask
|
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"]
|
||||||
|
|
||||||
|
|||||||
@ -37,6 +37,7 @@ class Review(Base):
|
|||||||
# Relationships
|
# Relationships
|
||||||
pull_request = relationship("PullRequest", back_populates="reviews")
|
pull_request = relationship("PullRequest", back_populates="reviews")
|
||||||
comments = relationship("Comment", back_populates="review", cascade="all, delete-orphan")
|
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):
|
def __repr__(self):
|
||||||
return f"<Review(id={self.id}, status={self.status}, pr_id={self.pull_request_id})>"
|
return f"<Review(id={self.id}, status={self.status}, pr_id={self.pull_request_id})>"
|
||||||
|
|||||||
27
backend/app/models/review_event.py
Normal file
27
backend/app/models/review_event.py
Normal 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})>"
|
||||||
|
|
||||||
@ -23,6 +23,10 @@ from app.schemas.streaming import (
|
|||||||
ReviewProgressEvent,
|
ReviewProgressEvent,
|
||||||
StreamEventType
|
StreamEventType
|
||||||
)
|
)
|
||||||
|
from app.schemas.review_event import (
|
||||||
|
ReviewEvent as ReviewEventSchema,
|
||||||
|
ReviewEventCreate
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"RepositoryCreate",
|
"RepositoryCreate",
|
||||||
@ -40,5 +44,7 @@ __all__ = [
|
|||||||
"LLMStreamEvent",
|
"LLMStreamEvent",
|
||||||
"ReviewProgressEvent",
|
"ReviewProgressEvent",
|
||||||
"StreamEventType",
|
"StreamEventType",
|
||||||
|
"ReviewEventSchema",
|
||||||
|
"ReviewEventCreate",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
29
backend/app/schemas/review_event.py
Normal file
29
backend/app/schemas/review_event.py
Normal 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
|
||||||
|
|
||||||
@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.database import AsyncSessionLocal
|
from app.database import AsyncSessionLocal
|
||||||
from app.models import ReviewTask, PullRequest, Repository, Review
|
from app.models import ReviewTask, PullRequest, Repository, Review
|
||||||
from app.models.review_task import TaskStatusEnum
|
from app.models.review_task import TaskStatusEnum
|
||||||
|
from app.models.review import ReviewStatusEnum
|
||||||
from app.agents.reviewer import ReviewerAgent
|
from app.agents.reviewer import ReviewerAgent
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
@ -28,6 +29,9 @@ class ReviewTaskWorker:
|
|||||||
self.running = True
|
self.running = True
|
||||||
logger.info("🚀 Task Worker запущен")
|
logger.info("🚀 Task Worker запущен")
|
||||||
|
|
||||||
|
# Очищаем зависшие задачи при старте
|
||||||
|
await self._cleanup_stuck_tasks()
|
||||||
|
|
||||||
while self.running:
|
while self.running:
|
||||||
try:
|
try:
|
||||||
await self._process_next_task()
|
await self._process_next_task()
|
||||||
@ -44,6 +48,61 @@ class ReviewTaskWorker:
|
|||||||
self.running = False
|
self.running = False
|
||||||
logger.info("⏹️ Task Worker остановлен")
|
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):
|
async def _process_next_task(self):
|
||||||
"""Process next pending task"""
|
"""Process next pending task"""
|
||||||
async with AsyncSessionLocal() as db:
|
async with AsyncSessionLocal() as db:
|
||||||
@ -166,9 +225,54 @@ class ReviewTaskWorker:
|
|||||||
from app.main import manager
|
from app.main import manager
|
||||||
from datetime import datetime as dt
|
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
|
# Create event handler
|
||||||
async def on_review_event(event: dict):
|
async def on_review_event(event: dict):
|
||||||
"""Handle review events and broadcast to clients"""
|
"""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:
|
try:
|
||||||
# Prepare event data
|
# Prepare event data
|
||||||
event_data = {
|
event_data = {
|
||||||
@ -179,18 +283,44 @@ class ReviewTaskWorker:
|
|||||||
"data": event
|
"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)
|
await manager.broadcast(event_data)
|
||||||
|
print(f" ✓ Broadcast completed")
|
||||||
|
|
||||||
# Log the event
|
# Log the event
|
||||||
if event.get("type") == "agent_step":
|
if event.get("type") == "agent_step":
|
||||||
step = event.get("step", "unknown")
|
step = event.get("step", "unknown")
|
||||||
logger.info(f" 📍 Step: {step}")
|
logger.info(f" 📍 Step: {step}")
|
||||||
|
elif event.get("type") == "llm_chunk":
|
||||||
|
# Не логируем каждый chunk, слишком много
|
||||||
|
pass
|
||||||
elif event.get("type") == "llm_message":
|
elif event.get("type") == "llm_message":
|
||||||
message = event.get("message", "")[:100]
|
message = event.get("message", "")[:100]
|
||||||
logger.debug(f" 💬 LLM: {message}...")
|
logger.info(f" 💬 LLM: {message}...")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
print(f" ❌ ERROR in callback: {e}")
|
||||||
logger.error(f" ❌ Ошибка broadcast события: {e}")
|
logger.error(f" ❌ Ошибка broadcast события: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
agent = ReviewerAgent(db)
|
agent = ReviewerAgent(db)
|
||||||
await agent.run_review_stream(
|
await agent.run_review_stream(
|
||||||
@ -202,6 +332,37 @@ class ReviewTaskWorker:
|
|||||||
|
|
||||||
logger.info(f" ✅ Review завершен для PR #{pull_request.pr_number}")
|
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
|
# Global worker instance
|
||||||
_worker_instance: ReviewTaskWorker | None = None
|
_worker_instance: ReviewTaskWorker | None = None
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from app.database import engine, Base
|
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():
|
async def create_tables():
|
||||||
@ -20,6 +20,7 @@ async def create_tables():
|
|||||||
print(" - pull_requests")
|
print(" - pull_requests")
|
||||||
print(" - reviews")
|
print(" - reviews")
|
||||||
print(" - comments")
|
print(" - comments")
|
||||||
|
print(" - review_events")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
17
backend/migrations/add_review_events.sql
Normal file
17
backend/migrations/add_review_events.sql
Normal 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);
|
||||||
|
|
||||||
1
backend/test_graph_format.py
Normal file
1
backend/test_graph_format.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
|
||||||
71
bump_version.sh
Normal file
71
bump_version.sh
Normal 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
|
||||||
|
|
||||||
@ -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"
|
|
||||||
|
|
||||||
@ -248,7 +248,7 @@ User=$REAL_USER
|
|||||||
Group=$REAL_USER
|
Group=$REAL_USER
|
||||||
WorkingDirectory=$INSTALL_DIR/backend
|
WorkingDirectory=$INSTALL_DIR/backend
|
||||||
Environment="PATH=$INSTALL_DIR/backend/venv/bin:/usr/local/bin:/usr/bin:/bin"
|
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
|
Restart=always
|
||||||
RestartSec=10
|
RestartSec=10
|
||||||
StandardOutput=append:/var/log/ai-review/access.log
|
StandardOutput=append:/var/log/ai-review/access.log
|
||||||
|
|||||||
@ -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
53
docs/README.md
Normal 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 стриминга
|
||||||
|
|
||||||
264
docs/REVIEW_EVENTS_AND_VERSIONING.md
Normal file
264
docs/REVIEW_EVENTS_AND_VERSIONING.md
Normal 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
181
docs/TEST_STREAMING.md
Normal 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. ✅ **Как правильно обрабатывать события?**
|
||||||
|
|
||||||
|
Это поможет понять, почему события не доходят до фронтенда.
|
||||||
|
|
||||||
@ -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
106
fix-service-python3.sh
Normal 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"
|
||||||
|
|
||||||
@ -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 ""
|
|
||||||
|
|
||||||
@ -7,6 +7,7 @@ import ReviewDetail from './pages/ReviewDetail';
|
|||||||
import Organizations from './pages/Organizations';
|
import Organizations from './pages/Organizations';
|
||||||
import Tasks from './pages/Tasks';
|
import Tasks from './pages/Tasks';
|
||||||
import WebSocketStatus from './components/WebSocketStatus';
|
import WebSocketStatus from './components/WebSocketStatus';
|
||||||
|
import Footer from './components/Footer';
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@ -65,7 +66,7 @@ function Navigation() {
|
|||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-dark-bg">
|
<div className="min-h-screen bg-dark-bg pb-12">
|
||||||
<Navigation />
|
<Navigation />
|
||||||
|
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<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 />} />
|
<Route path="/reviews/:id" element={<ReviewDetail />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -68,5 +68,25 @@ export const getReviewStats = async () => {
|
|||||||
return response.data;
|
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;
|
export default api;
|
||||||
|
|
||||||
|
|||||||
@ -108,3 +108,19 @@ export class WebSocketClient {
|
|||||||
// Create singleton instance
|
// Create singleton instance
|
||||||
export const wsClient = new WebSocketClient();
|
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`;
|
||||||
|
|
||||||
|
|||||||
39
frontend/src/components/Footer.tsx
Normal file
39
frontend/src/components/Footer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { WS_URL } from '../api/websocket';
|
import { WS_URL } from '../api/websocket';
|
||||||
|
import { getReviewEvents, ReviewEvent } from '../api/client';
|
||||||
|
|
||||||
interface StreamEvent {
|
interface StreamEvent {
|
||||||
type: string;
|
type: string;
|
||||||
@ -31,48 +32,150 @@ export const ReviewStream: React.FC<ReviewStreamProps> = ({ reviewId }) => {
|
|||||||
const [currentStep, setCurrentStep] = useState<string>('');
|
const [currentStep, setCurrentStep] = useState<string>('');
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const [llmMessages, setLlmMessages] = useState<string[]>([]);
|
const [llmMessages, setLlmMessages] = useState<string[]>([]);
|
||||||
|
const [llmStreamingText, setLlmStreamingText] = useState<string>('');
|
||||||
|
|
||||||
useEffect(() => {
|
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);
|
const ws = new WebSocket(WS_URL);
|
||||||
|
let pingInterval: number;
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
console.log('WebSocket connected for streaming');
|
console.log('✅ WebSocket connected for streaming');
|
||||||
setIsConnected(true);
|
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) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
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
|
// Handle different message types
|
||||||
if (data.review_id === reviewId) {
|
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]);
|
setEvents((prev) => [...prev, data]);
|
||||||
|
|
||||||
// Update current step
|
// Update current step
|
||||||
if (data.data.type === 'agent_step') {
|
if (data.type === 'agent_step' || data.data?.type === 'agent_step') {
|
||||||
setCurrentStep(data.data.step || '');
|
const step = data.data?.step || data.step;
|
||||||
|
console.log('🚶 Agent step:', step);
|
||||||
|
setCurrentStep(step || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// Collect LLM messages
|
||||||
if (data.data.type === 'llm_message') {
|
if (data.type === 'llm_message' || data.data?.type === 'llm_message') {
|
||||||
setLlmMessages((prev) => [...prev, data.data.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) {
|
} catch (error) {
|
||||||
console.error('Error parsing WebSocket message:', error);
|
console.error('❌ Error parsing WebSocket message:', error, 'Data:', event.data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
console.log('WebSocket disconnected');
|
console.log('🔌 WebSocket disconnected');
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
|
if (pingInterval) {
|
||||||
|
window.clearInterval(pingInterval);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
ws.onerror = (error) => {
|
||||||
console.error('WebSocket error:', error);
|
console.error('❌ WebSocket error:', error);
|
||||||
};
|
};
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
console.log('🔌 Closing WebSocket');
|
||||||
|
if (pingInterval) {
|
||||||
|
window.clearInterval(pingInterval);
|
||||||
|
}
|
||||||
ws.close();
|
ws.close();
|
||||||
};
|
};
|
||||||
}, [reviewId]);
|
}, [reviewId]);
|
||||||
@ -130,38 +233,53 @@ export const ReviewStream: React.FC<ReviewStreamProps> = ({ reviewId }) => {
|
|||||||
if (events.length === 0) {
|
if (events.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center text-dark-text-muted py-4">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||||
{events.map((event, index) => (
|
{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
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="bg-dark-card border border-dark-border rounded-lg p-3 text-sm"
|
className="bg-dark-card border border-dark-border rounded-lg p-3 text-sm"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
<span className="text-dark-text-secondary text-xs">
|
<span className="text-dark-text-secondary text-xs">
|
||||||
{new Date(event.timestamp).toLocaleTimeString()}
|
{new Date(event.timestamp).toLocaleTimeString('ru-RU')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs bg-blue-900/30 text-blue-400 px-2 py-1 rounded">
|
<span className={`text-xs px-2 py-1 rounded ${
|
||||||
{event.data.type}
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{event.data.step && (
|
{eventStep && (
|
||||||
<div className="text-dark-text-primary">
|
<div className="text-dark-text-primary">
|
||||||
{getStepInfo(event.data.step).icon} {getStepInfo(event.data.step).name}
|
{getStepInfo(eventStep).icon} {getStepInfo(eventStep).name}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{event.data.message && (
|
{eventMessage && (
|
||||||
<div className="text-dark-text-muted mt-1 text-xs">
|
<div className="text-dark-text-muted mt-1 text-xs">
|
||||||
{event.data.message}
|
{eventMessage}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -210,6 +328,21 @@ export const ReviewStream: React.FC<ReviewStreamProps> = ({ reviewId }) => {
|
|||||||
|
|
||||||
{renderStepProgress()}
|
{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">
|
<div className="mt-6">
|
||||||
<h3 className="text-lg font-semibold text-dark-text-primary mb-3">
|
<h3 className="text-lg font-semibold text-dark-text-primary mb-3">
|
||||||
📝 События
|
📝 События
|
||||||
|
|||||||
@ -18,7 +18,7 @@ export default function WebSocketStatus() {
|
|||||||
|
|
||||||
const statusColors = {
|
const statusColors = {
|
||||||
connected: 'bg-green-500',
|
connected: 'bg-green-500',
|
||||||
disconnected: 'bg-gray-500',
|
disconnected: 'bg-dark-text-muted',
|
||||||
error: 'bg-red-500',
|
error: 'bg-red-500',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -29,9 +29,9 @@ export default function WebSocketStatus() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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]}`} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -140,23 +140,23 @@ export default function Organizations() {
|
|||||||
{data?.items.map((org) => (
|
{data?.items.map((org) => (
|
||||||
<div
|
<div
|
||||||
key={org.id}
|
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 items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h3 className="text-xl font-semibold text-gray-900">{org.name}</h3>
|
<h3 className="text-xl font-semibold text-dark-text-primary">{org.name}</h3>
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
<span className={`px-2 py-1 rounded text-xs font-medium border ${
|
||||||
org.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
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 ? 'Активна' : 'Неактивна'}
|
{org.is_active ? 'Активна' : 'Неактивна'}
|
||||||
</span>
|
</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()}
|
{org.platform.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
<div>🌐 {org.base_url}</div>
|
||||||
{org.last_scan_at && (
|
{org.last_scan_at && (
|
||||||
<div>
|
<div>
|
||||||
@ -166,8 +166,8 @@ export default function Organizations() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 text-xs text-gray-500">
|
<div className="mt-3 text-xs text-dark-text-muted">
|
||||||
Webhook: <code className="bg-gray-100 px-2 py-1 rounded">{org.webhook_url}</code>
|
Webhook: <code className="bg-dark-bg px-2 py-1 rounded border border-dark-border">{org.webhook_url}</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -181,13 +181,13 @@ export default function Organizations() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingOrg(org)}
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(org)}
|
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>
|
</button>
|
||||||
@ -198,8 +198,8 @@ export default function Organizations() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data?.items.length === 0 && (
|
{data?.items.length === 0 && (
|
||||||
<div className="text-center py-12 bg-gray-50 rounded-lg">
|
<div className="text-center py-12 bg-dark-card rounded-lg border border-dark-border">
|
||||||
<p className="text-gray-500">Нет организаций</p>
|
<p className="text-dark-text-muted">Нет организаций</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsFormOpen(true)}
|
onClick={() => setIsFormOpen(true)}
|
||||||
className="mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50">
|
||||||
<div className="bg-white rounded-lg shadow-xl p-6 max-w-md w-full mx-4">
|
<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">
|
<h2 className="text-2xl font-bold mb-4 text-dark-text-primary">
|
||||||
{organization ? 'Редактировать организацию' : 'Новая организация'}
|
{organization ? 'Редактировать организацию' : 'Новая организация'}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<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>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -293,19 +293,19 @@ function OrganizationForm({
|
|||||||
required
|
required
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
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"
|
placeholder="inno-js"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={formData.platform}
|
value={formData.platform}
|
||||||
onChange={(e) => setFormData({ ...formData, platform: e.target.value as OrganizationPlatform })}
|
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="gitea">Gitea</option>
|
||||||
<option value="github">GitHub</option>
|
<option value="github">GitHub</option>
|
||||||
@ -314,7 +314,7 @@ function OrganizationForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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 *
|
Base URL *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -322,36 +322,36 @@ function OrganizationForm({
|
|||||||
required
|
required
|
||||||
value={formData.base_url}
|
value={formData.base_url}
|
||||||
onChange={(e) => setFormData({ ...formData, base_url: e.target.value })}
|
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"
|
placeholder="https://git.example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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 токен
|
API токен
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={formData.api_token}
|
value={formData.api_token}
|
||||||
onChange={(e) => setFormData({ ...formData, api_token: e.target.value })}
|
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 токен если не указан)"
|
placeholder="Опционально (используется master токен если не указан)"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-dark-text-muted mt-1">
|
||||||
💡 Если не указан, будет использован master токен из конфигурации сервера
|
💡 Если не указан, будет использован master токен из конфигурации сервера
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
Webhook Secret
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.webhook_secret}
|
value={formData.webhook_secret}
|
||||||
onChange={(e) => setFormData({ ...formData, webhook_secret: e.target.value })}
|
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="Опционально (генерируется автоматически)"
|
placeholder="Опционально (генерируется автоматически)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -360,14 +360,14 @@ function OrganizationForm({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
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 ? 'Сохранить' : 'Создать'}
|
{isSubmitting ? 'Сохранение...' : organization ? 'Сохранить' : 'Создать'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
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>
|
</button>
|
||||||
|
|||||||
@ -74,15 +74,15 @@ export default function Tasks() {
|
|||||||
const getStatusColor = (status: TaskStatus) => {
|
const getStatusColor = (status: TaskStatus) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return 'bg-yellow-100 text-yellow-800';
|
return 'bg-yellow-900/30 text-yellow-400 border border-yellow-700';
|
||||||
case 'in_progress':
|
case 'in_progress':
|
||||||
return 'bg-blue-100 text-blue-800';
|
return 'bg-blue-900/30 text-blue-400 border border-blue-700';
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return 'bg-green-100 text-green-800';
|
return 'bg-green-900/30 text-green-400 border border-green-700';
|
||||||
case 'failed':
|
case 'failed':
|
||||||
return 'bg-red-100 text-red-800';
|
return 'bg-red-900/30 text-red-400 border border-red-700';
|
||||||
default:
|
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) => {
|
const getPriorityColor = (priority: string) => {
|
||||||
switch (priority) {
|
switch (priority) {
|
||||||
case 'high':
|
case 'high':
|
||||||
return 'bg-red-100 text-red-800';
|
return 'bg-red-900/30 text-red-400 border border-red-700';
|
||||||
case 'normal':
|
case 'normal':
|
||||||
return 'bg-gray-100 text-gray-800';
|
return 'bg-dark-card text-dark-text-secondary border border-dark-border';
|
||||||
case 'low':
|
case 'low':
|
||||||
return 'bg-green-100 text-green-800';
|
return 'bg-green-900/30 text-green-400 border border-green-700';
|
||||||
default:
|
default:
|
||||||
return 'bg-gray-100 text-gray-800';
|
return 'bg-dark-card text-dark-text-muted border border-dark-border';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="text-gray-500">Загрузка...</div>
|
<div className="text-dark-text-muted">Загрузка...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -126,23 +126,23 @@ export default function Tasks() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Очередь задач</h1>
|
<h1 className="text-3xl font-bold text-dark-text-primary">Очередь задач</h1>
|
||||||
<p className="text-gray-600 mt-1">
|
<p className="text-dark-text-secondary mt-1">
|
||||||
Мониторинг и управление задачами на review
|
Мониторинг и управление задачами на review
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Worker Status */}
|
{/* Worker Status */}
|
||||||
{workerStatus && (
|
{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 justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<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'}`} />
|
<div className={`w-3 h-3 rounded-full ${workerStatus.running ? 'bg-green-500 animate-pulse' : 'bg-dark-text-muted'}`} />
|
||||||
<span className="font-medium">
|
<span className="font-medium text-dark-text-primary">
|
||||||
{workerStatus.running ? '🚀 Worker активен' : '⏹️ Worker остановлен'}
|
{workerStatus.running ? '🚀 Worker активен' : '⏹️ Worker остановлен'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-dark-text-secondary">
|
||||||
{workerStatus.current_task_id && (
|
{workerStatus.current_task_id && (
|
||||||
<span>Обрабатывается задача #{workerStatus.current_task_id}</span>
|
<span>Обрабатывается задача #{workerStatus.current_task_id}</span>
|
||||||
)}
|
)}
|
||||||
@ -158,49 +158,49 @@ export default function Tasks() {
|
|||||||
{tasksData && (
|
{tasksData && (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
<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 === undefined ? 'ring-2 ring-indigo-500' : ''
|
statusFilter === undefined ? 'ring-2 ring-blue-500' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setStatusFilter(undefined)}
|
onClick={() => setStatusFilter(undefined)}
|
||||||
>
|
>
|
||||||
<div className="text-2xl font-bold text-gray-900">{tasksData.total}</div>
|
<div className="text-2xl font-bold text-dark-text-primary">{tasksData.total}</div>
|
||||||
<div className="text-sm text-gray-600">Всего</div>
|
<div className="text-sm text-dark-text-secondary">Всего</div>
|
||||||
</div>
|
</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' : ''
|
statusFilter === 'pending' ? 'ring-2 ring-yellow-500' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setStatusFilter('pending')}
|
onClick={() => setStatusFilter('pending')}
|
||||||
>
|
>
|
||||||
<div className="text-2xl font-bold text-yellow-600">{tasksData.pending}</div>
|
<div className="text-2xl font-bold text-yellow-400">{tasksData.pending}</div>
|
||||||
<div className="text-sm text-gray-600">Ожидает</div>
|
<div className="text-sm text-dark-text-secondary">Ожидает</div>
|
||||||
</div>
|
</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' : ''
|
statusFilter === 'in_progress' ? 'ring-2 ring-blue-500' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setStatusFilter('in_progress')}
|
onClick={() => setStatusFilter('in_progress')}
|
||||||
>
|
>
|
||||||
<div className="text-2xl font-bold text-blue-600">{tasksData.in_progress}</div>
|
<div className="text-2xl font-bold text-blue-400">{tasksData.in_progress}</div>
|
||||||
<div className="text-sm text-gray-600">Выполняется</div>
|
<div className="text-sm text-dark-text-secondary">Выполняется</div>
|
||||||
</div>
|
</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' : ''
|
statusFilter === 'completed' ? 'ring-2 ring-green-500' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setStatusFilter('completed')}
|
onClick={() => setStatusFilter('completed')}
|
||||||
>
|
>
|
||||||
<div className="text-2xl font-bold text-green-600">{tasksData.completed}</div>
|
<div className="text-2xl font-bold text-green-400">{tasksData.completed}</div>
|
||||||
<div className="text-sm text-gray-600">Завершено</div>
|
<div className="text-sm text-dark-text-secondary">Завершено</div>
|
||||||
</div>
|
</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' : ''
|
statusFilter === 'failed' ? 'ring-2 ring-red-500' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setStatusFilter('failed')}
|
onClick={() => setStatusFilter('failed')}
|
||||||
>
|
>
|
||||||
<div className="text-2xl font-bold text-red-600">{tasksData.failed}</div>
|
<div className="text-2xl font-bold text-red-400">{tasksData.failed}</div>
|
||||||
<div className="text-sm text-gray-600">Ошибок</div>
|
<div className="text-sm text-dark-text-secondary">Ошибок</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -210,12 +210,12 @@ export default function Tasks() {
|
|||||||
{tasksData?.items.map((task) => (
|
{tasksData?.items.map((task) => (
|
||||||
<div
|
<div
|
||||||
key={task.id}
|
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 items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-3">
|
<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}
|
#{task.id}
|
||||||
</span>
|
</span>
|
||||||
<span className={`px-2 py-1 rounded text-xs font-medium ${getStatusColor(task.status)}`}>
|
<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="mt-2 space-y-1">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="text-gray-600">PR:</span>{' '}
|
<span className="text-dark-text-secondary">PR:</span>{' '}
|
||||||
<span className="font-medium">#{task.pr_number}</span>{' '}
|
<span className="font-medium text-dark-text-primary">#{task.pr_number}</span>{' '}
|
||||||
<span className="text-gray-700">{task.pr_title}</span>
|
<span className="text-dark-text-primary">{task.pr_title}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 text-xs text-gray-500">
|
<div className="flex gap-4 text-xs text-dark-text-muted">
|
||||||
<span>
|
<span>
|
||||||
Создано: {formatDistanceToNow(new Date(task.created_at), { addSuffix: true, locale: ru })}
|
Создано: {formatDistanceToNow(new Date(task.created_at), { addSuffix: true, locale: ru })}
|
||||||
</span>
|
</span>
|
||||||
@ -252,13 +252,13 @@ export default function Tasks() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{task.error_message && (
|
{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}
|
<strong>Ошибка:</strong> {task.error_message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{task.retry_count > 0 && (
|
{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}
|
Попыток: {task.retry_count} / {task.max_retries}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -270,7 +270,7 @@ export default function Tasks() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleRetry(task.id)}
|
onClick={() => handleRetry(task.id)}
|
||||||
disabled={retryMutation.isPending}
|
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>
|
</button>
|
||||||
@ -279,7 +279,7 @@ export default function Tasks() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(task.id)}
|
onClick={() => handleDelete(task.id)}
|
||||||
disabled={deleteMutation.isPending}
|
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>
|
</button>
|
||||||
@ -291,8 +291,8 @@ export default function Tasks() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tasksData?.items.length === 0 && (
|
{tasksData?.items.length === 0 && (
|
||||||
<div className="text-center py-12 bg-gray-50 rounded-lg">
|
<div className="text-center py-12 bg-dark-card rounded-lg border border-dark-border">
|
||||||
<p className="text-gray-500">
|
<p className="text-dark-text-muted">
|
||||||
{statusFilter ? `Нет задач со статусом "${statusFilter}"` : 'Нет задач в очереди'}
|
{statusFilter ? `Нет задач со статусом "${statusFilter}"` : 'Нет задач в очереди'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -305,7 +305,7 @@ export default function Tasks() {
|
|||||||
title={modalMessage.includes('❌') ? 'Ошибка' : modalMessage.includes('✅') ? 'Успешно' : 'Уведомление'}
|
title={modalMessage.includes('❌') ? 'Ошибка' : modalMessage.includes('✅') ? 'Успешно' : 'Уведомление'}
|
||||||
type={modalMessage.includes('❌') ? 'error' : modalMessage.includes('✅') ? 'success' : 'info'}
|
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>
|
</Modal>
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
isOpen={showConfirm}
|
isOpen={showConfirm}
|
||||||
|
|||||||
130
start.bat
130
start.bat
@ -1,100 +1,78 @@
|
|||||||
@echo off
|
@echo off
|
||||||
REM Единый скрипт запуска AI Code Review Platform
|
REM AI Review - Build & Start
|
||||||
|
|
||||||
echo.
|
echo ================================
|
||||||
echo ========================================
|
echo AI Review - Starting
|
||||||
echo AI Code Review Platform - Запуск
|
echo ================================
|
||||||
echo ========================================
|
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
REM 1. Проверка Node.js
|
REM 1. Build Frontend
|
||||||
echo [STEP 1/7] Проверка Node.js...
|
echo [1/3] Building frontend...
|
||||||
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...
|
|
||||||
cd frontend
|
cd frontend
|
||||||
|
|
||||||
if not exist "node_modules\" (
|
if not exist "node_modules\" (
|
||||||
echo Установка npm пакетов...
|
echo Installing npm packages...
|
||||||
call npm install
|
call npm install
|
||||||
) else (
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
echo node_modules уже существует, пропускаем...
|
echo [ERROR] npm install failed
|
||||||
|
cd ..
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
)
|
)
|
||||||
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
|
|
||||||
|
|
||||||
|
echo Building...
|
||||||
call npm run build
|
call npm run build
|
||||||
echo [OK] Frontend собран в backend/public
|
|
||||||
echo.
|
|
||||||
|
|
||||||
REM 5. Установка зависимостей backend
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
cd ..\backend
|
echo [ERROR] Frontend build failed
|
||||||
echo [STEP 5/7] Установка зависимостей backend...
|
cd ..
|
||||||
if not exist "venv\" (
|
pause
|
||||||
echo Создание виртуального окружения...
|
exit /b 1
|
||||||
python -m venv venv
|
|
||||||
)
|
)
|
||||||
|
|
||||||
REM Активация venv
|
echo [OK] Frontend built to backend\public
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
REM 2. Setup Backend
|
||||||
|
echo.
|
||||||
|
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
|
call venv\Scripts\activate.bat
|
||||||
|
|
||||||
REM Установка зависимостей
|
echo Installing dependencies...
|
||||||
pip install -r requirements.txt
|
pip install -q -r requirements.txt
|
||||||
echo [OK] Зависимости backend установлены
|
|
||||||
echo.
|
|
||||||
|
|
||||||
REM 6. Проверка .env
|
if %ERRORLEVEL% NEQ 0 (
|
||||||
echo [STEP 6/7] Проверка конфигурации...
|
echo [ERROR] Failed to install dependencies
|
||||||
if not exist ".env" (
|
cd ..
|
||||||
echo [WARNING] Файл .env не найден!
|
pause
|
||||||
if exist ".env.example" (
|
exit /b 1
|
||||||
echo Создаем .env из примера...
|
|
||||||
copy .env.example .env
|
|
||||||
echo [OK] Создан .env файл
|
|
||||||
echo [WARNING] ВАЖНО: Отредактируйте .env и добавьте необходимые токены!
|
|
||||||
) else (
|
|
||||||
echo [ERROR] .env.example не найден!
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
echo.
|
|
||||||
|
|
||||||
REM 7. Запуск backend
|
REM 3. Start Backend
|
||||||
echo [STEP 7/7] Запуск сервера...
|
|
||||||
echo ========================================
|
|
||||||
echo.
|
echo.
|
||||||
echo Backend: http://localhost:8000
|
echo [3/3] Starting server...
|
||||||
echo Frontend: http://localhost:8000
|
echo ================================
|
||||||
|
echo.
|
||||||
|
echo URL: http://localhost:8000
|
||||||
echo API Docs: http://localhost:8000/docs
|
echo API Docs: http://localhost:8000/docs
|
||||||
echo.
|
echo.
|
||||||
echo Для остановки нажмите Ctrl+C
|
echo Press Ctrl+C to stop
|
||||||
|
echo ================================
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
|
|||||||
108
start.sh
108
start.sh
@ -1,104 +1,54 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Единый скрипт запуска AI Code Review Platform
|
# AI Review - Build & Start
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
echo "🚀 AI Code Review Platform - Запуск"
|
echo "================================"
|
||||||
echo "===================================="
|
echo "AI Review - Starting"
|
||||||
|
echo "================================"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Цвета для вывода
|
# 1. Build Frontend
|
||||||
GREEN='\033[0;32m'
|
echo "[1/3] Building frontend..."
|
||||||
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}"
|
|
||||||
cd frontend
|
cd frontend
|
||||||
|
|
||||||
if [ ! -d "node_modules" ]; then
|
if [ ! -d "node_modules" ]; then
|
||||||
|
echo "Installing npm packages..."
|
||||||
npm install
|
npm install
|
||||||
else
|
|
||||||
echo "node_modules уже существует, пропускаем..."
|
|
||||||
fi
|
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
|
npm run build
|
||||||
echo -e "${GREEN}✅ Frontend собран в backend/public${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 5. Установка зависимостей backend
|
echo "[OK] Frontend built to backend/public"
|
||||||
cd ../backend
|
cd ..
|
||||||
echo -e "${YELLOW}📦 Установка зависимостей backend...${NC}"
|
|
||||||
|
# 2. Setup Backend
|
||||||
|
echo ""
|
||||||
|
echo "[2/3] Setting up backend..."
|
||||||
|
cd backend
|
||||||
|
|
||||||
if [ ! -d "venv" ]; then
|
if [ ! -d "venv" ]; then
|
||||||
echo "Создание виртуального окружения..."
|
echo "Creating venv..."
|
||||||
$PYTHON_CMD -m venv venv
|
python3 -m venv venv
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Активация venv
|
echo "Activating venv..."
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
|
|
||||||
# Установка зависимостей
|
echo "Installing dependencies..."
|
||||||
pip install -r requirements.txt
|
pip install -q -r requirements.txt
|
||||||
echo -e "${GREEN}✅ Зависимости backend установлены${NC}"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 6. Проверка .env
|
# 3. Start Backend
|
||||||
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 ""
|
echo ""
|
||||||
fi
|
echo "[3/3] Starting server..."
|
||||||
|
echo "================================"
|
||||||
# 7. Запуск backend
|
|
||||||
echo -e "${GREEN}🎉 Запуск сервера...${NC}"
|
|
||||||
echo "===================================="
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "📍 Backend: http://localhost:8000"
|
echo "URL: http://localhost:8000"
|
||||||
echo "📍 Frontend: http://localhost:8000"
|
echo "API Docs: http://localhost:8000/docs"
|
||||||
echo "📍 API Docs: http://localhost:8000/docs"
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Для остановки нажмите Ctrl+C"
|
echo "Press Ctrl+C to stop"
|
||||||
|
echo "================================"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
|
|||||||
72
tests/README.md
Normal file
72
tests/README.md
Normal 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) - Детальная документация по тестированию стриминга
|
||||||
|
|
||||||
229
tests/test_langgraph_events.py
Normal file
229
tests/test_langgraph_events.py
Normal 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
137
tests/test_llm_streaming.py
Normal 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
228
tests/test_simple_graph.py
Normal 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())
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user