Compare commits
11 Commits
bb1aedfe2a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f1b75a0f2a | |||
| 0f38d8d588 | |||
| ba83d0cfe4 | |||
| 0a2b139f9b | |||
| 101671ea6e | |||
| 2bfcf5f782 | |||
| b9b0f58de2 | |||
| 177dea2769 | |||
| cba8940005 | |||
| ee2c8de372 | |||
| 3ae3488691 |
@@ -1,16 +0,0 @@
|
|||||||
# ============================================================
|
|
||||||
# ЗАГЛУШКИ — замени значения на реальные перед запуском
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
# BroJS Journal токен (генерируется на platform.brojs.ru, протухает)
|
|
||||||
# Используется и для MCP, и для Inference API (токены учитываются на платформе)
|
|
||||||
# ⚠️ ЗАГЛУШКА #1
|
|
||||||
JOURNAL_TOKEN=YOUR_JOURNAL_TOKEN_HERE
|
|
||||||
|
|
||||||
# Gitea токен (git.brojs.ru → Settings → Applications → Access Tokens)
|
|
||||||
# ⚠️ ЗАГЛУШКА #3
|
|
||||||
GITEA_TOKEN=YOUR_GITEA_TOKEN_HERE
|
|
||||||
|
|
||||||
# Tavily API ключ для web_search (https://tavily.com, опционально)
|
|
||||||
# ⚠️ ЗАГЛУШКА #4 (можно оставить пустым — web_search будет недоступен)
|
|
||||||
TAVILY_API_KEY=
|
|
||||||
@@ -11,6 +11,7 @@ __pycache__/
|
|||||||
*.pyo
|
*.pyo
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
|
venvv/
|
||||||
env/
|
env/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
dist/
|
dist/
|
||||||
@@ -29,3 +30,8 @@ Thumbs.db
|
|||||||
|
|
||||||
# LangGraph / LangSmith
|
# LangGraph / LangSmith
|
||||||
.langgraph_api/
|
.langgraph_api/
|
||||||
|
|
||||||
|
# Deep Agents UI (Next.js)
|
||||||
|
deep-agents-ui/node_modules/
|
||||||
|
deep-agents-ui/.next/
|
||||||
|
deep-agents-ui/.env.local
|
||||||
|
|||||||
@@ -0,0 +1,567 @@
|
|||||||
|
# Архитектура папки `src/agent`
|
||||||
|
|
||||||
|
## Обзор проекта
|
||||||
|
|
||||||
|
Проект **brojs-agent** — это мультиагентная система для автоматизации выполнения заданий курса **KFU-26-1** на платформе BroJS (platform.brojs.ru). Система использует **OpenRouter** (облачный LLM) и координирует несколько специализированных агентов через фреймворк **deepagents** и **LangGraph**.
|
||||||
|
|
||||||
|
### Основная задача
|
||||||
|
1. Получить список незакрытых заданий из журнала BroJS
|
||||||
|
2. Запустить агента для выполнения каждого задания
|
||||||
|
3. Агент пишет код, создаёт репозиторий на Gitea, тестирует, отправляет ответ
|
||||||
|
4. При отклонении (пересдача) — клонирует код, исправляет по замечаниям, пересдаёт
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура файлов
|
||||||
|
|
||||||
|
```
|
||||||
|
src/agent/
|
||||||
|
├── agent.py # 🎯 Инициализация всех агентов (главный орк, ДЗ, пересдача)
|
||||||
|
├── constants.py # ⚙️ Константы: ID курса, пути, Gitea-параметры
|
||||||
|
├── llm.py # 🧠 Инициализация LLM через OpenRouter
|
||||||
|
├── mcp_client.py # 📡 Загрузка инструментов Journal через MCP
|
||||||
|
├── gitea_tools.py # 🔧 REST-инструменты для git.brojs.ru
|
||||||
|
├── tools.py # 🌐 Git и веб-инструменты
|
||||||
|
├── prompts.py # 📝 Системные промпты для всех агентов
|
||||||
|
├── subagents.py # 🤖 Спецификации субагентов
|
||||||
|
├── runner_tools.py # ⚙️ Инструменты запуска заданий
|
||||||
|
├── solve_tools.py # 💻 Инструменты для решения (генерация кода, валидация)
|
||||||
|
├── solve_prompts.py # 📄 Промпты для решения заданий
|
||||||
|
├── middlewares/ # 🛡️ Middleware для обработки ошибок и валидации
|
||||||
|
│ ├── retry_on_rate_limit.py # Retry при ошибках 429
|
||||||
|
│ ├── sanitize_tool_calls.py # Фильтрация незнакомых инструментов
|
||||||
|
│ └── validate_journal_workflow.py # Валидация workflow Journal
|
||||||
|
├── graph/ # 📊 LangGraph pipeline
|
||||||
|
│ ├── pipeline.py # Основной pipeline последовательного выполнения
|
||||||
|
│ └── __init__.py
|
||||||
|
├── agent_workspace/ # 💾 Рабочая папка агента
|
||||||
|
│ ├── AGENTS.md # Память агента между запусками
|
||||||
|
│ └── large_tool_results/ # Кэш больших результатов
|
||||||
|
└── __pycache__/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Компоненты системы
|
||||||
|
|
||||||
|
### 1️⃣ **LLM (Большая языковая модель)**
|
||||||
|
**Файл:** [llm.py](llm.py)
|
||||||
|
|
||||||
|
```python
|
||||||
|
ChatOpenAI(
|
||||||
|
model="openai/gpt-oss-20b:free",
|
||||||
|
base_url="https://openrouter.ai/api/v1",
|
||||||
|
api_key=os.getenv("OPENAI_API_KEY"), # sk-or-v1-...
|
||||||
|
temperature=0.0,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Провайдер:** OpenRouter (облачный сервис)
|
||||||
|
- **Модель:** `gpt-oss-20b:free` (бесплатная, 20B параметров)
|
||||||
|
- **Температура:** 0.0 (детерминированные ответы)
|
||||||
|
- **Зачем OpenRouter вместо Ollama?**
|
||||||
|
- Нет требований к GPU/RAM
|
||||||
|
- Совместим с LangChain из коробки
|
||||||
|
- Работает в CI/CD
|
||||||
|
- Бесплатный тир
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2️⃣ **Konstantes & пути**
|
||||||
|
**Файл:** [constants.py](constants.py)
|
||||||
|
|
||||||
|
Определяет ключевые параметры:
|
||||||
|
|
||||||
|
```python
|
||||||
|
COURSE_ID = "698b49da77cb6d4d2e43ce78" # ID курса KFU-26-1
|
||||||
|
GITEA_OWNER = "dapa46" # Владелец репозиториев на git.brojs.ru
|
||||||
|
AGENT_WORKSPACE_DIR = /path/to/agent_workspace # Рабочая папка
|
||||||
|
AGENTS_MD_VFS_PATH = "/AGENTS.md" # Виртуальный путь памяти
|
||||||
|
```
|
||||||
|
|
||||||
|
Функция `ensure_agents_md_file()` создаёт `AGENTS.md` при первом запуске — файл служит **памятью** агента между сообщениями.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3️⃣ **MCP: Инструменты Journal**
|
||||||
|
**Файл:** [mcp_client.py](mcp_client.py)
|
||||||
|
|
||||||
|
**MCP (Model Context Protocol)** — стандарт для подключения инструментов к LLM.
|
||||||
|
|
||||||
|
#### Как это работает:
|
||||||
|
1. **Загрузка асинхронно** через `MultiServerMCPClient`
|
||||||
|
2. **HTTP transport** к `https://platform.brojs.ru/jrnl-bh/api/mcp`
|
||||||
|
3. **Авторизация** Bearer токеном из `.env` (`JOURNAL_TOKEN=jrnl_...`)
|
||||||
|
4. **Раздает инструменты в две группы:**
|
||||||
|
|
||||||
|
| Группа | Инструменты | Назначение |
|
||||||
|
|--------|-----------|-----------|
|
||||||
|
| **Courses & Lessons** | `courses_list`, `lessons_list` | Получить структуру курса |
|
||||||
|
| **Tasks & Submissions** | `tasks_list`, `task_text`, `task_get`, `task_update_answer`, `task_submit`, `task_submission_status` | Работа с заданиями |
|
||||||
|
|
||||||
|
#### Обработка ошибок:
|
||||||
|
- **Exponential backoff** при ошибке 429 (rate limit): 15, 30, 60, 120, 240 сек
|
||||||
|
- **Персистентный клиент** переиспользуется для всего запуска
|
||||||
|
- **Минимальная пауза** 1.5 сек между вызовами (предотвращает burst)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4️⃣ **Gitea REST API**
|
||||||
|
**Файл:** [gitea_tools.py](gitea_tools.py)
|
||||||
|
|
||||||
|
Инструменты для работы с git.brojs.ru:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@tool()
|
||||||
|
def gitea_list_repos() → str
|
||||||
|
# Список своих репозиториев
|
||||||
|
|
||||||
|
@tool()
|
||||||
|
def gitea_create_repo(name: str) → str
|
||||||
|
# Создать новый репозиторий
|
||||||
|
|
||||||
|
@tool()
|
||||||
|
def gitea_get_file(repo: str, path: str) → str
|
||||||
|
# Прочитать файл из репозитория
|
||||||
|
|
||||||
|
@tool()
|
||||||
|
def gitea_create_file(repo: str, path: str, content: str, message: str) → str
|
||||||
|
# Создать/обновить файл
|
||||||
|
|
||||||
|
@tool()
|
||||||
|
def gitea_delete_repo(repo: str) → str
|
||||||
|
# Удалить репозиторий
|
||||||
|
```
|
||||||
|
|
||||||
|
**Авторизация:** Token через заголовок `Authorization: token {GITEA_TOKEN}`
|
||||||
|
|
||||||
|
**Эндпоинты:** REST API v1: `/api/v1/repos/{owner}/{repo}/...`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5️⃣ **Git & Web tools**
|
||||||
|
**Файл:** [tools.py](tools.py)
|
||||||
|
|
||||||
|
#### Git инструменты (работают в `agent_workspace`):
|
||||||
|
- `git_clone(url)` — клонировать репозиторий
|
||||||
|
- `git_commit(repo, files, message)` — коммитить файлы
|
||||||
|
- `git_push(repo)` — пушить в origin
|
||||||
|
- `git_get_commit_history(repo)` — получить историю
|
||||||
|
|
||||||
|
#### Web инструменты:
|
||||||
|
- `web_search(query)` — поиск через Tavily (если настроен API ключ)
|
||||||
|
- `get_page_content(url)` — загрузить страницу и вернуть Markdown
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6️⃣ **Solve tools: Генерация и валидация кода**
|
||||||
|
**Файл:** [solve_tools.py](solve_tools.py)
|
||||||
|
|
||||||
|
Специализированные инструменты для решения заданий:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@tool()
|
||||||
|
async def generate_code_solution(
|
||||||
|
task_id: str,
|
||||||
|
task_text: str,
|
||||||
|
previous_code: str = ""
|
||||||
|
) → str
|
||||||
|
# Генерирует/улучшает Python-решение
|
||||||
|
# Вызывает субагента с prompt из solve_prompts.py
|
||||||
|
|
||||||
|
@tool()
|
||||||
|
async def validate_teacher_comment(
|
||||||
|
task_id: str,
|
||||||
|
comment: str
|
||||||
|
) → dict
|
||||||
|
# Валидирует замечание преподавателя
|
||||||
|
# Разбирает комментарий на claim'ы, каждый валидирует отдельно
|
||||||
|
```
|
||||||
|
|
||||||
|
**Кэширование:** Результаты кэшируются в `agent_workspace/large_tool_results/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7️⃣ **Агенты (deepagents)**
|
||||||
|
**Файл:** [agent.py](agent.py)
|
||||||
|
|
||||||
|
Система создаёт **3 основных агента:**
|
||||||
|
|
||||||
|
#### 🎯 Главный агент (`agent`)
|
||||||
|
- **Инструменты:** Gitea, Journal, solve_task
|
||||||
|
- **Роль:** Оркестратор — управляет всем процессом
|
||||||
|
- **Субагенты:** web_search, homework_doing, journal_bh_tasks_submissions
|
||||||
|
- **Middleware:** SanitizeToolCallsMiddleware (фильтрует недоступные инструменты)
|
||||||
|
|
||||||
|
#### 💻 Агент ДЗ (`homework_direct_agent`)
|
||||||
|
- **Инструменты:** Git, Gitea, Web, Journal, Solve tools
|
||||||
|
- **Роль:** Выполняет одно задание от начала до конца
|
||||||
|
- **Процесс:**
|
||||||
|
1. Читает текст задания
|
||||||
|
2. Генерирует решение (код)
|
||||||
|
3. Тестирует локально
|
||||||
|
4. Создаёт/обновляет репозиторий
|
||||||
|
5. Пушит решение
|
||||||
|
6. Отправляет ответ в Journal
|
||||||
|
7. Сдаёт на проверку
|
||||||
|
- **Middleware:**
|
||||||
|
- `RetryOnRateLimitMiddleware()` — retry при 429
|
||||||
|
- `SanitizeToolCallsMiddleware` — фильтрация
|
||||||
|
- `ValidateJournalWorkflowMiddleware()` — проверка workflow
|
||||||
|
|
||||||
|
#### 🔄 Агент пересдачи (`rework_agent`)
|
||||||
|
- **Инструменты:** Такие же как homework_direct_agent
|
||||||
|
- **Роль:** При отклонении — клонирует, исправляет, пересдаёт
|
||||||
|
- **Процесс:**
|
||||||
|
1. Клонирует предыдущее решение
|
||||||
|
2. Читает замечания преподавателя
|
||||||
|
3. Исправляет код по замечаниям
|
||||||
|
4. Пушит обновление
|
||||||
|
5. Переотправляет ответ
|
||||||
|
6. Пересдаёт
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8️⃣ **Субагенты (для делегирования)**
|
||||||
|
**Файл:** [subagents.py](subagents.py)
|
||||||
|
|
||||||
|
LangGraph поддерживает делегирование задач субагентам. Определены 3 субагента:
|
||||||
|
|
||||||
|
```python
|
||||||
|
subagent_specs = [
|
||||||
|
{
|
||||||
|
"name": "web_search",
|
||||||
|
"description": "Ищет информацию в интернете",
|
||||||
|
"system_prompt": research_instructions,
|
||||||
|
# tools добавляются в agent.py
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "homework_doing",
|
||||||
|
"description": "Выполняет домашние задания",
|
||||||
|
"system_prompt": homework_doing_instructions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "journal_bh_tasks_submissions",
|
||||||
|
"description": "Работает с Journal: задания и сдачи",
|
||||||
|
"system_prompt": journal_tasks_submissions_instructions,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждый субагент получает свой набор инструментов и промпт через middleware.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9️⃣ **Middleware: фильтрация и обработка ошибок**
|
||||||
|
**Папка:** [middlewares/](middlewares/)
|
||||||
|
|
||||||
|
#### `retry_on_rate_limit.py`
|
||||||
|
Перехватывает ошибки 429 и retry'т с exponential backoff.
|
||||||
|
|
||||||
|
```python
|
||||||
|
class RetryOnRateLimitMiddleware:
|
||||||
|
async def before_tool_call(tool, input):
|
||||||
|
# Ищет 429 в предыдущих ошибках
|
||||||
|
if rate_limit_error:
|
||||||
|
await asyncio.sleep(backoff)
|
||||||
|
return retry_tool_call()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `sanitize_tool_calls.py`
|
||||||
|
Фильтрует вызовы инструментов — дозволяет только известные.
|
||||||
|
|
||||||
|
```python
|
||||||
|
class SanitizeToolCallsMiddleware:
|
||||||
|
def __init__(self, known_tools: set[str]):
|
||||||
|
self.known_tools = known_tools
|
||||||
|
|
||||||
|
async def before_tool_call(tool, input):
|
||||||
|
if tool.name not in self.known_tools:
|
||||||
|
raise ValueError(f"Unknown tool: {tool.name}")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `validate_journal_workflow.py`
|
||||||
|
Проверяет корректность workflow при работе с Journal:
|
||||||
|
- После получения задания → обновить ответ
|
||||||
|
- Перед сдачей → проверить статус
|
||||||
|
- После сдачи → подтвердить
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔟 **LangGraph Pipeline**
|
||||||
|
**Файл:** [graph/pipeline.py](graph/pipeline.py)
|
||||||
|
|
||||||
|
**LangGraph** — это граф для управления состоянием и последовательностью выполнения.
|
||||||
|
|
||||||
|
#### Состояние (`PipelineState`):
|
||||||
|
```python
|
||||||
|
class PipelineState(TypedDict):
|
||||||
|
tasks: list[TaskInfo] # Список всех заданий
|
||||||
|
current_index: int # Индекс текущего задания
|
||||||
|
results: list[dict] # Результаты выполнения
|
||||||
|
errors: list[str] # Ошибки
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Узлы графа:
|
||||||
|
1. **`fetch_tasks`** → Получить список незакрытых заданий из Journal
|
||||||
|
2. **`process_task`** → Запустить `homework_direct_agent` на текущее задание
|
||||||
|
3. **`check_result`** → Проверить результат (успех/ошибка)
|
||||||
|
4. **`next_or_done`** → Перейти к следующему заданию или завершить
|
||||||
|
|
||||||
|
#### Переходы:
|
||||||
|
```
|
||||||
|
START → fetch_tasks → process_task → check_result → (success) → next_or_done
|
||||||
|
→ (error) → next_or_done
|
||||||
|
→ (need_rework) → run_rework_agent → next_or_done
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1️⃣1️⃣ **Промпты (системные инструкции)**
|
||||||
|
**Файл:** [prompts.py](prompts.py)
|
||||||
|
|
||||||
|
Содержит системные промпты для каждого агента:
|
||||||
|
|
||||||
|
#### `main_agent_instructions`
|
||||||
|
Инструкции для главного оркестратора:
|
||||||
|
- Управляет процессом в целом
|
||||||
|
- Делегирует задачи субагентам
|
||||||
|
- Координирует работу
|
||||||
|
|
||||||
|
#### `homework_doing_instructions`
|
||||||
|
Инструкции для агента выполнения ДЗ:
|
||||||
|
- Как читать задание
|
||||||
|
- Как генерировать код
|
||||||
|
- Как тестировать
|
||||||
|
- Как создавать репозиторий
|
||||||
|
- Как отправлять ответ
|
||||||
|
|
||||||
|
#### `rework_instructions`
|
||||||
|
Инструкции для агента пересдачи:
|
||||||
|
- Как интерпретировать замечания
|
||||||
|
- Как исправлять код
|
||||||
|
- Как пересдавать
|
||||||
|
|
||||||
|
#### `research_instructions`
|
||||||
|
Инструкции для веб-поиска:
|
||||||
|
- Искать только из реально открытых страниц
|
||||||
|
- Не выдумывать факты
|
||||||
|
- Подтверждать URL
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Поток выполнения
|
||||||
|
|
||||||
|
### 📊 Общий сценарий
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Пользователь запускает pipeline.py
|
||||||
|
↓
|
||||||
|
2. pipeline.fetch_tasks()
|
||||||
|
→ Запрашивает Journal: "Дай мне все незакрытые задания курса KFU-26-1"
|
||||||
|
→ Получает список TaskInfo (id, title, status)
|
||||||
|
↓
|
||||||
|
3. Для каждого задания (while current_index < len(tasks)):
|
||||||
|
↓
|
||||||
|
4. pipeline.process_task()
|
||||||
|
→ Вызывает homework_direct_agent.invoke({
|
||||||
|
"messages": [HumanMessage("Выполни задание XYZ")]
|
||||||
|
})
|
||||||
|
↓
|
||||||
|
5. homework_direct_agent работает:
|
||||||
|
|
||||||
|
5a. Читает текст задания через task_text()
|
||||||
|
5b. Вызывает generate_code_solution() → получает Python-код
|
||||||
|
5c. Тестирует код локально (git_clone → test.py → git_push)
|
||||||
|
5d. Обновляет ответ в Journal через task_update_answer()
|
||||||
|
5e. Отправляет на проверку через task_submit()
|
||||||
|
↓
|
||||||
|
6. pipeline.check_result()
|
||||||
|
→ Проверяет: успех? ошибка? нужна пересдача?
|
||||||
|
↓
|
||||||
|
7. Если успех → перейти к следующему заданию
|
||||||
|
Если ошибка → залогировать и перейти
|
||||||
|
Если пересдача → запустить rework_agent
|
||||||
|
↓
|
||||||
|
8. pipeline.next_or_done()
|
||||||
|
→ current_index += 1
|
||||||
|
→ Если есть ещё задания → goto 4
|
||||||
|
→ Иначе → завершить, вернуть results
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Интеграции
|
||||||
|
|
||||||
|
### 🔌 Внешние сервисы
|
||||||
|
|
||||||
|
| Сервис | Назначение | Авторизация | Endpoint |
|
||||||
|
|--------|-----------|-----------|----------|
|
||||||
|
| **OpenRouter** | LLM (генерация кода) | `OPENAI_API_KEY=sk-or-v1-...` | `https://openrouter.ai/api/v1` |
|
||||||
|
| **BroJS Journal** | Задания и сдачи | `JOURNAL_TOKEN=jrnl_...` | `https://platform.brojs.ru/jrnl-bh/api/mcp` |
|
||||||
|
| **Gitea (git.brojs.ru)** | Хранилище кода | `GITEA_TOKEN=...` | `https://git.brojs.ru/api/v1` |
|
||||||
|
| **Tavily** | Веб-поиск (опционально) | `TAVILY_API_KEY=tvly-...` | `https://api.tavily.com` |
|
||||||
|
|
||||||
|
### 🛠️ Локальные компоненты
|
||||||
|
|
||||||
|
- **agent_workspace/** — кэш результатов, память агента (AGENTS.md)
|
||||||
|
- **Python subprocess** — выполнение git команд, тестирование кода
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Конфигурация (`.env`)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Обязательные
|
||||||
|
JOURNAL_TOKEN=jrnl_8b251e8e5b345e310269a697968abecad08043a2a30f07229cefe488e59fb951
|
||||||
|
GITEA_TOKEN=f5892316061c124f447710bb242f2f90c565a411
|
||||||
|
OPENAI_API_KEY=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
|
||||||
|
|
||||||
|
# Опциональные
|
||||||
|
TAVILY_API_KEY=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 # для web_search
|
||||||
|
```
|
||||||
|
|
||||||
|
**Где получить:**
|
||||||
|
- `JOURNAL_TOKEN` → platform.brojs.ru → Settings → Access Tokens
|
||||||
|
- `GITEA_TOKEN` → git.brojs.ru → Settings → Applications → Access Tokens
|
||||||
|
- `OPENAI_API_KEY` → openrouter.ai → API Keys (формат `sk-or-v1-...`)
|
||||||
|
- `TAVILY_API_KEY` → tavily.com → API Keys
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ключевые паттерны
|
||||||
|
|
||||||
|
### 🎯 Паттерн: Tool Middleware Pipeline
|
||||||
|
|
||||||
|
```python
|
||||||
|
# В deepagents каждый вызов инструмента проходит через цепь middleware:
|
||||||
|
|
||||||
|
инструмент → middleware1.before → middleware2.before → ... → ИСПОЛНЕНИЕ
|
||||||
|
↓
|
||||||
|
результат ← middleware1.after ← middleware2.after ← ... ← результат
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔄 Паттерн: Субагент как инструмент
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Главный агент не делает работу сам, а вызывает:
|
||||||
|
agent.invoke()
|
||||||
|
→ Видит "нужно выполнить ДЗ"
|
||||||
|
→ Вызывает инструмент: homework_doing (это субагент)
|
||||||
|
→ Субагент имеет свой LLM, свой prompt, свои инструменты
|
||||||
|
→ Возвращает результат главному агенту
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📝 Паттерн: Виртуальная файловая система (VFS)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Агент видит файловую систему как:
|
||||||
|
/AGENTS.md # памятью агента (бинд к agent_workspace/AGENTS.md)
|
||||||
|
/skills/... # bundled skills (бинд к src/agent/skills/)
|
||||||
|
~/project/... # рабочая папка (бинд к agent_workspace/)
|
||||||
|
```
|
||||||
|
|
||||||
|
Это позволяет агенту иметь консистентный вид файловой системы, хотя на самом деле это виртуальная проекция.
|
||||||
|
|
||||||
|
### 💾 Паттерн: Асинхронная загрузка MCP
|
||||||
|
|
||||||
|
```python
|
||||||
|
# MCP может быть долгой операцией (сетевой запрос)
|
||||||
|
# Решение: asyncio.run() в отдельном потоке при наличии event loop
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.get_running_loop() # есть ли event loop?
|
||||||
|
# Да → запустить в ThreadPoolExecutor
|
||||||
|
with ThreadPoolExecutor() as pool:
|
||||||
|
result = pool.submit(lambda: asyncio.run(fetch())).result()
|
||||||
|
except RuntimeError:
|
||||||
|
# Нет → просто asyncio.run()
|
||||||
|
result = asyncio.run(fetch())
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Отладка
|
||||||
|
|
||||||
|
### 📋 Логирование
|
||||||
|
|
||||||
|
Система выводит логи при инициализации:
|
||||||
|
```
|
||||||
|
=== Загружено: journal=X, gitea=5, git=5 ===
|
||||||
|
```
|
||||||
|
|
||||||
|
При ошибке MCP:
|
||||||
|
```
|
||||||
|
MCP 'journal-bh-professor': не удалось загрузить инструменты — ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🧪 Тестирование компонентов
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Импорт отдельного агента
|
||||||
|
from src.agent.agent import homework_direct_agent
|
||||||
|
|
||||||
|
# Запуск на тестовом задании
|
||||||
|
result = homework_direct_agent.invoke({
|
||||||
|
"messages": [HumanMessage("Выполни задание ...")]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔍 Проверка инструментов
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Какие инструменты есть у агента?
|
||||||
|
print(homework_direct_agent.tools)
|
||||||
|
|
||||||
|
# Какие middleware установлены?
|
||||||
|
# Нужно смотреть в create_deep_agent(middleware=[...])
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Возможные ошибки и решения
|
||||||
|
|
||||||
|
| Ошибка | Причина | Решение |
|
||||||
|
|--------|---------|---------|
|
||||||
|
| `ImportError: No module named 'src.agent.agent'` | Неправильный PYTHONPATH | `cd /path/to/project && python -m src.agent.graph.pipeline` |
|
||||||
|
| `MCP journal: не удалось загрузить` | Токен неправильный/устарел | Сгенерировать новый `JOURNAL_TOKEN` на platform.brojs.ru |
|
||||||
|
| `429 Too Many Requests` | Rate limit OpenRouter | Middleware автоматически retry'т с backoff |
|
||||||
|
| `gitea_create_repo: 401 Unauthorized` | Неправильный Gitea токен | Проверить `GITEA_TOKEN` в `.env` |
|
||||||
|
| `generate_code_solution timeout` | Генерация долгая | Увеличить timeout в LLM конфиге |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Резюме: Как это работает вместе
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ PIPELINE (LangGraph) │
|
||||||
|
│ Последовательно: fetch_tasks → process_task → check │
|
||||||
|
└──────────────────────┬──────────────────────────────────┘
|
||||||
|
│
|
||||||
|
├─→ JOURNAL (MCP)
|
||||||
|
│ Получить задания, отправить ответы
|
||||||
|
│
|
||||||
|
├─→ HOMEWORK_DIRECT_AGENT
|
||||||
|
│ ├─ generate_code_solution (LLM)
|
||||||
|
│ ├─ gitea_* (REST API)
|
||||||
|
│ ├─ git_* (subprocess)
|
||||||
|
│ └─ task_submit (Journal)
|
||||||
|
│
|
||||||
|
├─→ REWORK_AGENT
|
||||||
|
│ └─ Аналогично, но для пересдачи
|
||||||
|
│
|
||||||
|
└─→ MIDDLEWARES
|
||||||
|
├─ SanitizeToolCallsMiddleware
|
||||||
|
├─ RetryOnRateLimitMiddleware
|
||||||
|
└─ ValidateJournalWorkflowMiddleware
|
||||||
|
|
||||||
|
┌─────────────────────┐
|
||||||
|
│ OpenRouter LLM │
|
||||||
|
│ gpt-oss-20b:free │
|
||||||
|
└─────────────────────┘
|
||||||
|
(Генерация кода и рассуждения)
|
||||||
|
```
|
||||||
|
|
||||||
|
Система автоматизирует весь цикл выполнения и сдачи заданий, обрабатывая ошибки, пересдачи и координируя несколько специализированных агентов через единый фреймворк.
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ AI-агент для автоматического выполнения зад
|
|||||||
|
|
||||||
1. Читает незакрытые задания из журнала через BroJS MCP
|
1. Читает незакрытые задания из журнала через BroJS MCP
|
||||||
2. Пишет Python-решение для каждого задания
|
2. Пишет Python-решение для каждого задания
|
||||||
3. Создаёт репозиторий на `git.brojs.ru/glevelll/task-<id>`
|
3. Создаёт репозиторий на `git.brojs.ru/<yourName>/task-<id>`
|
||||||
4. Коммитит решение через Gitea API
|
4. Коммитит решение через Gitea API
|
||||||
5. Отправляет ссылку на репозиторий как ответ
|
5. Отправляет ссылку на репозиторий как ответ
|
||||||
6. Сдаёт задание на проверку
|
6. Сдаёт задание на проверку
|
||||||
@@ -95,7 +95,39 @@ asyncio.run(main())
|
|||||||
"
|
"
|
||||||
```
|
```
|
||||||
|
|
||||||
### LangGraph dev server (UI в браузере)
|
### Deep Agents UI (рекомендуется)
|
||||||
|
|
||||||
|
Веб-интерфейс на базе [deep-agents-ui](https://github.com/langchain-ai/deep-agents-ui): чат, треды, просмотр файлов агента, вызовы инструментов.
|
||||||
|
|
||||||
|
**Требования:** Node.js 20+, `.env` с ключами API.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Запустить LangGraph API + UI одной командой (два окна PowerShell)
|
||||||
|
.\scripts\start_agent_ui.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Или по отдельности:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Терминал 1 — LangGraph API (порт 2024)
|
||||||
|
.\scripts\start_langgraph.ps1
|
||||||
|
|
||||||
|
# Терминал 2 — Deep Agents UI (порт 3000)
|
||||||
|
.\scripts\start_deep_ui.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Откройте **http://localhost:3000**. Настройки подставляются из `deep-agents-ui/.env.local`:
|
||||||
|
|
||||||
|
| Параметр | Значение |
|
||||||
|
|----------|----------|
|
||||||
|
| Deployment URL | `http://127.0.0.1:2024` |
|
||||||
|
| Assistant ID | `agent` |
|
||||||
|
|
||||||
|
Для пакетного пайплайна по всем заданиям смените Assistant ID на `pipeline` в Settings.
|
||||||
|
|
||||||
|
Первый запуск UI: `cd deep-agents-ui && npm install` (если скрипт не сделал это сам).
|
||||||
|
|
||||||
|
### LangGraph dev server (Studio)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Установить langgraph-cli
|
# Установить langgraph-cli
|
||||||
@@ -138,6 +170,11 @@ brojs-agent/
|
|||||||
├── pyproject.toml
|
├── pyproject.toml
|
||||||
├── langgraph.json
|
├── langgraph.json
|
||||||
├── agent.py # точка входа для langgraph dev
|
├── agent.py # точка входа для langgraph dev
|
||||||
|
├── deep-agents-ui/ # UI на базе langchain-ai/deep-agents-ui
|
||||||
|
├── scripts/
|
||||||
|
│ ├── start_agent_ui.ps1 # запуск API + UI
|
||||||
|
│ ├── start_langgraph.ps1 # только LangGraph API
|
||||||
|
│ └── start_deep_ui.ps1 # только Next.js UI
|
||||||
└── src/
|
└── src/
|
||||||
└── agent/
|
└── agent/
|
||||||
├── agent.py # создание агентов (main, homework, rework)
|
├── agent.py # создание агентов (main, homework, rework)
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""
|
||||||
|
CLI для brojs-agent.
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
python cli.py solve <task_id> — решить одно задание
|
||||||
|
python cli.py run — решить все todo-задания курса
|
||||||
|
python cli.py status — проверить статусы заданий
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Обходим локальный прокси
|
||||||
|
os.environ["NO_PROXY"] = (
|
||||||
|
"openrouter.ai,platform.brojs.ru,git.brojs.ru,"
|
||||||
|
+ os.environ.get("NO_PROXY", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
# UTF-8 на Windows
|
||||||
|
try:
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
if hasattr(sys.stderr, "reconfigure"):
|
||||||
|
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Команды
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def cmd_solve(task_id: str) -> None:
|
||||||
|
"""Решить одно задание."""
|
||||||
|
from langchain_core.messages import HumanMessage
|
||||||
|
from src.agent.agent import homework_direct_agent
|
||||||
|
|
||||||
|
print(f"[cli] Решаю задание {task_id[:8]}...")
|
||||||
|
result = await homework_direct_agent.ainvoke(
|
||||||
|
{"messages": [HumanMessage(content=f"Реши задание taskId={task_id}")]},
|
||||||
|
{"configurable": {"thread_id": f"cli-{task_id}"}},
|
||||||
|
)
|
||||||
|
final = result["messages"][-1]
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(final.content if hasattr(final, "content") else str(final))
|
||||||
|
print('='*60)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_run() -> None:
|
||||||
|
"""Решить все todo-задания курса (LLM-оркестратор управляет всем)."""
|
||||||
|
import time
|
||||||
|
from langchain_core.messages import HumanMessage
|
||||||
|
from src.agent.agent import agent
|
||||||
|
|
||||||
|
prompt = (
|
||||||
|
"Выполни все задания со статусом todo в курсе KFU-26-1 "
|
||||||
|
"(courseId=698b49da77cb6d4d2e43ce78).\n\n"
|
||||||
|
"Шаги:\n"
|
||||||
|
"1. Получи список заданий через mcp__journal-bh-professor__tasks_list\n"
|
||||||
|
"2. Для каждого задания со статусом todo вызови solve_task(task_id=...)\n"
|
||||||
|
"3. Выполняй строго по одному заданию, жди результата перед следующим\n"
|
||||||
|
"4. Доложи итоговые результаты"
|
||||||
|
)
|
||||||
|
|
||||||
|
print("[cli] Агент-оркестратор запущен (LLM управляет всем)...")
|
||||||
|
result = await agent.ainvoke(
|
||||||
|
{"messages": [HumanMessage(content=prompt)]},
|
||||||
|
{"configurable": {"thread_id": f"run-all-{int(time.time())}"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
final = (result.get("messages") or [{}])[-1]
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(getattr(final, "content", str(final)))
|
||||||
|
print('='*60)
|
||||||
|
|
||||||
|
|
||||||
|
async def cmd_status() -> None:
|
||||||
|
"""Проверить статусы всех заданий."""
|
||||||
|
from src.agent.mcp_client import load_journal_toolsets
|
||||||
|
import json
|
||||||
|
|
||||||
|
STATUS_EMOJI = {
|
||||||
|
"done": "✅",
|
||||||
|
"ready_for_review": "🔍",
|
||||||
|
"in_progress": "🔄",
|
||||||
|
"todo": "📋",
|
||||||
|
"rejected": "❌",
|
||||||
|
}
|
||||||
|
|
||||||
|
print("[cli] Получаю список заданий...")
|
||||||
|
journal = load_journal_toolsets()
|
||||||
|
tools = {t.name: t for t in journal.tasks_submissions_tools}
|
||||||
|
tool = tools.get("mcp__journal-bh-professor__tasks_list") \
|
||||||
|
or next((v for k, v in tools.items() if "tasks_list" in k), None)
|
||||||
|
if not tool:
|
||||||
|
print(f"Ошибка: инструмент tasks_list не найден. Доступны: {list(tools.keys())}")
|
||||||
|
return
|
||||||
|
|
||||||
|
raw = await tool.ainvoke({"courseId": "698b49da77cb6d4d2e43ce78"})
|
||||||
|
text = next((x["text"] for x in raw if x.get("type") == "text"), str(raw)) if isinstance(raw, list) else str(raw)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
items = data.get("tasks", data) if isinstance(data, dict) else data
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
items = []
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
print("Нет данных о заданиях")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"\n{'Статус':<22} {'ID':>10} Название")
|
||||||
|
print("-" * 80)
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for item in items:
|
||||||
|
t = item.get("task", item) if isinstance(item, dict) else {}
|
||||||
|
tid = t.get("id", "")
|
||||||
|
status = item.get("status", "")
|
||||||
|
title = t.get("title", t.get("name", ""))
|
||||||
|
emoji = STATUS_EMOJI.get(status, "❓")
|
||||||
|
print(f" {emoji} {status:<18} {tid[:8]}... {title}")
|
||||||
|
counts[status] = counts.get(status, 0) + 1
|
||||||
|
|
||||||
|
print()
|
||||||
|
for s, n in counts.items():
|
||||||
|
print(f" {STATUS_EMOJI.get(s,'❓')} {s}: {n}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Точка входа
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(__doc__)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
cmd = sys.argv[1].lower()
|
||||||
|
|
||||||
|
if cmd == "solve":
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("Использование: python cli.py solve <task_id>")
|
||||||
|
sys.exit(1)
|
||||||
|
asyncio.run(cmd_solve(sys.argv[2]))
|
||||||
|
|
||||||
|
elif cmd == "run":
|
||||||
|
asyncio.run(cmd_run())
|
||||||
|
|
||||||
|
elif cmd == "status":
|
||||||
|
asyncio.run(cmd_status())
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(__doc__)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
# To get started with Dependabot version updates, you'll need to specify which
|
||||||
|
# package ecosystems to update and where the package manifests are located.
|
||||||
|
# Please see the documentation for all configuration options:
|
||||||
|
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "npm" # See documentation for possible values
|
||||||
|
directory: "/" # Location of package manifests
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
- package-ecosystem: "github-actions" # See documentation for possible values
|
||||||
|
directory: "/" # Location of package manifests
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
# Run formatting on all PRs
|
||||||
|
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch: # Allows triggering the workflow manually in GitHub UI
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
# If another push to the same PR or branch happens while this workflow is still running,
|
||||||
|
# cancel the earlier run in favor of the next run.
|
||||||
|
#
|
||||||
|
# There's no point in testing an outdated version of the code. GitHub only allows
|
||||||
|
# a limited number of job runners to be active at the same time, so it's better to cancel
|
||||||
|
# pointless jobs early so that more useful jobs can run sooner.
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
format:
|
||||||
|
name: Check formatting
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- name: Enable Corepack
|
||||||
|
run: corepack enable
|
||||||
|
- name: Use Node.js 20.x
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 20.x
|
||||||
|
cache: "yarn"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --immutable --mode=skip-build
|
||||||
|
- name: Check formatting
|
||||||
|
run: yarn format:check
|
||||||
|
|
||||||
|
lint:
|
||||||
|
name: Check linting
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- name: Enable Corepack
|
||||||
|
run: corepack enable
|
||||||
|
- name: Use Node.js 20.x
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 20.x
|
||||||
|
cache: "yarn"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --immutable --mode=skip-build
|
||||||
|
- name: Check linting
|
||||||
|
run: yarn run lint
|
||||||
|
|
||||||
|
build:
|
||||||
|
name: Build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- name: Enable Corepack
|
||||||
|
run: corepack enable
|
||||||
|
- name: Use Node.js 20.x
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: 20.x
|
||||||
|
cache: "yarn"
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --immutable --mode=skip-build
|
||||||
|
- name: Build
|
||||||
|
run: yarn build
|
||||||
|
|
||||||
|
readme-spelling:
|
||||||
|
name: Check README spelling
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
|
||||||
|
with:
|
||||||
|
ignore_words_file: .codespellignore
|
||||||
|
path: README.md
|
||||||
|
|
||||||
|
check-spelling:
|
||||||
|
name: Check code spelling
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
|
||||||
|
with:
|
||||||
|
ignore_words_file: .codespellignore
|
||||||
|
path: src
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
name: PR Title Lint
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: read
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened, edited, synchronize]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-pr-title:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Validate PR Title
|
||||||
|
uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
types: |
|
||||||
|
feat
|
||||||
|
fix
|
||||||
|
docs
|
||||||
|
style
|
||||||
|
refactor
|
||||||
|
perf
|
||||||
|
test
|
||||||
|
build
|
||||||
|
ci
|
||||||
|
chore
|
||||||
|
revert
|
||||||
|
release
|
||||||
|
scopes: |
|
||||||
|
shared
|
||||||
|
cli
|
||||||
|
web
|
||||||
|
open-swe
|
||||||
|
docs
|
||||||
|
requireScope: false
|
||||||
|
ignoreLabels: |
|
||||||
|
ignore-lint-pr-title
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
legacy-peer-deps=true
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
20
|
||||||
|
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# production
|
||||||
|
build
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# misc
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
# lock files
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
package-lock.json
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 LangChain
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# 🚀🧠 Deep Agents UI
|
||||||
|
|
||||||
|
[Deep Agents](https://github.com/langchain-ai/deepagents) is a simple, open source agent harness that implements a few generally useful tools, including planning (prior to task execution), computer access (giving the able access to a shell and a filesystem), and sub-agent delegation (isolated task execution). This is a UI for interacting with deepagents.
|
||||||
|
|
||||||
|
## 🚀 Quickstart
|
||||||
|
|
||||||
|
**Install dependencies and run the app**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/langchain-ai/deep-agents-ui.git
|
||||||
|
cd deep-agents-ui
|
||||||
|
yarn install
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deploy a Deep Agent**
|
||||||
|
|
||||||
|
As an example, see our [Deep Agents quickstarts](https://github.com/langchain-ai/deepagents/tree/main/examples) for examples and run the `deep_research` example.
|
||||||
|
|
||||||
|
The `langgraph.json` file has the assistant ID as the key:
|
||||||
|
|
||||||
|
```
|
||||||
|
"graphs": {
|
||||||
|
"research": "./agent.py:agent"
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
Kick off the local LangGraph deployment:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd deepagents-quickstarts/deep_research
|
||||||
|
langgraph dev
|
||||||
|
```
|
||||||
|
|
||||||
|
You will see the local LangGraph deployment log to terminal:
|
||||||
|
|
||||||
|
```
|
||||||
|
╦ ┌─┐┌┐┌┌─┐╔═╗┬─┐┌─┐┌─┐┬ ┬
|
||||||
|
║ ├─┤││││ ┬║ ╦├┬┘├─┤├─┘├─┤
|
||||||
|
╩═╝┴ ┴┘└┘└─┘╚═╝┴└─┴ ┴┴ ┴ ┴
|
||||||
|
|
||||||
|
- 🚀 API: http://127.0.0.1:2024
|
||||||
|
- 🎨 Studio UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024
|
||||||
|
- 📚 API Docs: http://127.0.0.1:2024/docs
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
You can get the Deployment URL and Assistant ID from the terminal output and `langgraph.json` file, respectively:
|
||||||
|
|
||||||
|
- Deployment URL: <http://127.0.1:2024>
|
||||||
|
- Assistant ID: `research`
|
||||||
|
|
||||||
|
**Open Deep Agents UI** at [http://localhost:3000](http://localhost:3000) and input the Deployment URL and Assistant ID:
|
||||||
|
|
||||||
|
- **Deployment URL**: The URL for the LangGraph deployment you are connecting to
|
||||||
|
- **Assistant ID**: The ID of the assistant or agent you want to use
|
||||||
|
- [Optional] **LangSmith API Key**: Your LangSmith API key (format: `lsv2_pt_...`). This may be required for accessing deployed LangGraph applications. You can also provide this via the `NEXT_PUBLIC_LANGSMITH_API_KEY` environment variable.
|
||||||
|
|
||||||
|
**Usage**
|
||||||
|
|
||||||
|
You can interact with the deployment via the chat interface and can edit settings at any time by clicking on the Settings button in the header.
|
||||||
|
|
||||||
|
<img width="2039" height="1495" alt="Screenshot 2025-11-17 at 1 11 27 PM" src="https://github.com/user-attachments/assets/50e1b5f3-a626-4461-9ad9-90347e471e8c" />
|
||||||
|
|
||||||
|
As the deepagent runs, you can see its files in LangGraph state.
|
||||||
|
|
||||||
|
<img width="2039" height="1495" alt="Screenshot 2025-11-17 at 1 11 36 PM" src="https://github.com/user-attachments/assets/86cc6228-5414-4cf0-90f5-d206d30c005e" />
|
||||||
|
|
||||||
|
You can click on any file to view it.
|
||||||
|
|
||||||
|
<img width="2039" height="1495" alt="Screenshot 2025-11-17 at 1 11 40 PM" src="https://github.com/user-attachments/assets/9883677f-e365-428d-b941-992bdbfa79dd" />
|
||||||
|
|
||||||
|
### Optional: Environment Variables
|
||||||
|
|
||||||
|
You can optionally set environment variables instead of using the settings dialog:
|
||||||
|
|
||||||
|
```env
|
||||||
|
NEXT_PUBLIC_LANGSMITH_API_KEY="lsv2_xxxx"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Settings configured in the UI take precedence over environment variables.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
You can run your Deep Agents in Debug Mode, which will execute the agent step by step. This will allow you to re-run the specific steps of the agent. This is intended to be used alongside the optimizer.
|
||||||
|
|
||||||
|
You can also turn off Debug Mode to run the full agent end-to-end.
|
||||||
|
|
||||||
|
### 📚 Resources
|
||||||
|
|
||||||
|
If the term "Deep Agents" is new to you, check out these videos!
|
||||||
|
[What are Deep Agents?](https://www.youtube.com/watch?v=433SmtTc0TA)
|
||||||
|
[Implementing Deep Agents](https://www.youtube.com/watch?v=TTMYJAw5tiA&t=701s)
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "default",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.ts",
|
||||||
|
"css": "src/app/globals.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ["dist", ".next", "node_modules"] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
"react-refresh": reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
"@typescript-eslint/no-explicit-any": 0,
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{ args: "none", argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
"react-refresh/only-export-components": [
|
||||||
|
"warn",
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+10490
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"name": "deep-agents-ui",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --turbopack",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@langchain/core": "^1.1.19",
|
||||||
|
"@langchain/langgraph": "^1.0.2",
|
||||||
|
"@langchain/langgraph-sdk": "^1.0.3",
|
||||||
|
"@radix-ui/colors": "^1.0.0",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.12",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.7",
|
||||||
|
"@types/diff": "^5.0.3",
|
||||||
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
|
"@types/uuid": "^9.0.8",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^1.2.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"diff": "^8.0.3",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
|
"lodash": "^4.18.1",
|
||||||
|
"lucide-react": "^0.539.0",
|
||||||
|
"next": "^16.2.5",
|
||||||
|
"nuqs": "^2.8.8",
|
||||||
|
"react": "19.1.0",
|
||||||
|
"react-dom": "19.1.0",
|
||||||
|
"react-markdown": "^9.0.1",
|
||||||
|
"react-resizable-panels": "^3.0.6",
|
||||||
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
|
"remark-gfm": "^4.0.0",
|
||||||
|
"sass": "^1.99.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"swr": "^2.4.1",
|
||||||
|
"tailwind-merge": "^2.6",
|
||||||
|
"use-stick-to-bottom": "^1.1.1",
|
||||||
|
"uuid": "^9.0.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@headlessui/tailwindcss": "^0.2.2",
|
||||||
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
|
"@tailwindcss/typography": "^0.5.9",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"autoprefixer": "^10.4.24",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"prettier": "^2.8.8",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.3.0",
|
||||||
|
"tailwindcss": "^3.4.4",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.54.0"
|
||||||
|
},
|
||||||
|
"packageManager": "yarn@1.22.22"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
"tailwindcss/nesting": {},
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* @see https://prettier.io/docs/configuration
|
||||||
|
* @type {import("prettier").Config}
|
||||||
|
*/
|
||||||
|
const config = {
|
||||||
|
endOfLine: "auto",
|
||||||
|
singleAttributePerLine: true,
|
||||||
|
plugins: ["prettier-plugin-tailwindcss"],
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,545 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
useState,
|
||||||
|
useRef,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
FormEvent,
|
||||||
|
Fragment,
|
||||||
|
} from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Square,
|
||||||
|
ArrowUp,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
Circle,
|
||||||
|
FileIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { ChatMessage } from "@/app/components/ChatMessage";
|
||||||
|
import type {
|
||||||
|
TodoItem,
|
||||||
|
ToolCall,
|
||||||
|
ActionRequest,
|
||||||
|
ReviewConfig,
|
||||||
|
} from "@/app/types/types";
|
||||||
|
import { Assistant, Message } from "@langchain/langgraph-sdk";
|
||||||
|
import { extractStringFromMessageContent } from "@/app/utils/utils";
|
||||||
|
import { useChatContext } from "@/providers/ChatProvider";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useStickToBottom } from "use-stick-to-bottom";
|
||||||
|
import { FilesPopover } from "@/app/components/TasksFilesSidebar";
|
||||||
|
|
||||||
|
interface ChatInterfaceProps {
|
||||||
|
assistant: Assistant | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusIcon = (status: TodoItem["status"], className?: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case "completed":
|
||||||
|
return (
|
||||||
|
<CheckCircle
|
||||||
|
size={16}
|
||||||
|
className={cn("text-success/80", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "in_progress":
|
||||||
|
return (
|
||||||
|
<Clock
|
||||||
|
size={16}
|
||||||
|
className={cn("text-warning/80", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Circle
|
||||||
|
size={16}
|
||||||
|
className={cn("text-tertiary/70", className)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
|
||||||
|
const [metaOpen, setMetaOpen] = useState<"tasks" | "files" | null>(null);
|
||||||
|
const tasksContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const { scrollRef, contentRef } = useStickToBottom();
|
||||||
|
|
||||||
|
const {
|
||||||
|
stream,
|
||||||
|
messages,
|
||||||
|
todos,
|
||||||
|
files,
|
||||||
|
ui,
|
||||||
|
setFiles,
|
||||||
|
isLoading,
|
||||||
|
isThreadLoading,
|
||||||
|
interrupt,
|
||||||
|
sendMessage,
|
||||||
|
stopStream,
|
||||||
|
resumeInterrupt,
|
||||||
|
} = useChatContext();
|
||||||
|
|
||||||
|
const submitDisabled = isLoading || !assistant;
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(e?: FormEvent) => {
|
||||||
|
if (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
const messageText = input.trim();
|
||||||
|
if (!messageText || isLoading || submitDisabled) return;
|
||||||
|
sendMessage(messageText);
|
||||||
|
setInput("");
|
||||||
|
},
|
||||||
|
[input, isLoading, sendMessage, setInput, submitDisabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (submitDisabled) return;
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSubmit, submitDisabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: can we make this part of the hook?
|
||||||
|
const processedMessages = useMemo(() => {
|
||||||
|
/*
|
||||||
|
1. Loop through all messages
|
||||||
|
2. For each AI message, add the AI message, and any tool calls to the messageMap
|
||||||
|
3. For each tool message, find the corresponding tool call in the messageMap and update the status and output
|
||||||
|
*/
|
||||||
|
const messageMap = new Map<
|
||||||
|
string,
|
||||||
|
{ message: Message; toolCalls: ToolCall[] }
|
||||||
|
>();
|
||||||
|
messages.forEach((message: Message) => {
|
||||||
|
if (message.type === "ai") {
|
||||||
|
const toolCallsInMessage: Array<{
|
||||||
|
id?: string;
|
||||||
|
function?: { name?: string; arguments?: unknown };
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
args?: unknown;
|
||||||
|
input?: unknown;
|
||||||
|
}> = [];
|
||||||
|
if (
|
||||||
|
message.additional_kwargs?.tool_calls &&
|
||||||
|
Array.isArray(message.additional_kwargs.tool_calls)
|
||||||
|
) {
|
||||||
|
toolCallsInMessage.push(...message.additional_kwargs.tool_calls);
|
||||||
|
} else if (message.tool_calls && Array.isArray(message.tool_calls)) {
|
||||||
|
toolCallsInMessage.push(
|
||||||
|
...message.tool_calls.filter(
|
||||||
|
(toolCall: { name?: string }) => toolCall.name !== ""
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else if (Array.isArray(message.content)) {
|
||||||
|
const toolUseBlocks = message.content.filter(
|
||||||
|
(block: { type?: string }) => block.type === "tool_use"
|
||||||
|
);
|
||||||
|
toolCallsInMessage.push(...toolUseBlocks);
|
||||||
|
}
|
||||||
|
const toolCallsWithStatus = toolCallsInMessage.map(
|
||||||
|
(toolCall: {
|
||||||
|
id?: string;
|
||||||
|
function?: { name?: string; arguments?: unknown };
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
args?: unknown;
|
||||||
|
input?: unknown;
|
||||||
|
}) => {
|
||||||
|
const name =
|
||||||
|
toolCall.function?.name ||
|
||||||
|
toolCall.name ||
|
||||||
|
toolCall.type ||
|
||||||
|
"unknown";
|
||||||
|
const args =
|
||||||
|
toolCall.function?.arguments ||
|
||||||
|
toolCall.args ||
|
||||||
|
toolCall.input ||
|
||||||
|
{};
|
||||||
|
return {
|
||||||
|
id: toolCall.id || `tool-${Math.random()}`,
|
||||||
|
name,
|
||||||
|
args,
|
||||||
|
status: interrupt ? "interrupted" : ("pending" as const),
|
||||||
|
} as ToolCall;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
messageMap.set(message.id!, {
|
||||||
|
message,
|
||||||
|
toolCalls: toolCallsWithStatus,
|
||||||
|
});
|
||||||
|
} else if (message.type === "tool") {
|
||||||
|
const toolCallId = message.tool_call_id;
|
||||||
|
if (!toolCallId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [, data] of messageMap.entries()) {
|
||||||
|
const toolCallIndex = data.toolCalls.findIndex(
|
||||||
|
(tc: ToolCall) => tc.id === toolCallId
|
||||||
|
);
|
||||||
|
if (toolCallIndex === -1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
data.toolCalls[toolCallIndex] = {
|
||||||
|
...data.toolCalls[toolCallIndex],
|
||||||
|
status: "completed" as const,
|
||||||
|
result: extractStringFromMessageContent(message),
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (message.type === "human") {
|
||||||
|
messageMap.set(message.id!, {
|
||||||
|
message,
|
||||||
|
toolCalls: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const processedArray = Array.from(messageMap.values());
|
||||||
|
return processedArray.map((data, index) => {
|
||||||
|
const prevMessage = index > 0 ? processedArray[index - 1].message : null;
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
showAvatar: data.message.type !== prevMessage?.type,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [messages, interrupt]);
|
||||||
|
|
||||||
|
const groupedTodos = {
|
||||||
|
in_progress: todos.filter((t) => t.status === "in_progress"),
|
||||||
|
pending: todos.filter((t) => t.status === "pending"),
|
||||||
|
completed: todos.filter((t) => t.status === "completed"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasTasks = todos.length > 0;
|
||||||
|
const hasFiles = Object.keys(files).length > 0;
|
||||||
|
|
||||||
|
// Parse out any action requests or review configs from the interrupt
|
||||||
|
const actionRequestsMap: Map<string, ActionRequest> | null = useMemo(() => {
|
||||||
|
const actionRequests =
|
||||||
|
interrupt?.value && (interrupt.value as any)["action_requests"];
|
||||||
|
if (!actionRequests) return new Map<string, ActionRequest>();
|
||||||
|
return new Map(actionRequests.map((ar: ActionRequest) => [ar.name, ar]));
|
||||||
|
}, [interrupt]);
|
||||||
|
|
||||||
|
const reviewConfigsMap: Map<string, ReviewConfig> | null = useMemo(() => {
|
||||||
|
const reviewConfigs =
|
||||||
|
interrupt?.value && (interrupt.value as any)["review_configs"];
|
||||||
|
if (!reviewConfigs) return new Map<string, ReviewConfig>();
|
||||||
|
return new Map(
|
||||||
|
reviewConfigs.map((rc: ReviewConfig) => [rc.actionName, rc])
|
||||||
|
);
|
||||||
|
}, [interrupt]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain"
|
||||||
|
ref={scrollRef}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mx-auto w-full max-w-[1024px] px-6 pb-6 pt-4"
|
||||||
|
ref={contentRef}
|
||||||
|
>
|
||||||
|
{isThreadLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<p className="text-muted-foreground">Loading...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{processedMessages.map((data, index) => {
|
||||||
|
const messageUi = ui?.filter(
|
||||||
|
(u: any) => u.metadata?.message_id === data.message.id
|
||||||
|
);
|
||||||
|
const isLastMessage = index === processedMessages.length - 1;
|
||||||
|
return (
|
||||||
|
<ChatMessage
|
||||||
|
key={data.message.id}
|
||||||
|
message={data.message}
|
||||||
|
toolCalls={data.toolCalls}
|
||||||
|
isLoading={isLoading}
|
||||||
|
actionRequestsMap={
|
||||||
|
isLastMessage ? actionRequestsMap : undefined
|
||||||
|
}
|
||||||
|
reviewConfigsMap={
|
||||||
|
isLastMessage ? reviewConfigsMap : undefined
|
||||||
|
}
|
||||||
|
ui={messageUi}
|
||||||
|
stream={stream}
|
||||||
|
onResumeInterrupt={resumeInterrupt}
|
||||||
|
graphId={assistant?.graph_id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-shrink-0 bg-background">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mx-4 mb-6 flex flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-background",
|
||||||
|
"mx-auto w-[calc(100%-32px)] max-w-[1024px] transition-colors duration-200 ease-in-out"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{(hasTasks || hasFiles) && (
|
||||||
|
<div className="flex max-h-72 flex-col overflow-y-auto border-b border-border bg-sidebar empty:hidden">
|
||||||
|
{!metaOpen && (
|
||||||
|
<>
|
||||||
|
{(() => {
|
||||||
|
const activeTask = todos.find(
|
||||||
|
(t) => t.status === "in_progress"
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalTasks = todos.length;
|
||||||
|
const remainingTasks =
|
||||||
|
totalTasks - groupedTodos.pending.length;
|
||||||
|
const isCompleted = totalTasks === remainingTasks;
|
||||||
|
|
||||||
|
const tasksTrigger = (() => {
|
||||||
|
if (!hasTasks) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setMetaOpen((prev) =>
|
||||||
|
prev === "tasks" ? null : "tasks"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="grid w-full cursor-pointer grid-cols-[auto_auto_1fr] items-center gap-3 px-[18px] py-3 text-left"
|
||||||
|
aria-expanded={metaOpen === "tasks"}
|
||||||
|
>
|
||||||
|
{(() => {
|
||||||
|
if (isCompleted) {
|
||||||
|
return [
|
||||||
|
<CheckCircle
|
||||||
|
key="icon"
|
||||||
|
size={16}
|
||||||
|
className="text-success/80"
|
||||||
|
/>,
|
||||||
|
<span
|
||||||
|
key="label"
|
||||||
|
className="ml-[1px] min-w-0 truncate text-sm"
|
||||||
|
>
|
||||||
|
All tasks completed
|
||||||
|
</span>,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeTask != null) {
|
||||||
|
return [
|
||||||
|
<div key="icon">
|
||||||
|
{getStatusIcon(activeTask.status)}
|
||||||
|
</div>,
|
||||||
|
<span
|
||||||
|
key="label"
|
||||||
|
className="ml-[1px] min-w-0 truncate text-sm"
|
||||||
|
>
|
||||||
|
Task{" "}
|
||||||
|
{totalTasks - groupedTodos.pending.length} of{" "}
|
||||||
|
{totalTasks}
|
||||||
|
</span>,
|
||||||
|
<span
|
||||||
|
key="content"
|
||||||
|
className="min-w-0 gap-2 truncate text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{activeTask.content}
|
||||||
|
</span>,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
<Circle
|
||||||
|
key="icon"
|
||||||
|
size={16}
|
||||||
|
className="text-tertiary/70"
|
||||||
|
/>,
|
||||||
|
<span
|
||||||
|
key="label"
|
||||||
|
className="ml-[1px] min-w-0 truncate text-sm"
|
||||||
|
>
|
||||||
|
Task {totalTasks - groupedTodos.pending.length}{" "}
|
||||||
|
of {totalTasks}
|
||||||
|
</span>,
|
||||||
|
];
|
||||||
|
})()}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const filesTrigger = (() => {
|
||||||
|
if (!hasFiles) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setMetaOpen((prev) =>
|
||||||
|
prev === "files" ? null : "files"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="flex flex-shrink-0 cursor-pointer items-center gap-2 px-[18px] py-3 text-left text-sm"
|
||||||
|
aria-expanded={metaOpen === "files"}
|
||||||
|
>
|
||||||
|
<FileIcon size={16} />
|
||||||
|
Files (State)
|
||||||
|
<span className="h-4 min-w-4 rounded-full bg-[#2F6868] px-0.5 text-center text-[10px] leading-[16px] text-white">
|
||||||
|
{Object.keys(files).length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-[1fr_auto_auto] items-center">
|
||||||
|
{tasksTrigger}
|
||||||
|
{filesTrigger}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{metaOpen && (
|
||||||
|
<>
|
||||||
|
<div className="sticky top-0 flex items-stretch bg-sidebar text-sm">
|
||||||
|
{hasTasks && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="py-3 pr-4 first:pl-[18px] aria-expanded:font-semibold"
|
||||||
|
onClick={() =>
|
||||||
|
setMetaOpen((prev) =>
|
||||||
|
prev === "tasks" ? null : "tasks"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
aria-expanded={metaOpen === "tasks"}
|
||||||
|
>
|
||||||
|
Tasks
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{hasFiles && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 py-3 pr-4 first:pl-[18px] aria-expanded:font-semibold"
|
||||||
|
onClick={() =>
|
||||||
|
setMetaOpen((prev) =>
|
||||||
|
prev === "files" ? null : "files"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
aria-expanded={metaOpen === "files"}
|
||||||
|
>
|
||||||
|
Files (State)
|
||||||
|
<span className="h-4 min-w-4 rounded-full bg-[#2F6868] px-0.5 text-center text-[10px] leading-[16px] text-white">
|
||||||
|
{Object.keys(files).length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
aria-label="Close"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => setMetaOpen(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={tasksContainerRef}
|
||||||
|
className="px-[18px]"
|
||||||
|
>
|
||||||
|
{metaOpen === "tasks" &&
|
||||||
|
Object.entries(groupedTodos)
|
||||||
|
.filter(([_, todos]) => todos.length > 0)
|
||||||
|
.map(([status, todos]) => (
|
||||||
|
<div
|
||||||
|
key={status}
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
<h3 className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-tertiary">
|
||||||
|
{
|
||||||
|
{
|
||||||
|
pending: "Pending",
|
||||||
|
in_progress: "In Progress",
|
||||||
|
completed: "Completed",
|
||||||
|
}[status]
|
||||||
|
}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-[auto_1fr] gap-3 rounded-sm p-1 pl-0 text-sm">
|
||||||
|
{todos.map((todo, index) => (
|
||||||
|
<Fragment key={`${status}_${todo.id}_${index}`}>
|
||||||
|
{getStatusIcon(todo.status, "mt-0.5")}
|
||||||
|
<span className="break-words text-inherit">
|
||||||
|
{todo.content}
|
||||||
|
</span>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{metaOpen === "files" && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<FilesPopover
|
||||||
|
files={files}
|
||||||
|
setFiles={setFiles}
|
||||||
|
editDisabled={
|
||||||
|
isLoading === true || interrupt !== undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex flex-col"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={isLoading ? "Running..." : "Write your message..."}
|
||||||
|
className="font-inherit field-sizing-content flex-1 resize-none border-0 bg-transparent px-[18px] pb-[13px] pt-[14px] text-sm leading-7 text-primary outline-none placeholder:text-tertiary"
|
||||||
|
rows={1}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between gap-2 p-3">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
type={isLoading ? "button" : "submit"}
|
||||||
|
variant={isLoading ? "destructive" : "default"}
|
||||||
|
onClick={isLoading ? stopStream : handleSubmit}
|
||||||
|
disabled={!isLoading && (submitDisabled || !input.trim())}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Square size={14} />
|
||||||
|
<span>Stop</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ArrowUp size={18} />
|
||||||
|
<span>Send</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ChatInterface.displayName = "ChatInterface";
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo, useState, useCallback } from "react";
|
||||||
|
import { SubAgentIndicator } from "@/app/components/SubAgentIndicator";
|
||||||
|
import { ToolCallBox } from "@/app/components/ToolCallBox";
|
||||||
|
import { MarkdownContent } from "@/app/components/MarkdownContent";
|
||||||
|
import type {
|
||||||
|
SubAgent,
|
||||||
|
ToolCall,
|
||||||
|
ActionRequest,
|
||||||
|
ReviewConfig,
|
||||||
|
} from "@/app/types/types";
|
||||||
|
import { Message } from "@langchain/langgraph-sdk";
|
||||||
|
import {
|
||||||
|
extractSubAgentContent,
|
||||||
|
extractStringFromMessageContent,
|
||||||
|
} from "@/app/utils/utils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ChatMessageProps {
|
||||||
|
message: Message;
|
||||||
|
toolCalls: ToolCall[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
actionRequestsMap?: Map<string, ActionRequest>;
|
||||||
|
reviewConfigsMap?: Map<string, ReviewConfig>;
|
||||||
|
ui?: any[];
|
||||||
|
stream?: any;
|
||||||
|
onResumeInterrupt?: (value: any) => void;
|
||||||
|
graphId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatMessage = React.memo<ChatMessageProps>(
|
||||||
|
({
|
||||||
|
message,
|
||||||
|
toolCalls,
|
||||||
|
isLoading,
|
||||||
|
actionRequestsMap,
|
||||||
|
reviewConfigsMap,
|
||||||
|
ui,
|
||||||
|
stream,
|
||||||
|
onResumeInterrupt,
|
||||||
|
graphId,
|
||||||
|
}) => {
|
||||||
|
const isUser = message.type === "human";
|
||||||
|
const messageContent = extractStringFromMessageContent(message);
|
||||||
|
const hasContent = messageContent && messageContent.trim() !== "";
|
||||||
|
const hasToolCalls = toolCalls.length > 0;
|
||||||
|
const subAgents = useMemo(() => {
|
||||||
|
return toolCalls
|
||||||
|
.filter((toolCall: ToolCall) => {
|
||||||
|
return (
|
||||||
|
toolCall.name === "task" &&
|
||||||
|
toolCall.args["subagent_type"] &&
|
||||||
|
toolCall.args["subagent_type"] !== "" &&
|
||||||
|
toolCall.args["subagent_type"] !== null
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((toolCall: ToolCall) => {
|
||||||
|
const subagentType = (toolCall.args as Record<string, unknown>)[
|
||||||
|
"subagent_type"
|
||||||
|
] as string;
|
||||||
|
return {
|
||||||
|
id: toolCall.id,
|
||||||
|
name: toolCall.name,
|
||||||
|
subAgentName: subagentType,
|
||||||
|
input: toolCall.args,
|
||||||
|
output: toolCall.result ? { result: toolCall.result } : undefined,
|
||||||
|
status: toolCall.status,
|
||||||
|
} as SubAgent;
|
||||||
|
});
|
||||||
|
}, [toolCalls]);
|
||||||
|
|
||||||
|
const [expandedSubAgents, setExpandedSubAgents] = useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
|
const isSubAgentExpanded = useCallback(
|
||||||
|
(id: string) => expandedSubAgents[id] ?? true,
|
||||||
|
[expandedSubAgents]
|
||||||
|
);
|
||||||
|
const toggleSubAgent = useCallback((id: string) => {
|
||||||
|
setExpandedSubAgents((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[id]: prev[id] === undefined ? false : !prev[id],
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex w-full max-w-full overflow-x-hidden",
|
||||||
|
isUser && "flex-row-reverse"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 max-w-full",
|
||||||
|
isUser ? "max-w-[70%]" : "w-full"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hasContent && (
|
||||||
|
<div className={cn("relative flex items-end gap-0")}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-4 overflow-hidden break-words text-sm font-normal leading-[150%]",
|
||||||
|
isUser
|
||||||
|
? "rounded-xl rounded-br-none border border-border px-3 py-2 text-foreground"
|
||||||
|
: "text-primary"
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
isUser
|
||||||
|
? { backgroundColor: "var(--color-user-message-bg)" }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isUser ? (
|
||||||
|
<p className="m-0 whitespace-pre-wrap break-words text-sm leading-relaxed">
|
||||||
|
{messageContent}
|
||||||
|
</p>
|
||||||
|
) : hasContent ? (
|
||||||
|
<MarkdownContent content={messageContent} />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasToolCalls && (
|
||||||
|
<div className="mt-4 flex w-full flex-col">
|
||||||
|
{toolCalls.map((toolCall: ToolCall) => {
|
||||||
|
if (toolCall.name === "task") return null;
|
||||||
|
const toolCallGenUiComponent = ui?.find(
|
||||||
|
(u) => u.metadata?.tool_call_id === toolCall.id
|
||||||
|
);
|
||||||
|
const actionRequest = actionRequestsMap?.get(toolCall.name);
|
||||||
|
const reviewConfig = reviewConfigsMap?.get(toolCall.name);
|
||||||
|
return (
|
||||||
|
<ToolCallBox
|
||||||
|
key={toolCall.id}
|
||||||
|
toolCall={toolCall}
|
||||||
|
uiComponent={toolCallGenUiComponent}
|
||||||
|
stream={stream}
|
||||||
|
graphId={graphId}
|
||||||
|
actionRequest={actionRequest}
|
||||||
|
reviewConfig={reviewConfig}
|
||||||
|
onResume={onResumeInterrupt}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isUser && subAgents.length > 0 && (
|
||||||
|
<div className="flex w-fit max-w-full flex-col gap-4">
|
||||||
|
{subAgents.map((subAgent) => (
|
||||||
|
<div
|
||||||
|
key={subAgent.id}
|
||||||
|
className="flex w-full flex-col gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="w-[calc(100%-100px)]">
|
||||||
|
<SubAgentIndicator
|
||||||
|
subAgent={subAgent}
|
||||||
|
onClick={() => toggleSubAgent(subAgent.id)}
|
||||||
|
isExpanded={isSubAgentExpanded(subAgent.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isSubAgentExpanded(subAgent.id) && (
|
||||||
|
<div className="w-full max-w-full">
|
||||||
|
<div className="bg-surface border-border-light rounded-md border p-4">
|
||||||
|
<h4 className="text-primary/70 mb-2 text-xs font-semibold uppercase tracking-wider">
|
||||||
|
Input
|
||||||
|
</h4>
|
||||||
|
<div className="mb-4">
|
||||||
|
<MarkdownContent
|
||||||
|
content={extractSubAgentContent(subAgent.input)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{subAgent.output && (
|
||||||
|
<>
|
||||||
|
<h4 className="text-primary/70 mb-2 text-xs font-semibold uppercase tracking-wider">
|
||||||
|
Output
|
||||||
|
</h4>
|
||||||
|
<MarkdownContent
|
||||||
|
content={extractSubAgentContent(subAgent.output)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ChatMessage.displayName = "ChatMessage";
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { StandaloneConfig } from "@/lib/config";
|
||||||
|
|
||||||
|
interface ConfigDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSave: (config: StandaloneConfig) => void;
|
||||||
|
initialConfig?: StandaloneConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfigDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSave,
|
||||||
|
initialConfig,
|
||||||
|
}: ConfigDialogProps) {
|
||||||
|
const [deploymentUrl, setDeploymentUrl] = useState(
|
||||||
|
initialConfig?.deploymentUrl ||
|
||||||
|
process.env.NEXT_PUBLIC_DEPLOYMENT_URL ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
const [assistantId, setAssistantId] = useState(
|
||||||
|
initialConfig?.assistantId || process.env.NEXT_PUBLIC_ASSISTANT_ID || ""
|
||||||
|
);
|
||||||
|
const [langsmithApiKey, setLangsmithApiKey] = useState(
|
||||||
|
initialConfig?.langsmithApiKey ||
|
||||||
|
process.env.NEXT_PUBLIC_LANGSMITH_API_KEY ||
|
||||||
|
""
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && initialConfig) {
|
||||||
|
setDeploymentUrl(initialConfig.deploymentUrl);
|
||||||
|
setAssistantId(initialConfig.assistantId);
|
||||||
|
setLangsmithApiKey(initialConfig.langsmithApiKey || "");
|
||||||
|
}
|
||||||
|
}, [open, initialConfig]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!deploymentUrl || !assistantId) {
|
||||||
|
alert("Please fill in all required fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave({
|
||||||
|
deploymentUrl,
|
||||||
|
assistantId,
|
||||||
|
langsmithApiKey: langsmithApiKey || undefined,
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-[525px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Configuration</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure your LangGraph deployment settings. These settings are
|
||||||
|
saved in your browser's local storage.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="deploymentUrl">Deployment URL</Label>
|
||||||
|
<Input
|
||||||
|
id="deploymentUrl"
|
||||||
|
placeholder="https://<deployment-url>"
|
||||||
|
value={deploymentUrl}
|
||||||
|
onChange={(e) => setDeploymentUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="assistantId">Assistant ID</Label>
|
||||||
|
<Input
|
||||||
|
id="assistantId"
|
||||||
|
placeholder="<assistant-id>"
|
||||||
|
value={assistantId}
|
||||||
|
onChange={(e) => setAssistantId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="langsmithApiKey">
|
||||||
|
LangSmith API Key{" "}
|
||||||
|
<span className="text-muted-foreground">(Optional)</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="langsmithApiKey"
|
||||||
|
type="password"
|
||||||
|
placeholder="lsv2_pt_..."
|
||||||
|
value={langsmithApiKey}
|
||||||
|
onChange={(e) => setLangsmithApiKey(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>Save</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo, useCallback, useState, useEffect } from "react";
|
||||||
|
import { FileText, Copy, Download, Edit, Save, X, Loader2 } from "lucide-react";
|
||||||
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||||
|
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { MarkdownContent } from "@/app/components/MarkdownContent";
|
||||||
|
import type { FileItem } from "@/app/types/types";
|
||||||
|
import useSWRMutation from "swr/mutation";
|
||||||
|
|
||||||
|
const LANGUAGE_MAP: Record<string, string> = {
|
||||||
|
js: "javascript",
|
||||||
|
jsx: "javascript",
|
||||||
|
ts: "typescript",
|
||||||
|
tsx: "typescript",
|
||||||
|
py: "python",
|
||||||
|
rb: "ruby",
|
||||||
|
go: "go",
|
||||||
|
rs: "rust",
|
||||||
|
java: "java",
|
||||||
|
cpp: "cpp",
|
||||||
|
c: "c",
|
||||||
|
cs: "csharp",
|
||||||
|
php: "php",
|
||||||
|
swift: "swift",
|
||||||
|
kt: "kotlin",
|
||||||
|
scala: "scala",
|
||||||
|
sh: "bash",
|
||||||
|
bash: "bash",
|
||||||
|
zsh: "bash",
|
||||||
|
json: "json",
|
||||||
|
xml: "xml",
|
||||||
|
html: "html",
|
||||||
|
css: "css",
|
||||||
|
scss: "scss",
|
||||||
|
sass: "sass",
|
||||||
|
less: "less",
|
||||||
|
sql: "sql",
|
||||||
|
yaml: "yaml",
|
||||||
|
yml: "yaml",
|
||||||
|
toml: "toml",
|
||||||
|
ini: "ini",
|
||||||
|
dockerfile: "dockerfile",
|
||||||
|
makefile: "makefile",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FileViewDialog = React.memo<{
|
||||||
|
file: FileItem | null;
|
||||||
|
onSaveFile: (fileName: string, content: string) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
editDisabled: boolean;
|
||||||
|
}>(({ file, onSaveFile, onClose, editDisabled }) => {
|
||||||
|
const [isEditingMode, setIsEditingMode] = useState(file === null);
|
||||||
|
const [fileName, setFileName] = useState(String(file?.path || ""));
|
||||||
|
const [fileContent, setFileContent] = useState(String(file?.content || ""));
|
||||||
|
|
||||||
|
const fileUpdate = useSWRMutation(
|
||||||
|
{ kind: "files-update", fileName, fileContent },
|
||||||
|
async ({ fileName, fileContent }) => {
|
||||||
|
if (!fileName || !fileContent) return;
|
||||||
|
return await onSaveFile(fileName, fileContent);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: () => setIsEditingMode(false),
|
||||||
|
onError: (error) => toast.error(`Failed to save file: ${error}`),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFileName(String(file?.path || ""));
|
||||||
|
setFileContent(String(file?.content || ""));
|
||||||
|
setIsEditingMode(file === null);
|
||||||
|
}, [file]);
|
||||||
|
|
||||||
|
const fileExtension = useMemo(() => {
|
||||||
|
const fileNameStr = String(fileName || "");
|
||||||
|
return fileNameStr.split(".").pop()?.toLowerCase() || "";
|
||||||
|
}, [fileName]);
|
||||||
|
|
||||||
|
const isMarkdown = useMemo(() => {
|
||||||
|
return fileExtension === "md" || fileExtension === "markdown";
|
||||||
|
}, [fileExtension]);
|
||||||
|
|
||||||
|
const language = useMemo(() => {
|
||||||
|
return LANGUAGE_MAP[fileExtension] || "text";
|
||||||
|
}, [fileExtension]);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(() => {
|
||||||
|
if (fileContent) {
|
||||||
|
navigator.clipboard.writeText(fileContent);
|
||||||
|
}
|
||||||
|
}, [fileContent]);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
if (fileContent && fileName) {
|
||||||
|
const blob = new Blob([fileContent], { type: "text/plain" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}, [fileContent, fileName]);
|
||||||
|
|
||||||
|
const handleEdit = useCallback(() => {
|
||||||
|
setIsEditingMode(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
if (file === null) {
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setFileName(String(file.path));
|
||||||
|
setFileContent(String(file.content));
|
||||||
|
setIsEditingMode(false);
|
||||||
|
}
|
||||||
|
}, [file, onClose]);
|
||||||
|
|
||||||
|
const fileNameIsValid = useMemo(() => {
|
||||||
|
return (
|
||||||
|
fileName.trim() !== "" &&
|
||||||
|
!fileName.includes("/") &&
|
||||||
|
!fileName.includes(" ")
|
||||||
|
);
|
||||||
|
}, [fileName]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={true}
|
||||||
|
onOpenChange={onClose}
|
||||||
|
>
|
||||||
|
<DialogContent className="flex h-[80vh] max-h-[80vh] min-w-[60vw] flex-col p-6">
|
||||||
|
<DialogTitle className="sr-only">
|
||||||
|
{file?.path || "New File"}
|
||||||
|
</DialogTitle>
|
||||||
|
<div className="mb-4 flex items-center justify-between border-b border-border pb-4">
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<FileText className="text-primary/50 h-5 w-5 shrink-0" />
|
||||||
|
{isEditingMode && file === null ? (
|
||||||
|
<Input
|
||||||
|
value={fileName}
|
||||||
|
onChange={(e) => setFileName(e.target.value)}
|
||||||
|
placeholder="Enter filename..."
|
||||||
|
className="text-base font-medium"
|
||||||
|
aria-invalid={!fileNameIsValid}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-base font-medium text-primary">
|
||||||
|
{file?.path}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{!isEditingMode && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
onClick={handleEdit}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2"
|
||||||
|
disabled={editDisabled}
|
||||||
|
>
|
||||||
|
<Edit
|
||||||
|
size={16}
|
||||||
|
className="mr-1"
|
||||||
|
/>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCopy}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2"
|
||||||
|
>
|
||||||
|
<Copy
|
||||||
|
size={16}
|
||||||
|
className="mr-1"
|
||||||
|
/>
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleDownload}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2"
|
||||||
|
>
|
||||||
|
<Download
|
||||||
|
size={16}
|
||||||
|
className="mr-1"
|
||||||
|
/>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
|
{isEditingMode ? (
|
||||||
|
<Textarea
|
||||||
|
value={fileContent}
|
||||||
|
onChange={(e) => setFileContent(e.target.value)}
|
||||||
|
placeholder="Enter file content..."
|
||||||
|
className="h-full min-h-[400px] resize-none font-mono text-sm"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="bg-surface h-full rounded-md">
|
||||||
|
<div className="p-4">
|
||||||
|
{fileContent ? (
|
||||||
|
isMarkdown ? (
|
||||||
|
<div className="rounded-md p-6">
|
||||||
|
<MarkdownContent content={fileContent} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
language={language}
|
||||||
|
style={oneDark}
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
showLineNumbers
|
||||||
|
wrapLines={true}
|
||||||
|
lineProps={{
|
||||||
|
style: {
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fileContent}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center p-12">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
File is empty
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isEditingMode && (
|
||||||
|
<div className="mt-4 flex justify-end gap-2 border-t border-border pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleCancel}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<X
|
||||||
|
size={16}
|
||||||
|
className="mr-1"
|
||||||
|
/>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => fileUpdate.trigger()}
|
||||||
|
size="sm"
|
||||||
|
disabled={
|
||||||
|
fileUpdate.isMutating ||
|
||||||
|
!fileName.trim() ||
|
||||||
|
!fileContent.trim() ||
|
||||||
|
!fileNameIsValid
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{fileUpdate.isMutating ? (
|
||||||
|
<Loader2
|
||||||
|
size={16}
|
||||||
|
className="mr-1 animate-spin"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Save
|
||||||
|
size={16}
|
||||||
|
className="mr-1"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
FileViewDialog.displayName = "FileViewDialog";
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||||
|
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface MarkdownContentProps {
|
||||||
|
content: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MarkdownContent = React.memo<MarkdownContentProps>(
|
||||||
|
({ content, className = "" }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"prose min-w-0 max-w-full overflow-hidden break-words text-sm leading-relaxed text-inherit [&_h1:first-child]:mt-0 [&_h1]:mb-4 [&_h1]:mt-6 [&_h1]:font-semibold [&_h2:first-child]:mt-0 [&_h2]:mb-4 [&_h2]:mt-6 [&_h2]:font-semibold [&_h3:first-child]:mt-0 [&_h3]:mb-4 [&_h3]:mt-6 [&_h3]:font-semibold [&_h4:first-child]:mt-0 [&_h4]:mb-4 [&_h4]:mt-6 [&_h4]:font-semibold [&_h5:first-child]:mt-0 [&_h5]:mb-4 [&_h5]:mt-6 [&_h5]:font-semibold [&_h6:first-child]:mt-0 [&_h6]:mb-4 [&_h6]:mt-6 [&_h6]:font-semibold [&_p:last-child]:mb-0 [&_p]:mb-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
components={{
|
||||||
|
code({
|
||||||
|
inline,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
inline?: boolean;
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const match = /language-(\w+)/.exec(className || "");
|
||||||
|
return !inline && match ? (
|
||||||
|
<SyntaxHighlighter
|
||||||
|
style={oneDark}
|
||||||
|
language={match[1]}
|
||||||
|
PreTag="div"
|
||||||
|
className="max-w-full rounded-md text-sm"
|
||||||
|
wrapLines={true}
|
||||||
|
wrapLongLines={true}
|
||||||
|
lineProps={{
|
||||||
|
style: {
|
||||||
|
wordBreak: "break-all",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
overflowWrap: "break-word",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
customStyle={{
|
||||||
|
margin: 0,
|
||||||
|
maxWidth: "100%",
|
||||||
|
overflowX: "auto",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{String(children).replace(/\n$/, "")}
|
||||||
|
</SyntaxHighlighter>
|
||||||
|
) : (
|
||||||
|
<code
|
||||||
|
className="bg-surface rounded-sm px-1 py-0.5 font-mono text-[0.9em]"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
pre({ children }: { children?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 max-w-full overflow-hidden last:mb-0">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
a({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
href?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary no-underline hover:underline"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
blockquote({ children }: { children?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<blockquote className="text-primary/50 my-4 border-l-4 border-border pl-4 italic">
|
||||||
|
{children}
|
||||||
|
</blockquote>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
ul({ children }: { children?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ul className="my-4 pl-6 [&>li:last-child]:mb-0 [&>li]:mb-1">
|
||||||
|
{children}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
ol({ children }: { children?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ol className="my-4 pl-6 [&>li:last-child]:mb-0 [&>li]:mb-1">
|
||||||
|
{children}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
table({ children }: { children?: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 overflow-x-auto">
|
||||||
|
<table className="[&_th]:bg-surface w-full border-collapse [&_td]:border [&_td]:border-border [&_td]:p-2 [&_th]:border [&_th]:border-border [&_th]:p-2 [&_th]:text-left [&_th]:font-semibold">
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
MarkdownContent.displayName = "MarkdownContent";
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
import type { SubAgent } from "@/app/types/types";
|
||||||
|
|
||||||
|
interface SubAgentIndicatorProps {
|
||||||
|
subAgent: SubAgent;
|
||||||
|
onClick: () => void;
|
||||||
|
isExpanded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SubAgentIndicator = React.memo<SubAgentIndicatorProps>(
|
||||||
|
({ subAgent, onClick, isExpanded = true }) => {
|
||||||
|
return (
|
||||||
|
<div className="w-fit max-w-[70vw] overflow-hidden rounded-lg border-none bg-card shadow-none outline-none">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex w-full items-center justify-between gap-2 border-none px-4 py-2 text-left shadow-none outline-none transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<div className="flex w-full items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-sans text-[15px] font-bold leading-[140%] tracking-[-0.6px] text-[#3F3F46]">
|
||||||
|
{subAgent.subAgentName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronUp
|
||||||
|
size={14}
|
||||||
|
className="shrink-0 text-[#70707B]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className="shrink-0 text-[#70707B]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
SubAgentIndicator.displayName = "SubAgentIndicator";
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
useMemo,
|
||||||
|
useCallback,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
CheckCircle,
|
||||||
|
Circle,
|
||||||
|
Clock,
|
||||||
|
ChevronDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import type { TodoItem, FileItem } from "@/app/types/types";
|
||||||
|
import { useChatContext } from "@/providers/ChatProvider";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { FileViewDialog } from "@/app/components/FileViewDialog";
|
||||||
|
|
||||||
|
export function FilesPopover({
|
||||||
|
files,
|
||||||
|
setFiles,
|
||||||
|
editDisabled,
|
||||||
|
}: {
|
||||||
|
files: Record<string, string>;
|
||||||
|
setFiles: (files: Record<string, string>) => Promise<void>;
|
||||||
|
editDisabled: boolean;
|
||||||
|
}) {
|
||||||
|
const [selectedFile, setSelectedFile] = useState<FileItem | null>(null);
|
||||||
|
|
||||||
|
const handleSaveFile = useCallback(
|
||||||
|
async (fileName: string, content: string) => {
|
||||||
|
await setFiles({ ...files, [fileName]: content });
|
||||||
|
setSelectedFile({ path: fileName, content: content });
|
||||||
|
},
|
||||||
|
[files, setFiles]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Object.keys(files).length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center p-4 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">No files created yet</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(256px,1fr))] gap-2">
|
||||||
|
{Object.keys(files).map((file) => {
|
||||||
|
const filePath = String(file);
|
||||||
|
const rawContent = files[file];
|
||||||
|
let fileContent: string;
|
||||||
|
if (
|
||||||
|
typeof rawContent === "object" &&
|
||||||
|
rawContent !== null &&
|
||||||
|
"content" in rawContent
|
||||||
|
) {
|
||||||
|
const contentArray = (rawContent as { content: unknown }).content;
|
||||||
|
if (Array.isArray(contentArray)) {
|
||||||
|
fileContent = contentArray.join("\n");
|
||||||
|
} else {
|
||||||
|
fileContent = String(contentArray || "");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fileContent = String(rawContent || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={filePath}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setSelectedFile({ path: filePath, content: fileContent })
|
||||||
|
}
|
||||||
|
className="cursor-pointer space-y-1 truncate rounded-md border border-border px-2 py-3 shadow-sm transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--color-file-button)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"var(--color-file-button-hover)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor =
|
||||||
|
"var(--color-file-button)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText
|
||||||
|
size={24}
|
||||||
|
className="mx-auto text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<span className="mx-auto block w-full truncate break-words text-center text-sm leading-relaxed text-foreground">
|
||||||
|
{filePath}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedFile && (
|
||||||
|
<FileViewDialog
|
||||||
|
file={selectedFile}
|
||||||
|
onSaveFile={handleSaveFile}
|
||||||
|
onClose={() => setSelectedFile(null)}
|
||||||
|
editDisabled={editDisabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TasksFilesSidebar = React.memo<{
|
||||||
|
todos: TodoItem[];
|
||||||
|
files: Record<string, string>;
|
||||||
|
setFiles: (files: Record<string, string>) => Promise<void>;
|
||||||
|
}>(({ todos, files, setFiles }) => {
|
||||||
|
const { isLoading, interrupt } = useChatContext();
|
||||||
|
const [tasksOpen, setTasksOpen] = useState(false);
|
||||||
|
const [filesOpen, setFilesOpen] = useState(false);
|
||||||
|
|
||||||
|
// Track previous counts to detect when content goes from empty to having items
|
||||||
|
const prevTodosCount = useRef(todos.length);
|
||||||
|
const prevFilesCount = useRef(Object.keys(files).length);
|
||||||
|
|
||||||
|
// Auto-expand when todos go from empty to having content
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevTodosCount.current === 0 && todos.length > 0) {
|
||||||
|
setTasksOpen(true);
|
||||||
|
}
|
||||||
|
prevTodosCount.current = todos.length;
|
||||||
|
}, [todos.length]);
|
||||||
|
|
||||||
|
// Auto-expand when files go from empty to having content
|
||||||
|
const filesCount = Object.keys(files).length;
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevFilesCount.current === 0 && filesCount > 0) {
|
||||||
|
setFilesOpen(true);
|
||||||
|
}
|
||||||
|
prevFilesCount.current = filesCount;
|
||||||
|
}, [filesCount]);
|
||||||
|
|
||||||
|
const getStatusIcon = useCallback((status: TodoItem["status"]) => {
|
||||||
|
switch (status) {
|
||||||
|
case "completed":
|
||||||
|
return (
|
||||||
|
<CheckCircle
|
||||||
|
size={12}
|
||||||
|
className="text-success/80"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "in_progress":
|
||||||
|
return (
|
||||||
|
<Clock
|
||||||
|
size={12}
|
||||||
|
className="text-warning/80"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Circle
|
||||||
|
size={10}
|
||||||
|
className="text-tertiary/70"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const groupedTodos = useMemo(() => {
|
||||||
|
return {
|
||||||
|
pending: todos.filter((t) => t.status === "pending"),
|
||||||
|
in_progress: todos.filter((t) => t.status === "in_progress"),
|
||||||
|
completed: todos.filter((t) => t.status === "completed"),
|
||||||
|
};
|
||||||
|
}, [todos]);
|
||||||
|
|
||||||
|
const groupedLabels = {
|
||||||
|
pending: "Pending",
|
||||||
|
in_progress: "In Progress",
|
||||||
|
completed: "Completed",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-0 w-full flex-1">
|
||||||
|
<div className="font-inter flex h-full w-full flex-col p-0">
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-3 pb-1.5 pt-2">
|
||||||
|
<span className="text-xs font-semibold tracking-wide text-zinc-600">
|
||||||
|
AGENT TASKS
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setTasksOpen((v) => !v)}
|
||||||
|
className={cn(
|
||||||
|
"flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-transform duration-200 hover:bg-muted",
|
||||||
|
tasksOpen ? "rotate-180" : "rotate-0"
|
||||||
|
)}
|
||||||
|
aria-label="Toggle tasks panel"
|
||||||
|
>
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{tasksOpen && (
|
||||||
|
<div className="bg-muted-secondary rounded-xl px-3 pb-2">
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
{todos.length === 0 ? (
|
||||||
|
<div className="flex h-full items-center justify-center p-4 text-center">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
No tasks created yet
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="ml-1 p-0.5">
|
||||||
|
{Object.entries(groupedTodos).map(([status, todos]) => (
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-tertiary">
|
||||||
|
{groupedLabels[status as keyof typeof groupedLabels]}
|
||||||
|
</h3>
|
||||||
|
{todos.map((todo, index) => (
|
||||||
|
<div
|
||||||
|
key={`${status}_${todo.id}_${index}`}
|
||||||
|
className="mb-1.5 flex items-start gap-2 rounded-sm p-1 text-sm"
|
||||||
|
>
|
||||||
|
{getStatusIcon(todo.status)}
|
||||||
|
<span className="flex-1 break-words leading-relaxed text-inherit">
|
||||||
|
{todo.content}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between px-3 pb-1.5 pt-2">
|
||||||
|
<span className="text-xs font-semibold tracking-wide text-zinc-600">
|
||||||
|
FILE SYSTEM
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilesOpen((v) => !v)}
|
||||||
|
className={cn(
|
||||||
|
"flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-transform duration-200 hover:bg-muted",
|
||||||
|
filesOpen ? "rotate-180" : "rotate-0"
|
||||||
|
)}
|
||||||
|
aria-label="Toggle files panel"
|
||||||
|
>
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{filesOpen && (
|
||||||
|
<FilesPopover
|
||||||
|
files={files}
|
||||||
|
setFiles={setFiles}
|
||||||
|
editDisabled={isLoading === true || interrupt !== undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TasksFilesSidebar.displayName = "TasksFilesSidebar";
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState, useRef, useCallback } from "react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { Loader2, MessageSquare, X } from "lucide-react";
|
||||||
|
import { useQueryState } from "nuqs";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ThreadItem } from "@/app/hooks/useThreads";
|
||||||
|
import { useThreads } from "@/app/hooks/useThreads";
|
||||||
|
|
||||||
|
type StatusFilter = "all" | "idle" | "busy" | "interrupted" | "error";
|
||||||
|
|
||||||
|
const GROUP_LABELS = {
|
||||||
|
interrupted: "Requiring Attention",
|
||||||
|
today: "Today",
|
||||||
|
yesterday: "Yesterday",
|
||||||
|
week: "This Week",
|
||||||
|
older: "Older",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<ThreadItem["status"], string> = {
|
||||||
|
idle: "bg-green-500",
|
||||||
|
busy: "bg-blue-500",
|
||||||
|
interrupted: "bg-orange-500",
|
||||||
|
error: "bg-red-600",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getThreadColor(status: ThreadItem["status"]): string {
|
||||||
|
return STATUS_COLORS[status] ?? "bg-gray-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(date: Date, now = new Date()): string {
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (days === 0) return format(date, "HH:mm");
|
||||||
|
if (days === 1) return "Yesterday";
|
||||||
|
if (days < 7) return format(date, "EEEE");
|
||||||
|
return format(date, "MM/dd");
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusFilterItem({
|
||||||
|
status,
|
||||||
|
label,
|
||||||
|
badge,
|
||||||
|
}: {
|
||||||
|
status: ThreadItem["status"];
|
||||||
|
label: string;
|
||||||
|
badge?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block size-2 rounded-full",
|
||||||
|
getThreadColor(status)
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
{badge !== undefined && badge > 0 && (
|
||||||
|
<span className="ml-1 inline-flex items-center justify-center rounded-full bg-red-600 px-1.5 py-0.5 text-xs font-bold leading-none text-white">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorState({ message }: { message: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||||
|
<p className="text-sm text-red-600">Failed to load threads</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoadingState() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 p-4">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
className="h-16 w-full"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||||
|
<MessageSquare className="mb-2 h-12 w-12 text-gray-300" />
|
||||||
|
<p className="text-sm text-muted-foreground">No threads found</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThreadListProps {
|
||||||
|
onThreadSelect: (id: string) => void;
|
||||||
|
onMutateReady?: (mutate: () => void) => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
onInterruptCountChange?: (count: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThreadList({
|
||||||
|
onThreadSelect,
|
||||||
|
onMutateReady,
|
||||||
|
onClose,
|
||||||
|
onInterruptCountChange,
|
||||||
|
}: ThreadListProps) {
|
||||||
|
const [currentThreadId] = useQueryState("threadId");
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||||
|
|
||||||
|
const threads = useThreads({
|
||||||
|
status: statusFilter === "all" ? undefined : statusFilter,
|
||||||
|
limit: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
const flattened = useMemo(() => {
|
||||||
|
return threads.data?.flat() ?? [];
|
||||||
|
}, [threads.data]);
|
||||||
|
|
||||||
|
const isLoadingMore =
|
||||||
|
threads.size > 0 && threads.data?.[threads.size - 1] == null;
|
||||||
|
const isEmpty = threads.data?.at(0)?.length === 0;
|
||||||
|
const isReachingEnd = isEmpty || (threads.data?.at(-1)?.length ?? 0) < 20;
|
||||||
|
|
||||||
|
// Group threads by time and status
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const groups: Record<keyof typeof GROUP_LABELS, ThreadItem[]> = {
|
||||||
|
interrupted: [],
|
||||||
|
today: [],
|
||||||
|
yesterday: [],
|
||||||
|
week: [],
|
||||||
|
older: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
flattened.forEach((thread) => {
|
||||||
|
if (thread.status === "interrupted") {
|
||||||
|
groups.interrupted.push(thread);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diff = now.getTime() - thread.updatedAt.getTime();
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (days === 0) {
|
||||||
|
groups.today.push(thread);
|
||||||
|
} else if (days === 1) {
|
||||||
|
groups.yesterday.push(thread);
|
||||||
|
} else if (days < 7) {
|
||||||
|
groups.week.push(thread);
|
||||||
|
} else {
|
||||||
|
groups.older.push(thread);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [flattened]);
|
||||||
|
|
||||||
|
const interruptedCount = useMemo(() => {
|
||||||
|
return flattened.filter((t) => t.status === "interrupted").length;
|
||||||
|
}, [flattened]);
|
||||||
|
|
||||||
|
// Expose thread list revalidation to parent component
|
||||||
|
// Use refs to create a stable callback that always calls the latest mutate function
|
||||||
|
const onMutateReadyRef = useRef(onMutateReady);
|
||||||
|
const mutateRef = useRef(threads.mutate);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onMutateReadyRef.current = onMutateReady;
|
||||||
|
}, [onMutateReady]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mutateRef.current = threads.mutate;
|
||||||
|
}, [threads.mutate]);
|
||||||
|
|
||||||
|
const mutateFn = useCallback(() => {
|
||||||
|
mutateRef.current();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onMutateReadyRef.current?.(mutateFn);
|
||||||
|
// Only run once on mount to avoid infinite loops
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Notify parent of interrupt count changes
|
||||||
|
useEffect(() => {
|
||||||
|
onInterruptCountChange?.(interruptedCount);
|
||||||
|
}, [interruptedCount, onInterruptCountChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex flex-col">
|
||||||
|
{/* Header with title, filter, and close button */}
|
||||||
|
<div className="grid flex-shrink-0 grid-cols-[1fr_auto] items-center gap-3 border-b border-border p-4">
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight">Threads</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={statusFilter}
|
||||||
|
onValueChange={(v) => setStatusFilter(v as StatusFilter)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-fit">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent align="end">
|
||||||
|
<SelectItem value="all">All statuses</SelectItem>
|
||||||
|
<SelectSeparator />
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>Active</SelectLabel>
|
||||||
|
<SelectItem value="idle">
|
||||||
|
<StatusFilterItem
|
||||||
|
status="idle"
|
||||||
|
label="Idle"
|
||||||
|
/>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="busy">
|
||||||
|
<StatusFilterItem
|
||||||
|
status="busy"
|
||||||
|
label="Busy"
|
||||||
|
/>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
<SelectSeparator />
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel>Attention</SelectLabel>
|
||||||
|
<SelectItem value="interrupted">
|
||||||
|
<StatusFilterItem
|
||||||
|
status="interrupted"
|
||||||
|
label="Interrupted"
|
||||||
|
badge={interruptedCount}
|
||||||
|
/>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="error">
|
||||||
|
<StatusFilterItem
|
||||||
|
status="error"
|
||||||
|
label="Error"
|
||||||
|
/>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{onClose && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClose}
|
||||||
|
className="h-8 w-8"
|
||||||
|
aria-label="Close threads sidebar"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="h-0 flex-1">
|
||||||
|
{threads.error && <ErrorState message={threads.error.message} />}
|
||||||
|
|
||||||
|
{!threads.error && !threads.data && threads.isLoading && (
|
||||||
|
<LoadingState />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!threads.error && !threads.isLoading && isEmpty && <EmptyState />}
|
||||||
|
|
||||||
|
{!threads.error && !isEmpty && (
|
||||||
|
<div className="box-border w-full max-w-full overflow-hidden p-2">
|
||||||
|
{(
|
||||||
|
Object.keys(GROUP_LABELS) as Array<keyof typeof GROUP_LABELS>
|
||||||
|
).map((group) => {
|
||||||
|
const groupThreads = grouped[group];
|
||||||
|
if (groupThreads.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group}
|
||||||
|
className="mb-4"
|
||||||
|
>
|
||||||
|
<h4 className="m-0 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{GROUP_LABELS[group]}
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{groupThreads.map((thread) => (
|
||||||
|
<button
|
||||||
|
key={thread.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onThreadSelect(thread.id)}
|
||||||
|
className={cn(
|
||||||
|
"grid w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-3 text-left transition-colors duration-200",
|
||||||
|
"hover:bg-accent",
|
||||||
|
currentThreadId === thread.id
|
||||||
|
? "border border-primary bg-accent hover:bg-accent"
|
||||||
|
: "border border-transparent bg-transparent"
|
||||||
|
)}
|
||||||
|
aria-current={currentThreadId === thread.id}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{/* Title + Timestamp Row */}
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<h3 className="truncate text-sm font-semibold">
|
||||||
|
{thread.title}
|
||||||
|
</h3>
|
||||||
|
<span className="ml-2 flex-shrink-0 text-xs text-muted-foreground">
|
||||||
|
{formatTime(thread.updatedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Description + Status Row */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="flex-1 truncate text-sm text-muted-foreground">
|
||||||
|
{thread.description}
|
||||||
|
</p>
|
||||||
|
<div className="ml-2 flex-shrink-0">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-2 w-2 rounded-full",
|
||||||
|
getThreadColor(thread.status)
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{!isReachingEnd && (
|
||||||
|
<div className="flex justify-center py-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => threads.setSize(threads.size + 1)}
|
||||||
|
disabled={isLoadingMore}
|
||||||
|
>
|
||||||
|
{isLoadingMore ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Load More"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { AlertCircle, Check, X, Pencil } from "lucide-react";
|
||||||
|
import type { ActionRequest, ReviewConfig } from "@/app/types/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ToolApprovalInterruptProps {
|
||||||
|
actionRequest: ActionRequest;
|
||||||
|
reviewConfig?: ReviewConfig;
|
||||||
|
onResume: (value: any) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToolApprovalInterrupt({
|
||||||
|
actionRequest,
|
||||||
|
reviewConfig,
|
||||||
|
onResume,
|
||||||
|
isLoading,
|
||||||
|
}: ToolApprovalInterruptProps) {
|
||||||
|
const [rejectionMessage, setRejectionMessage] = useState("");
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editedArgs, setEditedArgs] = useState<Record<string, unknown>>({});
|
||||||
|
const [showRejectionInput, setShowRejectionInput] = useState(false);
|
||||||
|
|
||||||
|
const allowedDecisions = reviewConfig?.allowedDecisions ?? [
|
||||||
|
"approve",
|
||||||
|
"reject",
|
||||||
|
"edit",
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleApprove = () => {
|
||||||
|
onResume({
|
||||||
|
decisions: [{ type: "approve" }],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = () => {
|
||||||
|
if (showRejectionInput) {
|
||||||
|
onResume({
|
||||||
|
decisions: [
|
||||||
|
{
|
||||||
|
type: "reject",
|
||||||
|
message: rejectionMessage.trim(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setShowRejectionInput(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRejectConfirm = () => {
|
||||||
|
onResume({
|
||||||
|
decisions: [
|
||||||
|
{
|
||||||
|
type: "reject",
|
||||||
|
message: rejectionMessage.trim(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
if (isEditing) {
|
||||||
|
onResume({
|
||||||
|
decisions: [
|
||||||
|
{
|
||||||
|
type: "edit",
|
||||||
|
edited_action: {
|
||||||
|
name: actionRequest.name,
|
||||||
|
args: editedArgs,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditedArgs({});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditing = () => {
|
||||||
|
setIsEditing(true);
|
||||||
|
setEditedArgs(JSON.parse(JSON.stringify(actionRequest.args)));
|
||||||
|
setShowRejectionInput(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEditing = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setEditedArgs({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateEditedArg = (key: string, value: string) => {
|
||||||
|
try {
|
||||||
|
const parsedValue =
|
||||||
|
value.trim().startsWith("{") || value.trim().startsWith("[")
|
||||||
|
? JSON.parse(value)
|
||||||
|
: value;
|
||||||
|
setEditedArgs((prev) => ({ ...prev, [key]: parsedValue }));
|
||||||
|
} catch {
|
||||||
|
setEditedArgs((prev) => ({ ...prev, [key]: value }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full rounded-md border border-border bg-muted/30 p-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-3 flex items-center gap-2 text-foreground">
|
||||||
|
<AlertCircle
|
||||||
|
size={16}
|
||||||
|
className="text-yellow-600 dark:text-yellow-400"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider">
|
||||||
|
Approval Required
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{actionRequest.description && (
|
||||||
|
<p className="mb-3 text-sm text-muted-foreground">
|
||||||
|
{actionRequest.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tool Info Card */}
|
||||||
|
<div className="mb-4 rounded-sm border border-border bg-background p-3">
|
||||||
|
<div className="mb-2">
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
Tool
|
||||||
|
</span>
|
||||||
|
<p className="mt-1 font-mono text-sm font-medium text-foreground">
|
||||||
|
{actionRequest.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
Edit Arguments
|
||||||
|
</span>
|
||||||
|
<div className="mt-2 space-y-3">
|
||||||
|
{Object.entries(actionRequest.args).map(([key, value]) => (
|
||||||
|
<div key={key}>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-foreground">
|
||||||
|
{key}
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
value={
|
||||||
|
editedArgs[key] !== undefined
|
||||||
|
? typeof editedArgs[key] === "string"
|
||||||
|
? (editedArgs[key] as string)
|
||||||
|
: JSON.stringify(editedArgs[key], null, 2)
|
||||||
|
: typeof value === "string"
|
||||||
|
? value
|
||||||
|
: JSON.stringify(value, null, 2)
|
||||||
|
}
|
||||||
|
onChange={(e) => updateEditedArg(key, e.target.value)}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
rows={
|
||||||
|
typeof value === "string" && value.length < 100 ? 2 : 4
|
||||||
|
}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
|
Arguments
|
||||||
|
</span>
|
||||||
|
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-all rounded-sm border border-border bg-muted/40 p-2 font-mono text-xs text-foreground">
|
||||||
|
{JSON.stringify(actionRequest.args, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rejection Message Input */}
|
||||||
|
{showRejectionInput && !isEditing && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="mb-2 block text-xs font-medium text-foreground">
|
||||||
|
Rejection Message (optional)
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
value={rejectionMessage}
|
||||||
|
onChange={(e) => setRejectionMessage(e.target.value)}
|
||||||
|
placeholder="Explain why you're rejecting this action..."
|
||||||
|
className="text-sm"
|
||||||
|
rows={2}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={cancelEditing}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleEdit}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="bg-green-600 text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700"
|
||||||
|
>
|
||||||
|
<Check size={14} />
|
||||||
|
{isLoading ? "Saving..." : "Save & Approve"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : showRejectionInput ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowRejectionInput(false);
|
||||||
|
setRejectionMessage("");
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleRejectConfirm}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? "Rejecting..." : "Confirm Reject"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{allowedDecisions.includes("reject") && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleReject}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{allowedDecisions.includes("edit") && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={startEditing}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{allowedDecisions.includes("approve") && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleApprove}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={cn(
|
||||||
|
"bg-green-600 text-white hover:bg-green-700",
|
||||||
|
"dark:bg-green-600 dark:hover:bg-green-700"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Check size={14} />
|
||||||
|
{isLoading ? "Approving..." : "Approve"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useMemo, useCallback } from "react";
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Terminal,
|
||||||
|
AlertCircle,
|
||||||
|
Loader2,
|
||||||
|
CircleCheckBigIcon,
|
||||||
|
StopCircle,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ToolCall, ActionRequest, ReviewConfig } from "@/app/types/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
|
||||||
|
import { ToolApprovalInterrupt } from "@/app/components/ToolApprovalInterrupt";
|
||||||
|
|
||||||
|
interface ToolCallBoxProps {
|
||||||
|
toolCall: ToolCall;
|
||||||
|
uiComponent?: any;
|
||||||
|
stream?: any;
|
||||||
|
graphId?: string;
|
||||||
|
actionRequest?: ActionRequest;
|
||||||
|
reviewConfig?: ReviewConfig;
|
||||||
|
onResume?: (value: any) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToolCallBox = React.memo<ToolCallBoxProps>(
|
||||||
|
({
|
||||||
|
toolCall,
|
||||||
|
uiComponent,
|
||||||
|
stream,
|
||||||
|
graphId,
|
||||||
|
actionRequest,
|
||||||
|
reviewConfig,
|
||||||
|
onResume,
|
||||||
|
isLoading,
|
||||||
|
}) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(
|
||||||
|
() => !!uiComponent || !!actionRequest
|
||||||
|
);
|
||||||
|
const [expandedArgs, setExpandedArgs] = useState<Record<string, boolean>>(
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { name, args, result, status } = useMemo(() => {
|
||||||
|
return {
|
||||||
|
name: toolCall.name || "Unknown Tool",
|
||||||
|
args: toolCall.args || {},
|
||||||
|
result: toolCall.result,
|
||||||
|
status: toolCall.status || "completed",
|
||||||
|
};
|
||||||
|
}, [toolCall]);
|
||||||
|
|
||||||
|
const statusIcon = useMemo(() => {
|
||||||
|
switch (status) {
|
||||||
|
case "completed":
|
||||||
|
return <CircleCheckBigIcon />;
|
||||||
|
case "error":
|
||||||
|
return (
|
||||||
|
<AlertCircle
|
||||||
|
size={14}
|
||||||
|
className="text-destructive"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "pending":
|
||||||
|
return (
|
||||||
|
<Loader2
|
||||||
|
size={14}
|
||||||
|
className="animate-spin"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case "interrupted":
|
||||||
|
return (
|
||||||
|
<StopCircle
|
||||||
|
size={14}
|
||||||
|
className="text-orange-500"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Terminal
|
||||||
|
size={14}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
|
const toggleExpanded = useCallback(() => {
|
||||||
|
setIsExpanded((prev) => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleArgExpanded = useCallback((argKey: string) => {
|
||||||
|
setExpandedArgs((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[argKey]: !prev[argKey],
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasContent = result || Object.keys(args).length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full overflow-hidden rounded-lg border-none shadow-none outline-none transition-colors duration-200 hover:bg-accent",
|
||||||
|
isExpanded && hasContent && "bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={toggleExpanded}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center justify-between gap-2 border-none px-2 py-2 text-left shadow-none outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-default"
|
||||||
|
)}
|
||||||
|
disabled={!hasContent}
|
||||||
|
>
|
||||||
|
<div className="flex w-full items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{statusIcon}
|
||||||
|
<span className="text-[15px] font-medium tracking-[-0.6px] text-foreground">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{hasContent &&
|
||||||
|
(isExpanded ? (
|
||||||
|
<ChevronUp
|
||||||
|
size={14}
|
||||||
|
className="shrink-0 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className="shrink-0 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isExpanded && hasContent && (
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
{uiComponent && stream && graphId ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<LoadExternalComponent
|
||||||
|
key={uiComponent.id}
|
||||||
|
stream={stream}
|
||||||
|
message={uiComponent}
|
||||||
|
namespace={graphId}
|
||||||
|
meta={{ status, args, result: result ?? "No Result Yet" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : actionRequest && onResume ? (
|
||||||
|
// Show tool approval UI when there's an action request but no GenUI
|
||||||
|
<div className="mt-4">
|
||||||
|
<ToolApprovalInterrupt
|
||||||
|
actionRequest={actionRequest}
|
||||||
|
reviewConfig={reviewConfig}
|
||||||
|
onResume={onResume}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{Object.keys(args).length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Arguments
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{Object.entries(args).map(([key, value]) => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="rounded-sm border border-border"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleArgExpanded(key)}
|
||||||
|
className="flex w-full items-center justify-between bg-muted/30 p-2 text-left text-xs font-medium transition-colors hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<span className="font-mono">{key}</span>
|
||||||
|
{expandedArgs[key] ? (
|
||||||
|
<ChevronUp
|
||||||
|
size={12}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ChevronDown
|
||||||
|
size={12}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{expandedArgs[key] && (
|
||||||
|
<div className="border-t border-border bg-muted/20 p-2">
|
||||||
|
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs leading-6 text-foreground">
|
||||||
|
{typeof value === "string"
|
||||||
|
? value
|
||||||
|
: JSON.stringify(value, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Result
|
||||||
|
</h4>
|
||||||
|
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-all rounded-sm border border-border bg-muted/40 p-2 font-mono text-xs leading-7 text-foreground">
|
||||||
|
{typeof result === "string"
|
||||||
|
? result
|
||||||
|
: JSON.stringify(result, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
ToolCallBox.displayName = "ToolCallBox";
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,395 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
/* Remove default focus box-shadows */
|
||||||
|
input:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus {
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Set default outline color to match brand instead of browser blue */
|
||||||
|
* {
|
||||||
|
outline-color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* App-specific color variables */
|
||||||
|
--color-primary: #1c3c3c;
|
||||||
|
--color-user-message: #076699;
|
||||||
|
--color-user-message-bg: #e8f4f8;
|
||||||
|
--color-avatar-bg: #e8ebeb;
|
||||||
|
--color-secondary: #1c3c3c;
|
||||||
|
--color-success: #10b981;
|
||||||
|
--color-warning: #f59e0b;
|
||||||
|
--color-error: #ef4444;
|
||||||
|
--color-background: #f9f9f9;
|
||||||
|
--color-subagent-hover: #bbc4c4;
|
||||||
|
--color-surface: #f9fafb;
|
||||||
|
--color-border: #e5e7eb;
|
||||||
|
--color-border-light: #f3f4f6;
|
||||||
|
--color-text-primary: #111827;
|
||||||
|
--color-text-secondary: #6b7280;
|
||||||
|
--color-text-tertiary: #9ca3af;
|
||||||
|
--color-file-button: #ffffff;
|
||||||
|
--color-file-button-hover: #e5e7eb;
|
||||||
|
|
||||||
|
/* Dark theme colors */
|
||||||
|
--color-primary-dark: #2dd4bf;
|
||||||
|
--color-user-message-dark: #076699;
|
||||||
|
--color-avatar-bg-dark: #bcb2fd;
|
||||||
|
--color-secondary-dark: #2dd4bf;
|
||||||
|
--color-success-dark: #34d399;
|
||||||
|
--color-warning-dark: #fbbf24;
|
||||||
|
--color-error-dark: #f87171;
|
||||||
|
--color-background-dark: #0f0f0f;
|
||||||
|
--color-subagent-hover-dark: #d0c9fe;
|
||||||
|
--color-surface-dark: #1a1a1a;
|
||||||
|
--color-border-dark: #2d2d2d;
|
||||||
|
--color-border-light-dark: #232323;
|
||||||
|
--color-text-primary-dark: #f3f4f6;
|
||||||
|
--color-text-secondary-dark: #9ca3af;
|
||||||
|
--color-text-tertiary-dark: #6b7280;
|
||||||
|
|
||||||
|
/* Spacing variables */
|
||||||
|
--spacing-xs: 0.25rem;
|
||||||
|
--spacing-sm: 0.5rem;
|
||||||
|
--spacing-md: 1rem;
|
||||||
|
--spacing-lg: 1.5rem;
|
||||||
|
--spacing-xl: 2rem;
|
||||||
|
--spacing-2xl: 3rem;
|
||||||
|
|
||||||
|
/* Font family variables */
|
||||||
|
--font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
"Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-family-mono: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono",
|
||||||
|
Consolas, "Courier New", monospace;
|
||||||
|
|
||||||
|
/* Font size variables */
|
||||||
|
--font-size-xs: 0.75rem;
|
||||||
|
--font-size-sm: 0.875rem;
|
||||||
|
--font-size-base: 1rem;
|
||||||
|
--font-size-lg: 1.125rem;
|
||||||
|
--font-size-xl: 1.25rem;
|
||||||
|
--font-size-2xl: 1.5rem;
|
||||||
|
--font-size-3xl: 1.875rem;
|
||||||
|
|
||||||
|
/* Font weight variables */
|
||||||
|
--font-weight-medium: 500;
|
||||||
|
--font-weight-semibold: 600;
|
||||||
|
|
||||||
|
/* Line height variables */
|
||||||
|
--line-height-tight: 1.25;
|
||||||
|
--line-height-normal: 1.5;
|
||||||
|
--line-height-relaxed: 1.75;
|
||||||
|
|
||||||
|
/* Border radius variables */
|
||||||
|
--radius-sm: 0.25rem;
|
||||||
|
--radius-md: 0.375rem;
|
||||||
|
--radius-lg: 0.5rem;
|
||||||
|
--radius-full: 9999px;
|
||||||
|
|
||||||
|
/* Shadow variables */
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||||
|
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||||
|
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
|
/* Transition variables */
|
||||||
|
--transition-base: 200ms ease;
|
||||||
|
|
||||||
|
/* Layout variables */
|
||||||
|
--sidebar-width: 320px;
|
||||||
|
--sidebar-collapsed-width: 60px;
|
||||||
|
--header-height: 64px;
|
||||||
|
--panel-width: 40vw;
|
||||||
|
--chat-max-width: 900px;
|
||||||
|
|
||||||
|
/* Tailwind/Radix UI component variables */
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--background: 0 0% 98%;
|
||||||
|
--foreground: 220 13% 13%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 220 13% 13%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 220 13% 13%;
|
||||||
|
--primary: 180 35% 17%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--secondary: 220 13% 91%;
|
||||||
|
--secondary-foreground: 220 13% 13%;
|
||||||
|
--muted: 220 13% 95%;
|
||||||
|
--muted-foreground: 220 9% 46%;
|
||||||
|
--accent: 220 13% 95%;
|
||||||
|
--accent-foreground: 220 13% 13%;
|
||||||
|
--destructive: 0 84% 60%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--border: 220 13% 91%;
|
||||||
|
--input: 220 13% 91%;
|
||||||
|
--ring: 180 35% 17%;
|
||||||
|
--sidebar: 220 13% 95%;
|
||||||
|
--chart-1: 12 76% 61%;
|
||||||
|
--chart-2: 173 58% 39%;
|
||||||
|
--chart-3: 197 37% 24%;
|
||||||
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
/* App-specific color variables */
|
||||||
|
--color-primary: #1c3c3c;
|
||||||
|
--color-user-message: #065a8a;
|
||||||
|
--color-user-message-bg: #2d2d2d;
|
||||||
|
--color-avatar-bg: #1c3c3c;
|
||||||
|
--color-secondary: #bbc4c4;
|
||||||
|
--color-success: #34d399;
|
||||||
|
--color-warning: #fbbf24;
|
||||||
|
--color-error: #f87171;
|
||||||
|
--color-background: #202020;
|
||||||
|
--color-subagent-hover: #1e3f3f;
|
||||||
|
--color-surface: #2a2a2a;
|
||||||
|
--color-border: #404040;
|
||||||
|
--color-border-light: #353535;
|
||||||
|
--color-text-primary: #f3f4f6;
|
||||||
|
--color-text-secondary: #9ca3af;
|
||||||
|
--color-text-tertiary: #6b7280;
|
||||||
|
--color-file-button: #2a2a2a;
|
||||||
|
--color-file-button-hover: #353535;
|
||||||
|
|
||||||
|
/* Tailwind/Radix UI component variables for dark mode */
|
||||||
|
--radius: 0.5rem;
|
||||||
|
--background: 0 0% 13%;
|
||||||
|
--foreground: 220 13% 95%;
|
||||||
|
--card: 0 0% 18%;
|
||||||
|
--card-foreground: 220 13% 95%;
|
||||||
|
--popover: 0 0% 18%;
|
||||||
|
--popover-foreground: 220 13% 95%;
|
||||||
|
--primary: 174 72% 56%;
|
||||||
|
--primary-foreground: 0 0% 13%;
|
||||||
|
--secondary: 0 0% 25%;
|
||||||
|
--secondary-foreground: 220 13% 95%;
|
||||||
|
--muted: 0 0% 22%;
|
||||||
|
--muted-foreground: 220 9% 70%;
|
||||||
|
--accent: 0 0% 22%;
|
||||||
|
--accent-foreground: 220 13% 95%;
|
||||||
|
--destructive: 0 63% 71%;
|
||||||
|
--destructive-foreground: 0 0% 13%;
|
||||||
|
--border: 0 0% 28%;
|
||||||
|
--input: 0 0% 28%;
|
||||||
|
--ring: 174 72% 56%;
|
||||||
|
--sidebar: 0 0% 18%;
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-2: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
"Helvetica Neue", Arial, sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
background-color: var(--color-background);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.875rem;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
h5 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
h6 {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 200ms ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
|
||||||
|
"Courier New", monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding: 0.125em 0.25em;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
|
||||||
|
"Courier New", monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.75;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
code {
|
||||||
|
padding: 0;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
line-height: inherit;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optimization Window animations */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9) translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar styles */
|
||||||
|
.scrollbar-pretty {
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-pretty::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-pretty::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-pretty::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #d1d5db;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-pretty::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global diff highlighting styles */
|
||||||
|
.word-added {
|
||||||
|
background-color: rgba(46, 160, 67, 0.4);
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-removed {
|
||||||
|
background-color: rgba(248, 81, 73, 0.4);
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: line-through;
|
||||||
|
text-decoration-color: #f85149;
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||||
|
import {
|
||||||
|
type Message,
|
||||||
|
type Assistant,
|
||||||
|
type Checkpoint,
|
||||||
|
} from "@langchain/langgraph-sdk";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import type { UseStreamThread } from "@langchain/langgraph-sdk/react";
|
||||||
|
import type { TodoItem } from "@/app/types/types";
|
||||||
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
|
import { useQueryState } from "nuqs";
|
||||||
|
|
||||||
|
export type StateType = {
|
||||||
|
messages: Message[];
|
||||||
|
todos: TodoItem[];
|
||||||
|
files: Record<string, string>;
|
||||||
|
email?: {
|
||||||
|
id?: string;
|
||||||
|
subject?: string;
|
||||||
|
page_content?: string;
|
||||||
|
};
|
||||||
|
ui?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useChat({
|
||||||
|
activeAssistant,
|
||||||
|
onHistoryRevalidate,
|
||||||
|
thread,
|
||||||
|
}: {
|
||||||
|
activeAssistant: Assistant | null;
|
||||||
|
onHistoryRevalidate?: () => void;
|
||||||
|
thread?: UseStreamThread<StateType>;
|
||||||
|
}) {
|
||||||
|
const [threadId, setThreadId] = useQueryState("threadId");
|
||||||
|
const client = useClient();
|
||||||
|
|
||||||
|
const stream = useStream<StateType>({
|
||||||
|
assistantId: activeAssistant?.assistant_id || "",
|
||||||
|
client: client ?? undefined,
|
||||||
|
reconnectOnMount: true,
|
||||||
|
threadId: threadId ?? null,
|
||||||
|
onThreadId: setThreadId,
|
||||||
|
defaultHeaders: { "x-auth-scheme": "langsmith" },
|
||||||
|
// Enable fetching state history when switching to existing threads
|
||||||
|
fetchStateHistory: true,
|
||||||
|
// Revalidate thread list when stream finishes, errors, or creates new thread
|
||||||
|
onFinish: onHistoryRevalidate,
|
||||||
|
onError: onHistoryRevalidate,
|
||||||
|
onCreated: onHistoryRevalidate,
|
||||||
|
experimental_thread: thread,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendMessage = useCallback(
|
||||||
|
(content: string) => {
|
||||||
|
const newMessage: Message = { id: uuidv4(), type: "human", content };
|
||||||
|
stream.submit(
|
||||||
|
{ messages: [newMessage] },
|
||||||
|
{
|
||||||
|
optimisticValues: (prev) => ({
|
||||||
|
messages: [...(prev.messages ?? []), newMessage],
|
||||||
|
}),
|
||||||
|
config: { ...(activeAssistant?.config ?? {}), recursion_limit: 100 },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Update thread list immediately when sending a message
|
||||||
|
onHistoryRevalidate?.();
|
||||||
|
},
|
||||||
|
[stream, activeAssistant?.config, onHistoryRevalidate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const runSingleStep = useCallback(
|
||||||
|
(
|
||||||
|
messages: Message[],
|
||||||
|
checkpoint?: Checkpoint,
|
||||||
|
isRerunningSubagent?: boolean,
|
||||||
|
optimisticMessages?: Message[]
|
||||||
|
) => {
|
||||||
|
if (checkpoint) {
|
||||||
|
stream.submit(undefined, {
|
||||||
|
...(optimisticMessages
|
||||||
|
? { optimisticValues: { messages: optimisticMessages } }
|
||||||
|
: {}),
|
||||||
|
config: activeAssistant?.config,
|
||||||
|
checkpoint: checkpoint,
|
||||||
|
...(isRerunningSubagent
|
||||||
|
? { interruptAfter: ["tools"] }
|
||||||
|
: { interruptBefore: ["tools"] }),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
stream.submit(
|
||||||
|
{ messages },
|
||||||
|
{ config: activeAssistant?.config, interruptBefore: ["tools"] }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[stream, activeAssistant?.config]
|
||||||
|
);
|
||||||
|
|
||||||
|
const setFiles = useCallback(
|
||||||
|
async (files: Record<string, string>) => {
|
||||||
|
if (!threadId) return;
|
||||||
|
// TODO: missing a way how to revalidate the internal state
|
||||||
|
// I think we do want to have the ability to externally manage the state
|
||||||
|
await client.threads.updateState(threadId, { values: { files } });
|
||||||
|
},
|
||||||
|
[client, threadId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const continueStream = useCallback(
|
||||||
|
(hasTaskToolCall?: boolean) => {
|
||||||
|
stream.submit(undefined, {
|
||||||
|
config: {
|
||||||
|
...(activeAssistant?.config || {}),
|
||||||
|
recursion_limit: 100,
|
||||||
|
},
|
||||||
|
...(hasTaskToolCall
|
||||||
|
? { interruptAfter: ["tools"] }
|
||||||
|
: { interruptBefore: ["tools"] }),
|
||||||
|
});
|
||||||
|
// Update thread list when continuing stream
|
||||||
|
onHistoryRevalidate?.();
|
||||||
|
},
|
||||||
|
[stream, activeAssistant?.config, onHistoryRevalidate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const markCurrentThreadAsResolved = useCallback(() => {
|
||||||
|
stream.submit(null, { command: { goto: "__end__", update: null } });
|
||||||
|
// Update thread list when marking thread as resolved
|
||||||
|
onHistoryRevalidate?.();
|
||||||
|
}, [stream, onHistoryRevalidate]);
|
||||||
|
|
||||||
|
const resumeInterrupt = useCallback(
|
||||||
|
(value: any) => {
|
||||||
|
stream.submit(null, { command: { resume: value } });
|
||||||
|
// Update thread list when resuming from interrupt
|
||||||
|
onHistoryRevalidate?.();
|
||||||
|
},
|
||||||
|
[stream, onHistoryRevalidate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopStream = useCallback(() => {
|
||||||
|
stream.stop();
|
||||||
|
}, [stream]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stream,
|
||||||
|
todos: stream.values.todos ?? [],
|
||||||
|
files: stream.values.files ?? {},
|
||||||
|
email: stream.values.email,
|
||||||
|
ui: stream.values.ui,
|
||||||
|
setFiles,
|
||||||
|
messages: stream.messages,
|
||||||
|
isLoading: stream.isLoading,
|
||||||
|
isThreadLoading: stream.isThreadLoading,
|
||||||
|
interrupt: stream.interrupt,
|
||||||
|
getMessagesMetadata: stream.getMessagesMetadata,
|
||||||
|
sendMessage,
|
||||||
|
runSingleStep,
|
||||||
|
continueStream,
|
||||||
|
stopStream,
|
||||||
|
markCurrentThreadAsResolved,
|
||||||
|
resumeInterrupt,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import useSWRInfinite from "swr/infinite";
|
||||||
|
import type { Thread } from "@langchain/langgraph-sdk";
|
||||||
|
import { Client } from "@langchain/langgraph-sdk";
|
||||||
|
import { getConfig } from "@/lib/config";
|
||||||
|
|
||||||
|
export interface ThreadItem {
|
||||||
|
id: string;
|
||||||
|
updatedAt: Date;
|
||||||
|
status: Thread["status"];
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
assistantId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
export function useThreads(props: {
|
||||||
|
status?: Thread["status"];
|
||||||
|
limit?: number;
|
||||||
|
}) {
|
||||||
|
const pageSize = props.limit || DEFAULT_PAGE_SIZE;
|
||||||
|
|
||||||
|
return useSWRInfinite(
|
||||||
|
(pageIndex: number, previousPageData: ThreadItem[] | null) => {
|
||||||
|
const config = getConfig();
|
||||||
|
const apiKey =
|
||||||
|
config?.langsmithApiKey ||
|
||||||
|
process.env.NEXT_PUBLIC_LANGSMITH_API_KEY ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the previous page returned no items, we've reached the end
|
||||||
|
if (previousPageData && previousPageData.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "threads" as const,
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
deploymentUrl: config.deploymentUrl,
|
||||||
|
assistantId: config.assistantId,
|
||||||
|
apiKey,
|
||||||
|
status: props?.status,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async ({
|
||||||
|
deploymentUrl,
|
||||||
|
assistantId,
|
||||||
|
apiKey,
|
||||||
|
status,
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
}: {
|
||||||
|
kind: "threads";
|
||||||
|
pageIndex: number;
|
||||||
|
pageSize: number;
|
||||||
|
deploymentUrl: string;
|
||||||
|
assistantId: string;
|
||||||
|
apiKey: string;
|
||||||
|
status?: Thread["status"];
|
||||||
|
}) => {
|
||||||
|
const client = new Client({
|
||||||
|
apiUrl: deploymentUrl,
|
||||||
|
defaultHeaders: apiKey ? { "X-Api-Key": apiKey } : {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if assistantId is a UUID (deployed) or graph name (local)
|
||||||
|
const isUUID =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||||
|
assistantId
|
||||||
|
);
|
||||||
|
|
||||||
|
const threads = await client.threads.search({
|
||||||
|
limit: pageSize,
|
||||||
|
offset: pageIndex * pageSize,
|
||||||
|
sortBy: "updated_at" as const,
|
||||||
|
sortOrder: "desc" as const,
|
||||||
|
status,
|
||||||
|
// Only filter by assistant_id metadata for deployed graphs (UUIDs)
|
||||||
|
// Local dev graphs don't set this metadata
|
||||||
|
...(isUUID ? { metadata: { assistant_id: assistantId } } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return threads.map((thread): ThreadItem => {
|
||||||
|
let title = "Untitled Thread";
|
||||||
|
let description = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (thread.values && typeof thread.values === "object") {
|
||||||
|
const values = thread.values as any;
|
||||||
|
const firstHumanMessage = values.messages.find(
|
||||||
|
(m: any) => m.type === "human"
|
||||||
|
);
|
||||||
|
if (firstHumanMessage?.content) {
|
||||||
|
const content =
|
||||||
|
typeof firstHumanMessage.content === "string"
|
||||||
|
? firstHumanMessage.content
|
||||||
|
: firstHumanMessage.content[0]?.text || "";
|
||||||
|
title = content.slice(0, 50) + (content.length > 50 ? "..." : "");
|
||||||
|
}
|
||||||
|
const firstAiMessage = values.messages.find(
|
||||||
|
(m: any) => m.type === "ai"
|
||||||
|
);
|
||||||
|
if (firstAiMessage?.content) {
|
||||||
|
const content =
|
||||||
|
typeof firstAiMessage.content === "string"
|
||||||
|
? firstAiMessage.content
|
||||||
|
: firstAiMessage.content[0]?.text || "";
|
||||||
|
description = content.slice(0, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback to thread ID
|
||||||
|
title = `Thread ${thread.thread_id.slice(0, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: thread.thread_id,
|
||||||
|
updatedAt: new Date(thread.updated_at),
|
||||||
|
status: thread.status,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
assistantId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
revalidateFirstPage: true,
|
||||||
|
revalidateOnFocus: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Inter } from "next/font/google";
|
||||||
|
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||||
|
import { Toaster } from "sonner";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang="en"
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
|
<body
|
||||||
|
className={inter.className}
|
||||||
|
suppressHydrationWarning
|
||||||
|
>
|
||||||
|
<NuqsAdapter>{children}</NuqsAdapter>
|
||||||
|
<Toaster />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, Suspense } from "react";
|
||||||
|
import { useQueryState } from "nuqs";
|
||||||
|
import { getConfig, saveConfig, StandaloneConfig } from "@/lib/config";
|
||||||
|
import { ConfigDialog } from "@/app/components/ConfigDialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Assistant } from "@langchain/langgraph-sdk";
|
||||||
|
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
||||||
|
import { Settings, MessagesSquare, SquarePen } from "lucide-react";
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import { ThreadList } from "@/app/components/ThreadList";
|
||||||
|
import { ChatProvider } from "@/providers/ChatProvider";
|
||||||
|
import { ChatInterface } from "@/app/components/ChatInterface";
|
||||||
|
|
||||||
|
interface HomePageInnerProps {
|
||||||
|
config: StandaloneConfig;
|
||||||
|
configDialogOpen: boolean;
|
||||||
|
setConfigDialogOpen: (open: boolean) => void;
|
||||||
|
handleSaveConfig: (config: StandaloneConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomePageInner({
|
||||||
|
config,
|
||||||
|
configDialogOpen,
|
||||||
|
setConfigDialogOpen,
|
||||||
|
handleSaveConfig,
|
||||||
|
}: HomePageInnerProps) {
|
||||||
|
const client = useClient();
|
||||||
|
const [threadId, setThreadId] = useQueryState("threadId");
|
||||||
|
const [sidebar, setSidebar] = useQueryState("sidebar");
|
||||||
|
|
||||||
|
const [mutateThreads, setMutateThreads] = useState<(() => void) | null>(null);
|
||||||
|
const [interruptCount, setInterruptCount] = useState(0);
|
||||||
|
const [assistant, setAssistant] = useState<Assistant | null>(null);
|
||||||
|
|
||||||
|
const fetchAssistant = useCallback(async () => {
|
||||||
|
const isUUID =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||||
|
config.assistantId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isUUID) {
|
||||||
|
// We should try to fetch the assistant directly with this UUID
|
||||||
|
try {
|
||||||
|
const data = await client.assistants.get(config.assistantId);
|
||||||
|
setAssistant(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch assistant:", error);
|
||||||
|
setAssistant({
|
||||||
|
assistant_id: config.assistantId,
|
||||||
|
graph_id: config.assistantId,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
config: {},
|
||||||
|
metadata: {},
|
||||||
|
version: 1,
|
||||||
|
name: "Assistant",
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
// We should try to list out the assistants for this graph, and then use the default one.
|
||||||
|
// TODO: Paginate this search, but 100 should be enough for graph name
|
||||||
|
const assistants = await client.assistants.search({
|
||||||
|
graphId: config.assistantId,
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
const defaultAssistant = assistants.find(
|
||||||
|
(assistant) => assistant.metadata?.["created_by"] === "system"
|
||||||
|
);
|
||||||
|
if (defaultAssistant === undefined) {
|
||||||
|
throw new Error("No default assistant found");
|
||||||
|
}
|
||||||
|
setAssistant(defaultAssistant);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"Failed to find default assistant from graph_id: try setting the assistant_id directly:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
setAssistant({
|
||||||
|
assistant_id: config.assistantId,
|
||||||
|
graph_id: config.assistantId,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
config: {},
|
||||||
|
metadata: {},
|
||||||
|
version: 1,
|
||||||
|
name: config.assistantId,
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [client, config.assistantId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAssistant();
|
||||||
|
}, [fetchAssistant]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConfigDialog
|
||||||
|
open={configDialogOpen}
|
||||||
|
onOpenChange={setConfigDialogOpen}
|
||||||
|
onSave={handleSaveConfig}
|
||||||
|
initialConfig={config}
|
||||||
|
/>
|
||||||
|
<div className="flex h-screen flex-col">
|
||||||
|
<header className="flex h-16 items-center justify-between border-b border-border px-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<h1 className="text-xl font-semibold">BroJS Agent</h1>
|
||||||
|
{!sidebar && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSidebar("1")}
|
||||||
|
className="rounded-md border border-border bg-card p-3 text-foreground hover:bg-accent"
|
||||||
|
>
|
||||||
|
<MessagesSquare className="mr-2 h-4 w-4" />
|
||||||
|
Threads
|
||||||
|
{interruptCount > 0 && (
|
||||||
|
<span className="ml-2 inline-flex min-h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] text-destructive-foreground">
|
||||||
|
{interruptCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
<span className="font-medium">Assistant:</span>{" "}
|
||||||
|
{config.assistantId}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setConfigDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<Settings className="mr-2 h-4 w-4" />
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setThreadId(null)}
|
||||||
|
disabled={!threadId}
|
||||||
|
className="border-[#2F6868] bg-[#2F6868] text-white hover:bg-[#2F6868]/80"
|
||||||
|
>
|
||||||
|
<SquarePen className="mr-2 h-4 w-4" />
|
||||||
|
New Thread
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<ResizablePanelGroup
|
||||||
|
direction="horizontal"
|
||||||
|
autoSaveId="standalone-chat"
|
||||||
|
>
|
||||||
|
{sidebar && (
|
||||||
|
<>
|
||||||
|
<ResizablePanel
|
||||||
|
id="thread-history"
|
||||||
|
order={1}
|
||||||
|
defaultSize={25}
|
||||||
|
minSize={20}
|
||||||
|
className="relative min-w-[380px]"
|
||||||
|
>
|
||||||
|
<ThreadList
|
||||||
|
onThreadSelect={async (id) => {
|
||||||
|
await setThreadId(id);
|
||||||
|
}}
|
||||||
|
onMutateReady={(fn) => setMutateThreads(() => fn)}
|
||||||
|
onClose={() => setSidebar(null)}
|
||||||
|
onInterruptCountChange={setInterruptCount}
|
||||||
|
/>
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ResizablePanel
|
||||||
|
id="chat"
|
||||||
|
className="relative flex flex-col"
|
||||||
|
order={2}
|
||||||
|
>
|
||||||
|
<ChatProvider
|
||||||
|
activeAssistant={assistant}
|
||||||
|
onHistoryRevalidate={() => mutateThreads?.()}
|
||||||
|
>
|
||||||
|
<ChatInterface assistant={assistant} />
|
||||||
|
</ChatProvider>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomePageContent() {
|
||||||
|
const [config, setConfig] = useState<StandaloneConfig | null>(null);
|
||||||
|
const [configDialogOpen, setConfigDialogOpen] = useState(false);
|
||||||
|
const [assistantId, setAssistantId] = useQueryState("assistantId");
|
||||||
|
|
||||||
|
// On mount, check for saved config, otherwise show config dialog
|
||||||
|
useEffect(() => {
|
||||||
|
const savedConfig = getConfig();
|
||||||
|
if (savedConfig) {
|
||||||
|
setConfig(savedConfig);
|
||||||
|
if (!assistantId) {
|
||||||
|
setAssistantId(savedConfig.assistantId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setConfigDialogOpen(true);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// If config changes, update the assistantId
|
||||||
|
useEffect(() => {
|
||||||
|
if (config && !assistantId) {
|
||||||
|
setAssistantId(config.assistantId);
|
||||||
|
}
|
||||||
|
}, [config, assistantId, setAssistantId]);
|
||||||
|
|
||||||
|
const handleSaveConfig = useCallback((newConfig: StandaloneConfig) => {
|
||||||
|
saveConfig(newConfig);
|
||||||
|
setConfig(newConfig);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const langsmithApiKey =
|
||||||
|
config?.langsmithApiKey || process.env.NEXT_PUBLIC_LANGSMITH_API_KEY || "";
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ConfigDialog
|
||||||
|
open={configDialogOpen}
|
||||||
|
onOpenChange={setConfigDialogOpen}
|
||||||
|
onSave={handleSaveConfig}
|
||||||
|
/>
|
||||||
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold">Welcome to Standalone Chat</h1>
|
||||||
|
<p className="mt-2 text-muted-foreground">
|
||||||
|
Configure your deployment to get started
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => setConfigDialogOpen(true)}
|
||||||
|
className="mt-4"
|
||||||
|
>
|
||||||
|
Open Configuration
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ClientProvider
|
||||||
|
deploymentUrl={config.deploymentUrl}
|
||||||
|
apiKey={langsmithApiKey}
|
||||||
|
>
|
||||||
|
<HomePageInner
|
||||||
|
config={config}
|
||||||
|
configDialogOpen={configDialogOpen}
|
||||||
|
setConfigDialogOpen={setConfigDialogOpen}
|
||||||
|
handleSaveConfig={handleSaveConfig}
|
||||||
|
/>
|
||||||
|
</ClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomePage() {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
<p className="text-muted-foreground">Loading...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<HomePageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
export interface ToolCall {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
result?: string;
|
||||||
|
status: "pending" | "completed" | "error" | "interrupted";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubAgent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
subAgentName: string;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
output?: Record<string, unknown>;
|
||||||
|
status: "pending" | "active" | "completed" | "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileItem {
|
||||||
|
path: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TodoItem {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
status: "pending" | "in_progress" | "completed";
|
||||||
|
updatedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Thread {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InterruptData {
|
||||||
|
value: any;
|
||||||
|
ns?: string[];
|
||||||
|
scope?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionRequest {
|
||||||
|
name: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewConfig {
|
||||||
|
actionName: string;
|
||||||
|
allowedDecisions?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolApprovalInterruptData {
|
||||||
|
action_requests: ActionRequest[];
|
||||||
|
review_configs?: ReviewConfig[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { Message } from "@langchain/langgraph-sdk";
|
||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractStringFromMessageContent(message: Message): string {
|
||||||
|
return typeof message.content === "string"
|
||||||
|
? message.content
|
||||||
|
: Array.isArray(message.content)
|
||||||
|
? message.content
|
||||||
|
.filter(
|
||||||
|
(c: unknown) =>
|
||||||
|
(typeof c === "object" &&
|
||||||
|
c !== null &&
|
||||||
|
"type" in c &&
|
||||||
|
(c as { type: string }).type === "text") ||
|
||||||
|
typeof c === "string"
|
||||||
|
)
|
||||||
|
.map((c: unknown) =>
|
||||||
|
typeof c === "string"
|
||||||
|
? c
|
||||||
|
: typeof c === "object" && c !== null && "text" in c
|
||||||
|
? (c as { text?: string }).text || ""
|
||||||
|
: ""
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractSubAgentContent(data: unknown): string {
|
||||||
|
if (typeof data === "string") {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && typeof data === "object") {
|
||||||
|
const dataObj = data as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Try to extract description first
|
||||||
|
if (dataObj.description && typeof dataObj.description === "string") {
|
||||||
|
return dataObj.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then try prompt
|
||||||
|
if (dataObj.prompt && typeof dataObj.prompt === "string") {
|
||||||
|
return dataObj.prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For output objects, try result
|
||||||
|
if (dataObj.result && typeof dataObj.result === "string") {
|
||||||
|
return dataObj.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to JSON stringification
|
||||||
|
return JSON.stringify(data, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for any other type
|
||||||
|
return JSON.stringify(data, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPreparingToCallTaskTool(messages: Message[]): boolean {
|
||||||
|
const lastMessage = messages[messages.length - 1];
|
||||||
|
return (
|
||||||
|
(lastMessage.type === "ai" &&
|
||||||
|
lastMessage.tool_calls?.some(
|
||||||
|
(call: { name?: string }) => call.name === "task"
|
||||||
|
)) ||
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMessageForLLM(message: Message): string {
|
||||||
|
let role: string;
|
||||||
|
if (message.type === "human") {
|
||||||
|
role = "Human";
|
||||||
|
} else if (message.type === "ai") {
|
||||||
|
role = "Assistant";
|
||||||
|
} else if (message.type === "tool") {
|
||||||
|
role = `Tool Result`;
|
||||||
|
} else {
|
||||||
|
role = message.type || "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = message.id ? ` (${message.id.slice(0, 8)})` : "";
|
||||||
|
|
||||||
|
let contentText = "";
|
||||||
|
|
||||||
|
// Extract content text
|
||||||
|
if (typeof message.content === "string") {
|
||||||
|
contentText = message.content;
|
||||||
|
} else if (Array.isArray(message.content)) {
|
||||||
|
const textParts: string[] = [];
|
||||||
|
|
||||||
|
message.content.forEach((part: any) => {
|
||||||
|
if (typeof part === "string") {
|
||||||
|
textParts.push(part);
|
||||||
|
} else if (part && typeof part === "object" && part.type === "text") {
|
||||||
|
textParts.push(part.text || "");
|
||||||
|
}
|
||||||
|
// Ignore other types like tool_use in content - we handle tool calls separately
|
||||||
|
});
|
||||||
|
|
||||||
|
contentText = textParts.join("\n\n").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// For tool messages, include additional tool metadata
|
||||||
|
if (message.type === "tool") {
|
||||||
|
const toolName = (message as any).name || "unknown_tool";
|
||||||
|
const toolCallId = (message as any).tool_call_id || "";
|
||||||
|
role = `Tool Result [${toolName}]`;
|
||||||
|
if (toolCallId) {
|
||||||
|
role += ` (call_id: ${toolCallId.slice(0, 8)})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tool calls from .tool_calls property (for AI messages)
|
||||||
|
const toolCallsText: string[] = [];
|
||||||
|
if (
|
||||||
|
message.type === "ai" &&
|
||||||
|
message.tool_calls &&
|
||||||
|
Array.isArray(message.tool_calls) &&
|
||||||
|
message.tool_calls.length > 0
|
||||||
|
) {
|
||||||
|
message.tool_calls.forEach((call: any) => {
|
||||||
|
const toolName = call.name || "unknown_tool";
|
||||||
|
const toolArgs = call.args ? JSON.stringify(call.args, null, 2) : "{}";
|
||||||
|
toolCallsText.push(`[Tool Call: ${toolName}]\nArguments: ${toolArgs}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combine content and tool calls
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (contentText) {
|
||||||
|
parts.push(contentText);
|
||||||
|
}
|
||||||
|
if (toolCallsText.length > 0) {
|
||||||
|
parts.push(...toolCallsText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return `${role}${timestamp}: [Empty message]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 1) {
|
||||||
|
return `${role}${timestamp}: ${parts[0]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${role}${timestamp}:\n${parts.join("\n\n")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatConversationForLLM(messages: Message[]): string {
|
||||||
|
const formattedMessages = messages.map(formatMessageForLLM);
|
||||||
|
return formattedMessages.join("\n\n---\n\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Root
|
||||||
|
data-slot="dialog"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Trigger
|
||||||
|
data-slot="dialog-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Portal
|
||||||
|
data-slot="dialog-portal"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="focus:outline-hidden absolute right-4 top-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg font-semibold leading-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] selection:bg-blue-200 selection:text-gray-900 file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30 dark:selection:bg-blue-600 dark:selection:text-white md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
);
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Label };
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { GripVertical } from "lucide-react";
|
||||||
|
import * as ResizablePrimitive from "react-resizable-panels";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const ResizablePanelGroup = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||||
|
<ResizablePrimitive.PanelGroup
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ResizablePanel = ResizablePrimitive.Panel;
|
||||||
|
|
||||||
|
const ResizableHandle = ({
|
||||||
|
withHandle,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||||
|
withHandle?: boolean;
|
||||||
|
}) => (
|
||||||
|
<ResizablePrimitive.PanelResizeHandle
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{withHandle && (
|
||||||
|
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||||
|
<GripVertical className="h-2.5 w-2.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePrimitive.PanelResizeHandle>
|
||||||
|
);
|
||||||
|
|
||||||
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="size-full rounded-[inherit] outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none select-none p-px transition-colors",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="relative flex-1 rounded-full bg-border"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar };
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root;
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group;
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
));
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
));
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
));
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName;
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] origin-[--radix-select-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
));
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
));
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
};
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Skeleton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Skeleton };
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
height: "20px",
|
||||||
|
width: "36px",
|
||||||
|
alignItems: "center",
|
||||||
|
borderRadius: "9999px",
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
backgroundColor: "var(--color-border)",
|
||||||
|
cursor: "pointer",
|
||||||
|
transition: "background-color 0.2s",
|
||||||
|
}}
|
||||||
|
data-state-styles={{
|
||||||
|
checked: {
|
||||||
|
backgroundColor: "var(--color-primary)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:!bg-[var(--color-primary)]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
width: "16px",
|
||||||
|
height: "16px",
|
||||||
|
borderRadius: "9999px",
|
||||||
|
backgroundColor: "white",
|
||||||
|
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.2)",
|
||||||
|
transition: "transform 0.2s",
|
||||||
|
transform: "translateX(1px)",
|
||||||
|
}}
|
||||||
|
className="data-[state=checked]:!translate-x-[17px]"
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium text-foreground transition-[color,box-shadow] focus-visible:border-ring focus-visible:outline-1 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:shadow-sm dark:text-muted-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Textarea.displayName = "Textarea";
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Button } from "./button";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "./tooltip";
|
||||||
|
|
||||||
|
interface TooltipIconButtonProps {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
tooltip: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TooltipIconButton({
|
||||||
|
icon,
|
||||||
|
onClick,
|
||||||
|
tooltip,
|
||||||
|
disabled,
|
||||||
|
}: TooltipIconButtonProps) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{tooltip}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delayDuration = 0,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delayDuration={delayDuration}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TooltipProvider>
|
||||||
|
<TooltipPrimitive.Root
|
||||||
|
data-slot="tooltip"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Trigger
|
||||||
|
data-slot="tooltip-trigger"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
sideOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"text-primary-foreground origin-(--radix-tooltip-content-transform-origin) z-50 w-fit text-balance rounded-md bg-primary px-3 py-1.5 text-xs animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-primary fill-primary" />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export interface StandaloneConfig {
|
||||||
|
deploymentUrl: string;
|
||||||
|
assistantId: string;
|
||||||
|
langsmithApiKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIG_KEY = "deep-agent-config";
|
||||||
|
|
||||||
|
export function getEnvConfig(): StandaloneConfig | null {
|
||||||
|
const deploymentUrl = process.env.NEXT_PUBLIC_DEPLOYMENT_URL;
|
||||||
|
const assistantId = process.env.NEXT_PUBLIC_ASSISTANT_ID;
|
||||||
|
if (!deploymentUrl || !assistantId) return null;
|
||||||
|
|
||||||
|
const langsmithApiKey = process.env.NEXT_PUBLIC_LANGSMITH_API_KEY;
|
||||||
|
return {
|
||||||
|
deploymentUrl,
|
||||||
|
assistantId,
|
||||||
|
langsmithApiKey: langsmithApiKey || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfig(): StandaloneConfig | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
|
||||||
|
const stored = localStorage.getItem(CONFIG_KEY);
|
||||||
|
if (stored) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(stored);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return getEnvConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveConfig(config: StandaloneConfig): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ReactNode, createContext, useContext } from "react";
|
||||||
|
import { Assistant } from "@langchain/langgraph-sdk";
|
||||||
|
import { type StateType, useChat } from "@/app/hooks/useChat";
|
||||||
|
import type { UseStreamThread } from "@langchain/langgraph-sdk/react";
|
||||||
|
|
||||||
|
interface ChatProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
activeAssistant: Assistant | null;
|
||||||
|
onHistoryRevalidate?: () => void;
|
||||||
|
thread?: UseStreamThread<StateType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatProvider({
|
||||||
|
children,
|
||||||
|
activeAssistant,
|
||||||
|
onHistoryRevalidate,
|
||||||
|
thread,
|
||||||
|
}: ChatProviderProps) {
|
||||||
|
const chat = useChat({ activeAssistant, onHistoryRevalidate, thread });
|
||||||
|
return <ChatContext.Provider value={chat}>{children}</ChatContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChatContextType = ReturnType<typeof useChat>;
|
||||||
|
|
||||||
|
export const ChatContext = createContext<ChatContextType | undefined>(
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
export function useChatContext() {
|
||||||
|
const context = useContext(ChatContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error("useChatContext must be used within a ChatProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext, useMemo, ReactNode } from "react";
|
||||||
|
import { Client } from "@langchain/langgraph-sdk";
|
||||||
|
|
||||||
|
interface ClientContextValue {
|
||||||
|
client: Client;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClientContext = createContext<ClientContextValue | null>(null);
|
||||||
|
|
||||||
|
interface ClientProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
deploymentUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientProvider({
|
||||||
|
children,
|
||||||
|
deploymentUrl,
|
||||||
|
apiKey,
|
||||||
|
}: ClientProviderProps) {
|
||||||
|
const client = useMemo(() => {
|
||||||
|
return new Client({
|
||||||
|
apiUrl: deploymentUrl,
|
||||||
|
defaultHeaders: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Api-Key": apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [deploymentUrl, apiKey]);
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ client }), [client]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ClientContext.Provider value={value}>{children}</ClientContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClient(): Client {
|
||||||
|
const context = useContext(ClientContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useClient must be used within a ClientProvider");
|
||||||
|
}
|
||||||
|
return context.client;
|
||||||
|
}
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
import { blackA, green, mauve, slate, violet } from "@radix-ui/colors";
|
||||||
|
import plugin from "tailwindcss/plugin";
|
||||||
|
import containerQueries from "@tailwindcss/container-queries";
|
||||||
|
import typography from "@tailwindcss/typography";
|
||||||
|
import forms from "@tailwindcss/forms";
|
||||||
|
import tailwindcssAnimate from "tailwindcss-animate";
|
||||||
|
import headlessui from "@headlessui/tailwindcss";
|
||||||
|
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||||
|
darkMode: ["class", '[data-joy-color-scheme="dark"]'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontSize: {
|
||||||
|
xxs: [
|
||||||
|
"0.75rem", // 12px
|
||||||
|
{
|
||||||
|
lineHeight: "1.125rem", // 18px
|
||||||
|
},
|
||||||
|
],
|
||||||
|
xs: [
|
||||||
|
"0.8125rem", // 13px
|
||||||
|
{
|
||||||
|
lineHeight: "1.125rem", // 18px
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sm: [
|
||||||
|
"0.875rem", // 14px
|
||||||
|
{
|
||||||
|
lineHeight: "1.25rem", // 20px
|
||||||
|
},
|
||||||
|
],
|
||||||
|
base: [
|
||||||
|
"1rem", // 16px
|
||||||
|
{
|
||||||
|
lineHeight: "1.5rem", // 24px
|
||||||
|
},
|
||||||
|
],
|
||||||
|
lg: [
|
||||||
|
"1.125rem", // 18px
|
||||||
|
{
|
||||||
|
lineHeight: "1.75rem", // 28px
|
||||||
|
letterSpacing: "-0.01em", // tracking-tight
|
||||||
|
},
|
||||||
|
],
|
||||||
|
xl: [
|
||||||
|
"1.25rem", // 20px
|
||||||
|
{
|
||||||
|
lineHeight: "1.875rem", // 30px
|
||||||
|
letterSpacing: "-0.01em", // tracking-tight
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
mono: [
|
||||||
|
`"Fira Code"`,
|
||||||
|
`ui-monospace`,
|
||||||
|
`SFMono-Regular`,
|
||||||
|
`Menlo`,
|
||||||
|
`Monaco`,
|
||||||
|
`Consolas`,
|
||||||
|
`"Liberation Mono"`,
|
||||||
|
`"Courier New"`,
|
||||||
|
`monospace`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
letterSpacing: {
|
||||||
|
tighter: "-0.04em",
|
||||||
|
tight: "-0.03em",
|
||||||
|
snug: "-0.02em",
|
||||||
|
normal: "0",
|
||||||
|
wide: "0.03em",
|
||||||
|
},
|
||||||
|
lineHeight: {
|
||||||
|
tight: "1.20",
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
navMenu: "linear-gradient(132deg, #4499F7 0%, #3FCDD6 100%)",
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: "var(--radius)",
|
||||||
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
sm: "calc(var(--radius) - 4px)",
|
||||||
|
xs: "3px",
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
xs: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||||
|
},
|
||||||
|
backgroundColor: {
|
||||||
|
primary: "var(--bg-primary)",
|
||||||
|
"primary-hover": "var(--bg-primary_hover)",
|
||||||
|
secondary: "var(--bg-secondary)",
|
||||||
|
"secondary-hover": "var(--bg-secondary_hover)",
|
||||||
|
tertiary: "var(--bg-tertiary)",
|
||||||
|
quaternary: "var(--bg-quaternary)",
|
||||||
|
|
||||||
|
"brand-primary": "var(--bg-brand-primary)",
|
||||||
|
"brand-primary-hover": "var(--bg-brand-primary_hover)",
|
||||||
|
"brand-secondary": "var(--bg-brand-secondary)",
|
||||||
|
"brand-tertiary": "var(--bg-brand-tertiary)",
|
||||||
|
purple: "var(--bg-purple)",
|
||||||
|
|
||||||
|
"success-primary": "var(--bg-success-primary)",
|
||||||
|
"success-secondary": "var(--bg-success-secondary)",
|
||||||
|
"success-strong": "var(--bg-success-strong)",
|
||||||
|
"error-primary": "var(--bg-error-primary)",
|
||||||
|
"error-secondary": "var(--bg-error-secondary)",
|
||||||
|
"error-strong": "var(--bg-error-strong)",
|
||||||
|
"error-strong-hover": "var(--bg-error-strong-hover)",
|
||||||
|
"warning-primary": "var(--bg-warning-primary)",
|
||||||
|
"warning-secondary": "var(--bg-warning-secondary)",
|
||||||
|
"warning-strong": "var(--bg-warning-strong)",
|
||||||
|
},
|
||||||
|
borderColor: {
|
||||||
|
primary: "var(--border-primary)",
|
||||||
|
secondary: "var(--border-secondary)",
|
||||||
|
tertiary: "var(--border-tertiary)",
|
||||||
|
error: "var(--border-error)",
|
||||||
|
"error-strong": "var(--border-error-strong)",
|
||||||
|
brand: "var(--border-brand)",
|
||||||
|
"brand-strong": "var(--border-brand-strong)",
|
||||||
|
"brand-subtle": "var(--border-brand-subtle)",
|
||||||
|
strong: "var(--border-strong)",
|
||||||
|
warning: "var(--border-warning)",
|
||||||
|
success: "var(--border-success)",
|
||||||
|
purple: "var(--border-purple)",
|
||||||
|
"status-green": "var(--border-status-green)",
|
||||||
|
"status-orange": "var(--border-status-orange)",
|
||||||
|
"status-yellow": "var(--border-status-yellow)",
|
||||||
|
"status-red": "var(--border-status-red)",
|
||||||
|
},
|
||||||
|
textColor: {
|
||||||
|
primary: "var(--text-primary)",
|
||||||
|
secondary: "var(--text-secondary)",
|
||||||
|
tertiary: "var(--text-tertiary)",
|
||||||
|
quaternary: "var(--text-quaternary)",
|
||||||
|
disabled: "var(--text-disabled)",
|
||||||
|
error: "var(--text-error)",
|
||||||
|
warning: "var(--text-warning)",
|
||||||
|
success: "var(--text-success)",
|
||||||
|
placeholder: "var(--text-placeholder)",
|
||||||
|
purple: "var(--text-purple)",
|
||||||
|
"brand-primary": "var(--text-brand-primary)",
|
||||||
|
"brand-secondary": "var(--text-brand-secondary)",
|
||||||
|
"brand-tertiary": "var(--text-brand-tertiary)",
|
||||||
|
"brand-disabled": "var(--text-brand-disabled)",
|
||||||
|
"status-green": "var(--text-status-green)",
|
||||||
|
"status-orange": "var(--text-status-orange)",
|
||||||
|
"status-yellow": "var(--text-status-yellow)",
|
||||||
|
"status-red": "var(--text-status-red)",
|
||||||
|
"button-primary": "var(--text-button-primary)",
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
border: "hsl(var(--border))",
|
||||||
|
input: "hsl(var(--input))",
|
||||||
|
ring: "hsl(var(--ring))",
|
||||||
|
background: "hsl(var(--background))",
|
||||||
|
foreground: "hsl(var(--foreground))",
|
||||||
|
primary: {
|
||||||
|
DEFAULT: "hsl(var(--primary))",
|
||||||
|
foreground: "hsl(var(--primary-foreground))",
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: "hsl(var(--secondary))",
|
||||||
|
foreground: "hsl(var(--secondary-foreground))",
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: "hsl(var(--destructive))",
|
||||||
|
foreground: "hsl(var(--destructive-foreground))",
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: "hsl(var(--muted))",
|
||||||
|
foreground: "hsl(var(--muted-foreground))",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "hsl(var(--accent))",
|
||||||
|
foreground: "hsl(var(--accent-foreground))",
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: "hsl(var(--popover))",
|
||||||
|
foreground: "hsl(var(--popover-foreground))",
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: "hsl(var(--card))",
|
||||||
|
foreground: "hsl(var(--card-foreground))",
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
DEFAULT: "hsl(var(--sidebar))",
|
||||||
|
},
|
||||||
|
chart: {
|
||||||
|
1: "hsl(var(--chart-1))",
|
||||||
|
2: "hsl(var(--chart-2))",
|
||||||
|
3: "hsl(var(--chart-3))",
|
||||||
|
4: "hsl(var(--chart-4))",
|
||||||
|
5: "hsl(var(--chart-5))",
|
||||||
|
},
|
||||||
|
ls: {
|
||||||
|
blue: "hsl(211.5, 91.8%, 61.8%)",
|
||||||
|
black: "hsl(var(--ls-black))",
|
||||||
|
green: {
|
||||||
|
600: "hsl(122, 63%, 38%)",
|
||||||
|
},
|
||||||
|
white: "var(--white)",
|
||||||
|
black: "var(--black)",
|
||||||
|
red: {
|
||||||
|
25: "var(--red-25)",
|
||||||
|
50: "var(--red-50)",
|
||||||
|
100: "var(--red-100)",
|
||||||
|
200: "var(--red-200)",
|
||||||
|
300: "var(--red-300)",
|
||||||
|
400: "var(--red-400)",
|
||||||
|
500: "var(--red-500)",
|
||||||
|
600: "var(--red-600)",
|
||||||
|
700: "var(--red-700)",
|
||||||
|
800: "var(--red-800)",
|
||||||
|
900: "var(--red-900)",
|
||||||
|
950: "var(--red-950)",
|
||||||
|
},
|
||||||
|
orange: {
|
||||||
|
25: "var(--orange-25)",
|
||||||
|
50: "var(--orange-50)",
|
||||||
|
100: "var(--orange-100)",
|
||||||
|
200: "var(--orange-200)",
|
||||||
|
300: "var(--orange-300)",
|
||||||
|
400: "var(--orange-400)",
|
||||||
|
500: "var(--orange-500)",
|
||||||
|
600: "var(--orange-600)",
|
||||||
|
700: "var(--orange-700)",
|
||||||
|
800: "var(--orange-800)",
|
||||||
|
900: "var(--orange-900)",
|
||||||
|
950: "var(--orange-950)",
|
||||||
|
},
|
||||||
|
gray: {
|
||||||
|
50: "var(--gray-50)",
|
||||||
|
100: "var(--gray-100)",
|
||||||
|
200: "var(--gray-200)",
|
||||||
|
300: "var(--gray-300)",
|
||||||
|
400: "var(--gray-400)",
|
||||||
|
500: "var(--gray-500)",
|
||||||
|
600: "var(--gray-600)",
|
||||||
|
700: "var(--gray-700)",
|
||||||
|
800: "var(--gray-800)",
|
||||||
|
900: "var(--gray-900)",
|
||||||
|
950: "var(--gray-950)",
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
25: "var(--green-25)",
|
||||||
|
50: "var(--green-50)",
|
||||||
|
100: "var(--green-100)",
|
||||||
|
200: "var(--green-200)",
|
||||||
|
300: "var(--green-300)",
|
||||||
|
400: "var(--green-400)",
|
||||||
|
500: "var(--green-500)",
|
||||||
|
600: "var(--green-600)",
|
||||||
|
700: "var(--green-700)",
|
||||||
|
800: "var(--green-800)",
|
||||||
|
900: "var(--green-900)",
|
||||||
|
950: "var(--green-950)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
brand: {
|
||||||
|
green: {
|
||||||
|
25: "var(--brand-25)",
|
||||||
|
50: "var(--brand-50)",
|
||||||
|
100: "var(--brand-100)",
|
||||||
|
200: "var(--brand-200)",
|
||||||
|
300: "var(--brand-300)",
|
||||||
|
400: "var(--brand-400)",
|
||||||
|
500: "var(--brand-500)",
|
||||||
|
600: "var(--brand-600)",
|
||||||
|
700: "var(--brand-700)",
|
||||||
|
800: "var(--brand-800)",
|
||||||
|
900: "var(--brand-900)",
|
||||||
|
950: "var(--brand-950)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
hide: {
|
||||||
|
from: { opacity: 1 },
|
||||||
|
to: { opacity: 0 },
|
||||||
|
},
|
||||||
|
slideIn: {
|
||||||
|
from: {
|
||||||
|
transform: "translateX(calc(100% + var(--viewport-padding)))",
|
||||||
|
},
|
||||||
|
to: { transform: "translateX(0)" },
|
||||||
|
},
|
||||||
|
swipeOut: {
|
||||||
|
from: { transform: "translateX(var(--radix-toast-swipe-end-x))" },
|
||||||
|
to: { transform: "translateX(calc(100% + var(--viewport-padding)))" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
hide: "hide 100ms ease-in",
|
||||||
|
slideIn: "slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1)",
|
||||||
|
swipeOut: "swipeOut 100ms ease-out",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
playground: {
|
||||||
|
css: {
|
||||||
|
"h1, h2, h3, h4, h5, h6": {
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
h1: {
|
||||||
|
fontSize: "24px",
|
||||||
|
},
|
||||||
|
h2: {
|
||||||
|
fontSize: "20px",
|
||||||
|
},
|
||||||
|
h3: {
|
||||||
|
fontSize: "18px",
|
||||||
|
},
|
||||||
|
h4: {
|
||||||
|
fontSize: "16px",
|
||||||
|
},
|
||||||
|
h5: {
|
||||||
|
fontSize: "14px",
|
||||||
|
},
|
||||||
|
h6: {
|
||||||
|
fontSize: "12px",
|
||||||
|
},
|
||||||
|
ul: {
|
||||||
|
marginLeft: "20px !important",
|
||||||
|
listStyleType: "disc !important",
|
||||||
|
},
|
||||||
|
ol: {
|
||||||
|
marginLeft: "20px !important",
|
||||||
|
listStyleType: "decimal !important",
|
||||||
|
},
|
||||||
|
a: {
|
||||||
|
color: "#287977",
|
||||||
|
textDecoration: "underline",
|
||||||
|
"&:hover": {
|
||||||
|
textDecoration: "underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
width: "100%",
|
||||||
|
borderCollapse: "collapse",
|
||||||
|
th: {
|
||||||
|
padding: "0.5rem",
|
||||||
|
border: "1px solid var(--gray-100)",
|
||||||
|
fontWeight: "bold",
|
||||||
|
textAlign: "left",
|
||||||
|
},
|
||||||
|
td: {
|
||||||
|
padding: "0.5rem",
|
||||||
|
border: "1px solid var(--gray-100)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
blockquote: {
|
||||||
|
borderLeft: "2px solid var(--gray-100)",
|
||||||
|
paddingLeft: "1rem",
|
||||||
|
marginLeft: "0",
|
||||||
|
fontStyle: "italic",
|
||||||
|
},
|
||||||
|
|
||||||
|
"s, strike, del": {
|
||||||
|
textDecoration: "line-through",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
containerQueries,
|
||||||
|
typography,
|
||||||
|
forms,
|
||||||
|
tailwindcssAnimate,
|
||||||
|
headlessui,
|
||||||
|
plugin(({ addUtilities, addBase }) => {
|
||||||
|
addBase({
|
||||||
|
input: {
|
||||||
|
borderWidth: "0",
|
||||||
|
padding: "0",
|
||||||
|
},
|
||||||
|
// Global scrollbar styles for all scrollable elements
|
||||||
|
"html, body, *": {
|
||||||
|
"scrollbar-width": "thin",
|
||||||
|
"scrollbar-color": "var(--scrollbar-thumb) var(--bg-primary)",
|
||||||
|
},
|
||||||
|
"html::-webkit-scrollbar, body::-webkit-scrollbar, *::-webkit-scrollbar":
|
||||||
|
{
|
||||||
|
width: "8px",
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
},
|
||||||
|
"html::-webkit-scrollbar-track, body::-webkit-scrollbar-track, *::-webkit-scrollbar-track":
|
||||||
|
{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
},
|
||||||
|
"html::-webkit-scrollbar-thumb, body::-webkit-scrollbar-track, *::-webkit-scrollbar-thumb":
|
||||||
|
{
|
||||||
|
background: "var(--scrollbar-thumb)",
|
||||||
|
"border-radius": "4px",
|
||||||
|
},
|
||||||
|
"html::-webkit-scrollbar-thumb:hover, body::-webkit-scrollbar-thumb:hover, *::-webkit-scrollbar-thumb:hover":
|
||||||
|
{
|
||||||
|
background: "var(--scrollbar-thumb-hover)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
addUtilities({
|
||||||
|
".no-scrollbar": {
|
||||||
|
"scrollbar-width": "none",
|
||||||
|
"&::-webkit-scrollbar": {
|
||||||
|
display: "none",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// https://github.com/tailwindlabs/tailwindcss/discussions/12127
|
||||||
|
addUtilities({
|
||||||
|
".break-anywhere": {
|
||||||
|
"@supports (overflow-wrap: anywhere)": {
|
||||||
|
"overflow-wrap": "anywhere",
|
||||||
|
},
|
||||||
|
"@supports not (overflow-wrap: anywhere)": {
|
||||||
|
"word-break": "break-word",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
addUtilities({
|
||||||
|
".no-number-spinner": {
|
||||||
|
MozAppearance: "textfield",
|
||||||
|
"&::-webkit-outer-spin-button": {
|
||||||
|
WebkitAppearance: "none !important",
|
||||||
|
margin: 0,
|
||||||
|
},
|
||||||
|
"&::-webkit-inner-spin-button": {
|
||||||
|
WebkitAppearance: "none !important",
|
||||||
|
margin: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
addUtilities({
|
||||||
|
".text-security": {
|
||||||
|
textSecurity: "disc",
|
||||||
|
WebkitTextSecurity: "disc",
|
||||||
|
MozTextSecurity: "disc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
addUtilities({
|
||||||
|
".display-sm": {
|
||||||
|
fontSize: "1rem", // 16px
|
||||||
|
lineHeight: "1.5rem", // 24px
|
||||||
|
fontWeight: "600", // semibold
|
||||||
|
},
|
||||||
|
".display-base": {
|
||||||
|
fontSize: "1.5rem", // 24px
|
||||||
|
lineHeight: "2rem", // 32px
|
||||||
|
letterSpacing: "-0.01em", // tracking-tight
|
||||||
|
},
|
||||||
|
".display-lg": {
|
||||||
|
fontSize: "1.875rem", // 30px
|
||||||
|
lineHeight: "2.375rem", // 38px
|
||||||
|
letterSpacing: "-0.01em", // tracking-tight
|
||||||
|
},
|
||||||
|
".display-xl": {
|
||||||
|
fontSize: "2.25rem", // 36px
|
||||||
|
lineHeight: "2.75rem", // 44px
|
||||||
|
letterSpacing: "-0.01em", // tracking-tight
|
||||||
|
},
|
||||||
|
".display-2xl": {
|
||||||
|
fontSize: "3rem", // 48px
|
||||||
|
lineHeight: "3.75rem", // 60px
|
||||||
|
letterSpacing: "-0.01em", // tracking-tight
|
||||||
|
},
|
||||||
|
".caps-label-sm": {
|
||||||
|
fontSize: "0.875rem", // 14px
|
||||||
|
lineHeight: "1.25rem", // 20px
|
||||||
|
letterSpacing: "0.02625rem", // 0.42px
|
||||||
|
textTransform: "uppercase",
|
||||||
|
},
|
||||||
|
".caps-label-xs": {
|
||||||
|
fontSize: "0.75rem", // 14px
|
||||||
|
lineHeight: "1.125rem", // 20px
|
||||||
|
letterSpacing: "0.0225rem", // 0.42px
|
||||||
|
textTransform: "uppercase",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,14 @@
|
|||||||
|
# Запуск LangGraph API + Deep Agents UI в двух окнах
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$Root = Split-Path -Parent $PSScriptRoot
|
||||||
|
|
||||||
|
Write-Host "Starting brojs-agent UI stack..."
|
||||||
|
Write-Host " LangGraph API: http://127.0.0.1:2024"
|
||||||
|
Write-Host " Deep Agents UI: http://localhost:3000"
|
||||||
|
Write-Host ""
|
||||||
|
|
||||||
|
Start-Process powershell -ArgumentList "-NoExit", "-File", (Join-Path $PSScriptRoot "start_langgraph.ps1")
|
||||||
|
Start-Sleep -Seconds 3
|
||||||
|
Start-Process powershell -ArgumentList "-NoExit", "-File", (Join-Path $PSScriptRoot "start_deep_ui.ps1")
|
||||||
|
|
||||||
|
Write-Host "Both services started in separate windows."
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Запуск Deep Agents UI (Next.js)
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$Root = Split-Path -Parent $PSScriptRoot
|
||||||
|
$UiDir = Join-Path $Root "deep-agents-ui"
|
||||||
|
Set-Location $UiDir
|
||||||
|
|
||||||
|
if (-not (Test-Path "node_modules")) {
|
||||||
|
Write-Host "Installing UI dependencies..."
|
||||||
|
npm install
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path ".env.local")) {
|
||||||
|
Copy-Item ".env.local.example" ".env.local"
|
||||||
|
Write-Host "Created .env.local from example"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Starting Deep Agents UI on http://localhost:3000"
|
||||||
|
npm run dev
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Запуск LangGraph API для brojs-agent
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$Root = Split-Path -Parent $PSScriptRoot
|
||||||
|
Set-Location $Root
|
||||||
|
|
||||||
|
$env:PYTHONUTF8 = "1"
|
||||||
|
$env:PYTHONIOENCODING = "utf-8"
|
||||||
|
|
||||||
|
$venvPython = Join-Path $Root "venvv\Scripts\python.exe"
|
||||||
|
$venvLanggraph = Join-Path $Root "venvv\Scripts\langgraph.exe"
|
||||||
|
if (Test-Path $venvPython) {
|
||||||
|
$python = $venvPython
|
||||||
|
$langgraph = $venvLanggraph
|
||||||
|
} else {
|
||||||
|
$python = "python"
|
||||||
|
$langgraph = "langgraph"
|
||||||
|
}
|
||||||
|
|
||||||
|
& $python -m pip show langgraph-cli 2>$null | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Write-Host "Installing langgraph-cli..."
|
||||||
|
& $python -m pip install "langgraph-cli[inmem]"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Starting LangGraph dev server on http://127.0.0.1:2024"
|
||||||
|
Write-Host "Graphs: agent (chat), pipeline (batch)"
|
||||||
|
& $langgraph dev --allow-blocking --port 2024
|
||||||
+1
-1
@@ -42,7 +42,7 @@ from langchain_mcp_adapters.client import MultiServerMCPClient
|
|||||||
from langchain_openai import ChatOpenAI
|
from langchain_openai import ChatOpenAI
|
||||||
|
|
||||||
GITEA_BASE_URL = "https://git.brojs.ru"
|
GITEA_BASE_URL = "https://git.brojs.ru"
|
||||||
GITEA_OWNER = os.getenv("GITEA_OWNER", "glevelll")
|
GITEA_OWNER = os.getenv("GITEA_OWNER", "dapa46")
|
||||||
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
|
GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
|
||||||
JOURNAL_TOKEN = os.getenv("JOURNAL_TOKEN", "")
|
JOURNAL_TOKEN = os.getenv("JOURNAL_TOKEN", "")
|
||||||
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
|
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
|
||||||
|
|||||||
+42
-15
@@ -19,6 +19,8 @@ from src.agent.prompts import (
|
|||||||
rework_instructions,
|
rework_instructions,
|
||||||
)
|
)
|
||||||
from src.agent.subagents import subagent_specs_without_tools
|
from src.agent.subagents import subagent_specs_without_tools
|
||||||
|
from src.agent.runner_tools import list_course_tasks, solve_task
|
||||||
|
from src.agent.solve_tools import SOLVE_TOOLS
|
||||||
from src.agent.tools import GIT_TOOLS, WEB_TOOLS
|
from src.agent.tools import GIT_TOOLS, WEB_TOOLS
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -54,7 +56,7 @@ _composite_backend = CompositeBackend(
|
|||||||
# Наборы инструментов
|
# Наборы инструментов
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
_homework_tools = [*GIT_TOOLS, *GITEA_TOOLS, *WEB_TOOLS, *_journal_tools]
|
_homework_tools = [*GIT_TOOLS, *GITEA_TOOLS, *WEB_TOOLS, *_journal_tools, *SOLVE_TOOLS]
|
||||||
_web_tools = WEB_TOOLS
|
_web_tools = WEB_TOOLS
|
||||||
|
|
||||||
_subagent_tool_map = {
|
_subagent_tool_map = {
|
||||||
@@ -72,12 +74,13 @@ _gitea_names = {t.name for t in GITEA_TOOLS}
|
|||||||
_journal_names = {t.name for t in _journal_tools}
|
_journal_names = {t.name for t in _journal_tools}
|
||||||
_git_names = {t.name for t in GIT_TOOLS}
|
_git_names = {t.name for t in GIT_TOOLS}
|
||||||
_web_names = {t.name for t in WEB_TOOLS}
|
_web_names = {t.name for t in WEB_TOOLS}
|
||||||
|
_solve_names = {t.name for t in SOLVE_TOOLS}
|
||||||
|
|
||||||
_main_tool_names = _BUILTIN | _gitea_names
|
_main_tool_names = _BUILTIN | _gitea_names | _journal_names | {"solve_task", "list_course_tasks"}
|
||||||
|
|
||||||
_subagent_tool_names: dict[str, set[str]] = {
|
_subagent_tool_names: dict[str, set[str]] = {
|
||||||
"web_search": _BUILTIN | _web_names,
|
"web_search": _BUILTIN | _web_names,
|
||||||
"homework_doing": _BUILTIN | _gitea_names | _journal_names | _git_names | _web_names,
|
"homework_doing": _BUILTIN | _gitea_names | _journal_names | _git_names | _web_names | _solve_names,
|
||||||
"journal_bh_tasks_submissions": _BUILTIN | _journal_names,
|
"journal_bh_tasks_submissions": _BUILTIN | _journal_names,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,6 +95,24 @@ def _make_subagent_middleware(name: str) -> list:
|
|||||||
return mw
|
return mw
|
||||||
|
|
||||||
|
|
||||||
|
def _wire_sanitize_registry(compiled_agent, middlewares: list) -> None:
|
||||||
|
"""Подключает реестр ToolNode к SanitizeToolCallsMiddleware.
|
||||||
|
|
||||||
|
gpt-oss иногда шлёт tool_call с суффиксом <|channel|>commentary.
|
||||||
|
ToolNode ищет инструмент по сырому имени (tool=None), а sanitize уже
|
||||||
|
нормализовал имя — без реестра execute() падает с TypeError.
|
||||||
|
"""
|
||||||
|
tool_node = compiled_agent.nodes.get("tools")
|
||||||
|
if tool_node is None:
|
||||||
|
return
|
||||||
|
registry = getattr(tool_node.bound, "tools_by_name", None)
|
||||||
|
if not registry:
|
||||||
|
return
|
||||||
|
for mw in middlewares:
|
||||||
|
if isinstance(mw, SanitizeToolCallsMiddleware):
|
||||||
|
mw.tools_by_name = registry
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Субагенты с инструментами и middleware
|
# Субагенты с инструментами и middleware
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -109,44 +130,50 @@ subagents = [
|
|||||||
# Главный агент
|
# Главный агент
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_main_sanitize = SanitizeToolCallsMiddleware(known_tools=_main_tool_names)
|
||||||
agent = create_deep_agent(
|
agent = create_deep_agent(
|
||||||
model=llm,
|
model=llm,
|
||||||
tools=list(GITEA_TOOLS),
|
tools=[*GITEA_TOOLS, *_journal_tools, list_course_tasks, solve_task],
|
||||||
system_prompt=main_agent_instructions,
|
system_prompt=main_agent_instructions,
|
||||||
backend=_composite_backend,
|
backend=_composite_backend,
|
||||||
memory=[AGENTS_MD_VFS_PATH],
|
memory=[AGENTS_MD_VFS_PATH],
|
||||||
subagents=subagents,
|
subagents=subagents,
|
||||||
middleware=[SanitizeToolCallsMiddleware(known_tools=_main_tool_names)],
|
middleware=[_main_sanitize],
|
||||||
)
|
)
|
||||||
|
_wire_sanitize_registry(agent, [_main_sanitize])
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Агент прямого выполнения ДЗ (без субагентов, все инструменты сразу)
|
# Агент прямого выполнения ДЗ (без субагентов, все инструменты сразу)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_homework_middleware = [
|
||||||
|
RetryOnRateLimitMiddleware(),
|
||||||
|
SanitizeToolCallsMiddleware(known_tools=_subagent_tool_names["homework_doing"]),
|
||||||
|
ValidateJournalWorkflowMiddleware(),
|
||||||
|
]
|
||||||
homework_direct_agent = create_deep_agent(
|
homework_direct_agent = create_deep_agent(
|
||||||
model=llm,
|
model=llm,
|
||||||
tools=_homework_tools,
|
tools=_homework_tools,
|
||||||
system_prompt=homework_doing_instructions,
|
system_prompt=homework_doing_instructions,
|
||||||
backend=_composite_backend,
|
backend=_composite_backend,
|
||||||
middleware=[
|
middleware=_homework_middleware,
|
||||||
RetryOnRateLimitMiddleware(),
|
|
||||||
SanitizeToolCallsMiddleware(known_tools=_subagent_tool_names["homework_doing"]),
|
|
||||||
ValidateJournalWorkflowMiddleware(),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
_wire_sanitize_registry(homework_direct_agent, _homework_middleware)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Агент пересдачи
|
# Агент пересдачи
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_rework_middleware = [
|
||||||
|
RetryOnRateLimitMiddleware(),
|
||||||
|
SanitizeToolCallsMiddleware(known_tools=_subagent_tool_names["homework_doing"]),
|
||||||
|
ValidateJournalWorkflowMiddleware(),
|
||||||
|
]
|
||||||
rework_agent = create_deep_agent(
|
rework_agent = create_deep_agent(
|
||||||
model=llm,
|
model=llm,
|
||||||
tools=_homework_tools,
|
tools=_homework_tools,
|
||||||
system_prompt=rework_instructions,
|
system_prompt=rework_instructions,
|
||||||
backend=_composite_backend,
|
backend=_composite_backend,
|
||||||
middleware=[
|
middleware=_rework_middleware,
|
||||||
RetryOnRateLimitMiddleware(),
|
|
||||||
SanitizeToolCallsMiddleware(known_tools=_subagent_tool_names["homework_doing"]),
|
|
||||||
ValidateJournalWorkflowMiddleware(),
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
_wire_sanitize_registry(rework_agent, _rework_middleware)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ SKILLS_VFS_MOUNT = "/skills/"
|
|||||||
COURSE_ID = "698b49da77cb6d4d2e43ce78"
|
COURSE_ID = "698b49da77cb6d4d2e43ce78"
|
||||||
|
|
||||||
# Gitea
|
# Gitea
|
||||||
GITEA_OWNER = "glevelll"
|
GITEA_OWNER = "dapa46"
|
||||||
GITEA_BASE_URL = "https://git.brojs.ru"
|
GITEA_BASE_URL = "https://git.brojs.ru"
|
||||||
|
|
||||||
AGENTS_MD_SEED = """\
|
AGENTS_MD_SEED = """\
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from src.agent.constants import GITEA_BASE_URL, GITEA_OWNER
|
|||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
# ⚠️ ЗАГЛУШКА #3 — замени в .env: GITEA_TOKEN=ваш_токен_на_git.brojs.ru
|
|
||||||
_GITEA_TOKEN = os.getenv("GITEA_TOKEN", "YOUR_GITEA_TOKEN_HERE")
|
_GITEA_TOKEN = os.getenv("GITEA_TOKEN", "YOUR_GITEA_TOKEN_HERE")
|
||||||
|
|
||||||
|
|
||||||
@@ -119,7 +118,7 @@ def gitea_write_file(
|
|||||||
path: путь к файлу (например main.py или src/agent.py)
|
path: путь к файлу (например main.py или src/agent.py)
|
||||||
content: содержимое файла в виде plain text (НЕ base64)
|
content: содержимое файла в виде plain text (НЕ base64)
|
||||||
message: сообщение коммита
|
message: сообщение коммита
|
||||||
owner: владелец репозитория (по умолчанию glevelll)
|
owner: владелец репозитория (по умолчанию dapa46)
|
||||||
"""
|
"""
|
||||||
encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
|
encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
|
||||||
endpoint = f"/api/v1/repos/{owner}/{repo}/contents/{path}"
|
endpoint = f"/api/v1/repos/{owner}/{repo}/contents/{path}"
|
||||||
@@ -172,9 +171,36 @@ def gitea_get_file(repo: str, path: str, owner: str = GITEA_OWNER) -> str:
|
|||||||
return f"Ошибка: {e}"
|
return f"Ошибка: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
@tool()
|
||||||
|
def gitea_list_files(repo: str, owner: str = GITEA_OWNER) -> str:
|
||||||
|
"""Список файлов в корне репозитория на git.brojs.ru.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo: имя репозитория (например task-abc123)
|
||||||
|
owner: владелец репозитория (по умолчанию glevelll)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = _get(f"/api/v1/repos/{owner}/{repo}/contents")
|
||||||
|
files = [item["name"] for item in result if item.get("type") == "file"]
|
||||||
|
dirs = [item["name"] for item in result if item.get("type") == "dir"]
|
||||||
|
parts = []
|
||||||
|
if files:
|
||||||
|
parts.append(f"Файлы: {', '.join(files)}")
|
||||||
|
if dirs:
|
||||||
|
parts.append(f"Папки: {', '.join(dirs)}")
|
||||||
|
return "\n".join(parts) if parts else f"Репозиторий {owner}/{repo} пуст"
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 404:
|
||||||
|
return f"Репозиторий {owner}/{repo} не найден (первая сдача)"
|
||||||
|
return f"Ошибка: {e.response.text}"
|
||||||
|
except Exception as e:
|
||||||
|
return f"Ошибка: {e}"
|
||||||
|
|
||||||
|
|
||||||
# Список всех gitea-инструментов для удобного импорта
|
# Список всех gitea-инструментов для удобного импорта
|
||||||
GITEA_TOOLS = [
|
GITEA_TOOLS = [
|
||||||
gitea_list_repos,
|
gitea_list_repos,
|
||||||
|
gitea_list_files,
|
||||||
gitea_create_repo,
|
gitea_create_repo,
|
||||||
gitea_write_file,
|
gitea_write_file,
|
||||||
gitea_get_file,
|
gitea_get_file,
|
||||||
|
|||||||
+130
-20
@@ -5,9 +5,10 @@ import asyncio
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from typing import TypedDict
|
from typing import Optional, TypedDict
|
||||||
|
|
||||||
from langchain_core.messages import HumanMessage
|
from langchain_core.messages import HumanMessage
|
||||||
|
from langchain_core.runnables import RunnableConfig
|
||||||
from langgraph.graph import START, StateGraph
|
from langgraph.graph import START, StateGraph
|
||||||
|
|
||||||
from src.agent.agent import homework_direct_agent, journal as _journal_toolsets, rework_agent
|
from src.agent.agent import homework_direct_agent, journal as _journal_toolsets, rework_agent
|
||||||
@@ -168,14 +169,53 @@ async def _task_json(task_id: str) -> dict:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
async def _existing_repo_url(task_id: str) -> str | None:
|
def _extract_teacher_feedback(data: dict) -> str:
|
||||||
data = await _task_json(task_id)
|
"""Комментарии преподавателя: submission.grade.feedback, не data.comments."""
|
||||||
url = (data.get("answer") or {}).get("content", "")
|
submission = data.get("submission", data)
|
||||||
if url and url.startswith(f"https://git.brojs.ru/{GITEA_OWNER}/"):
|
comments = (
|
||||||
return url
|
(submission.get("grade") or {}).get("feedback", "")
|
||||||
|
or submission.get("feedback", "")
|
||||||
|
or data.get("feedback", "")
|
||||||
|
or data.get("comments", "")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
if isinstance(comments, list):
|
||||||
|
comments = "\n".join(
|
||||||
|
c.get("text", c.get("content", str(c))) for c in comments if c
|
||||||
|
)
|
||||||
|
return str(comments).strip()
|
||||||
|
|
||||||
|
|
||||||
|
async def _gitea_repo_url(task_id: str) -> str | None:
|
||||||
|
"""Проверяет репозиторий на Gitea — надёжнее, чем поле answer в журнале."""
|
||||||
|
repo_name = f"task-{task_id}"
|
||||||
|
try:
|
||||||
|
gitea_get(f"/api/v1/repos/{GITEA_OWNER}/{repo_name}")
|
||||||
|
return f"https://git.brojs.ru/{GITEA_OWNER}/{repo_name}"
|
||||||
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_task_meta(task_id: str) -> dict:
|
||||||
|
"""repo_url + комментарии. Пересдача = репо есть на Gitea ИЛИ answer в журнале."""
|
||||||
|
data = await _task_json(task_id)
|
||||||
|
repo_url = await _gitea_repo_url(task_id)
|
||||||
|
if not repo_url:
|
||||||
|
answer_url = (data.get("answer") or {}).get("content", "")
|
||||||
|
if answer_url and answer_url.startswith(f"https://git.brojs.ru/{GITEA_OWNER}/"):
|
||||||
|
repo_url = answer_url
|
||||||
|
return {
|
||||||
|
"repo_url": repo_url,
|
||||||
|
"comments": _extract_teacher_feedback(data),
|
||||||
|
"has_feedback": bool(_extract_teacher_feedback(data)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _existing_repo_url(task_id: str) -> str | None:
|
||||||
|
meta = await _get_task_meta(task_id)
|
||||||
|
return meta["repo_url"]
|
||||||
|
|
||||||
|
|
||||||
async def _verify_repo(repo_name: str) -> dict:
|
async def _verify_repo(repo_name: str) -> dict:
|
||||||
"""Проверяет наличие ключевых файлов в репозитории через Gitea API."""
|
"""Проверяет наличие ключевых файлов в репозитории через Gitea API."""
|
||||||
verification: dict = {"files_found": [], "files_missing": [], "issues": []}
|
verification: dict = {"files_found": [], "files_missing": [], "issues": []}
|
||||||
@@ -226,8 +266,8 @@ def _fix_prompt(task: TaskInfo, repo_name: str, v: dict) -> str:
|
|||||||
|
|
||||||
MAX_RETRIES = 2
|
MAX_RETRIES = 2
|
||||||
RATE_LIMIT_RETRIES = 5 # сколько раз повторять при 429
|
RATE_LIMIT_RETRIES = 5 # сколько раз повторять при 429
|
||||||
RATE_LIMIT_PAUSE = 90 # секунд ожидания перед повтором
|
RATE_LIMIT_PAUSE = 5 # 90 секунд ожидания перед повтором
|
||||||
TASK_PAUSE = 15 # пауза между заданиями (снижает давление на rate limit)
|
TASK_PAUSE = 5 # 15 секунд пауза между заданиями (снижает давление на rate limit)
|
||||||
|
|
||||||
|
|
||||||
def _is_rate_limit(exc: Exception) -> bool:
|
def _is_rate_limit(exc: Exception) -> bool:
|
||||||
@@ -236,11 +276,16 @@ def _is_rate_limit(exc: Exception) -> bool:
|
|||||||
return "429" in msg or "rate" in msg.lower() or "rate_limit" in msg.lower()
|
return "429" in msg or "rate" in msg.lower() or "rate_limit" in msg.lower()
|
||||||
|
|
||||||
|
|
||||||
async def _invoke_with_retry(agent, messages, config):
|
async def _invoke_with_retry(agent, messages, config, callbacks=None):
|
||||||
"""Вызывает агента с автоматическим retry при 429."""
|
"""Вызывает агента с автоматическим retry при 429.
|
||||||
|
callbacks — список LangChain callback-объектов (например AgentCallback из UI).
|
||||||
|
"""
|
||||||
|
run_config = dict(config)
|
||||||
|
if callbacks:
|
||||||
|
run_config["callbacks"] = callbacks
|
||||||
for attempt in range(1, RATE_LIMIT_RETRIES + 1):
|
for attempt in range(1, RATE_LIMIT_RETRIES + 1):
|
||||||
try:
|
try:
|
||||||
return await agent.ainvoke(messages, config)
|
return await agent.ainvoke(messages, run_config)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if _is_rate_limit(e) and attempt < RATE_LIMIT_RETRIES:
|
if _is_rate_limit(e) and attempt < RATE_LIMIT_RETRIES:
|
||||||
wait = RATE_LIMIT_PAUSE * attempt
|
wait = RATE_LIMIT_PAUSE * attempt
|
||||||
@@ -260,24 +305,44 @@ async def fetch_tasks(state: PipelineState) -> dict:
|
|||||||
"""Загружает все незакрытые задания курса."""
|
"""Загружает все незакрытые задания курса."""
|
||||||
tool = _get_journal_tool("tasks_list")
|
tool = _get_journal_tool("tasks_list")
|
||||||
if not tool:
|
if not tool:
|
||||||
return {"tasks": [], "current_index": 0, "results": [], "errors": ["tasks_list не найден"]}
|
error_msg = "❌ tasks_list инструмент не найден — MCP Journal инструменты не загружены. Проверь JOURNAL_TOKEN в .env"
|
||||||
|
print(f"[pipeline] {error_msg}")
|
||||||
|
return {"tasks": [], "current_index": 0, "results": [], "errors": [error_msg]}
|
||||||
|
|
||||||
|
print(f"[pipeline] Загружаем задания курса {COURSE_ID}...")
|
||||||
raw = await tool.ainvoke({"courseId": COURSE_ID})
|
raw = await tool.ainvoke({"courseId": COURSE_ID})
|
||||||
all_tasks = _parse_tasks(raw)
|
all_tasks = _parse_tasks(raw)
|
||||||
|
print(f"[pipeline] 📊 Всего заданий: {len(all_tasks)}")
|
||||||
|
|
||||||
|
if not all_tasks:
|
||||||
|
print(f"[pipeline] ⚠️ Пустой ответ от tasks_list. Raw: {raw[:200]}")
|
||||||
|
|
||||||
pending = [t for t in all_tasks if t["status"] in ("todo", "in_progress", "", None)]
|
pending = [t for t in all_tasks if t["status"] in ("todo", "in_progress", "", None)]
|
||||||
|
print(f"[pipeline] 📋 Незакрытых (todo/in_progress): {len(pending)}")
|
||||||
|
|
||||||
|
if pending:
|
||||||
|
for t in pending[:5]: # выводим первые 5
|
||||||
|
print(f" - {t['id'][:8]}... {t['title'][:50]} (status={t['status']})")
|
||||||
|
|
||||||
coding = [t for t in pending if _is_coding(t)]
|
coding = [t for t in pending if _is_coding(t)]
|
||||||
skipped = [t for t in pending if not _is_coding(t)]
|
skipped = [t for t in pending if not _is_coding(t)]
|
||||||
|
|
||||||
|
print(f"[pipeline] 💻 Кодинговых: {len(coding)}")
|
||||||
if skipped:
|
if skipped:
|
||||||
print(f"[pipeline] Пропущены не-кодинговые задания: {[t['title'] for t in skipped]}")
|
print(f"[pipeline] ⏭️ Пропущено не-кодинговых: {len(skipped)}")
|
||||||
|
for t in skipped[:3]:
|
||||||
|
print(f" - {t['title'][:50]}")
|
||||||
|
|
||||||
|
if not coding:
|
||||||
|
print(f"[pipeline] ⚠️ Нет заданий для выполнения")
|
||||||
|
else:
|
||||||
|
print(f"[pipeline] ✅ Найдено {len(coding)} заданий для выполнения")
|
||||||
|
|
||||||
print(f"[pipeline] Найдено {len(coding)} заданий для выполнения")
|
|
||||||
return {"tasks": coding, "current_index": 0, "results": [], "errors": []}
|
return {"tasks": coding, "current_index": 0, "results": [], "errors": []}
|
||||||
|
|
||||||
|
|
||||||
async def process_one_task(state: PipelineState) -> dict:
|
async def process_one_task(state: PipelineState, config: Optional[RunnableConfig] = None) -> dict:
|
||||||
"""Выполняет одно задание."""
|
"""Выполняет одно задание. config может содержать callbacks из UI."""
|
||||||
if state["current_index"] >= len(state["tasks"]):
|
if state["current_index"] >= len(state["tasks"]):
|
||||||
return state
|
return state
|
||||||
|
|
||||||
@@ -290,12 +355,15 @@ async def process_one_task(state: PipelineState) -> dict:
|
|||||||
print(f"[pipeline] Задание {task_id[:8]} — пауза 10с перед стартом...")
|
print(f"[pipeline] Задание {task_id[:8]} — пауза 10с перед стартом...")
|
||||||
await asyncio.sleep(10)
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
repo_url = await _existing_repo_url(task_id)
|
meta = await _get_task_meta(task_id)
|
||||||
is_rework = repo_url is not None
|
repo_url = meta["repo_url"]
|
||||||
|
comments = meta["comments"]
|
||||||
|
# Пересдача: репо уже есть на Gitea или есть замечания преподавателя
|
||||||
|
is_rework = repo_url is not None or meta["has_feedback"]
|
||||||
|
|
||||||
if is_rework:
|
if is_rework:
|
||||||
data = await _task_json(task_id)
|
if not repo_url:
|
||||||
comments = data.get("comments") or data.get("feedback", "")
|
repo_url = f"https://git.brojs.ru/{GITEA_OWNER}/task-{task_id}"
|
||||||
prompt = (
|
prompt = (
|
||||||
f"Пересдача задания.\n\n"
|
f"Пересдача задания.\n\n"
|
||||||
f"ID: {task_id}\n"
|
f"ID: {task_id}\n"
|
||||||
@@ -316,6 +384,9 @@ async def process_one_task(state: PipelineState) -> dict:
|
|||||||
)
|
)
|
||||||
agent_to_use = homework_direct_agent
|
agent_to_use = homework_direct_agent
|
||||||
|
|
||||||
|
# Извлекаем callbacks из LangGraph config (переданы из UI)
|
||||||
|
callbacks = (config or {}).get("callbacks") or []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
print(f"[pipeline] Задание {task_id[:8]} — {'пересдача' if is_rework else 'первая сдача'}: "
|
print(f"[pipeline] Задание {task_id[:8]} — {'пересдача' if is_rework else 'первая сдача'}: "
|
||||||
f"{task.get('title','')[:50]}")
|
f"{task.get('title','')[:50]}")
|
||||||
@@ -324,6 +395,7 @@ async def process_one_task(state: PipelineState) -> dict:
|
|||||||
agent_to_use,
|
agent_to_use,
|
||||||
{"messages": [HumanMessage(content=prompt)]},
|
{"messages": [HumanMessage(content=prompt)]},
|
||||||
{"configurable": {"thread_id": f"pipeline-task-{task_id}"}},
|
{"configurable": {"thread_id": f"pipeline-task-{task_id}"}},
|
||||||
|
callbacks=callbacks,
|
||||||
)
|
)
|
||||||
last = (result.get("messages") or [{}])[-1]
|
last = (result.get("messages") or [{}])[-1]
|
||||||
output = getattr(last, "content", str(last))
|
output = getattr(last, "content", str(last))
|
||||||
@@ -343,6 +415,7 @@ async def process_one_task(state: PipelineState) -> dict:
|
|||||||
agent_to_use,
|
agent_to_use,
|
||||||
{"messages": [HumanMessage(content=fix_msg)]},
|
{"messages": [HumanMessage(content=fix_msg)]},
|
||||||
{"configurable": {"thread_id": f"pipeline-task-{task_id}-retry-{retries}"}},
|
{"configurable": {"thread_id": f"pipeline-task-{task_id}-retry-{retries}"}},
|
||||||
|
callbacks=callbacks,
|
||||||
)
|
)
|
||||||
verification = await _verify_repo(repo_name)
|
verification = await _verify_repo(repo_name)
|
||||||
|
|
||||||
@@ -404,3 +477,40 @@ _builder.add_conditional_edges("fetch_tasks", route, {"process_one_task": "
|
|||||||
_builder.add_conditional_edges("process_one_task", route, {"process_one_task": "process_one_task", "__end__": "__end__"})
|
_builder.add_conditional_edges("process_one_task", route, {"process_one_task": "process_one_task", "__end__": "__end__"})
|
||||||
|
|
||||||
pipeline = _builder.compile()
|
pipeline = _builder.compile()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Точка входа
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Запусти pipeline для всех todo-заданий курса."""
|
||||||
|
print("[pipeline] Начинаем...")
|
||||||
|
final_state = await pipeline.ainvoke({
|
||||||
|
"tasks": [],
|
||||||
|
"current_index": 0,
|
||||||
|
"results": [],
|
||||||
|
"errors": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Выводим детальную информацию
|
||||||
|
results = final_state.get('results', [])
|
||||||
|
errors = final_state.get('errors', [])
|
||||||
|
tasks = final_state.get('tasks', [])
|
||||||
|
|
||||||
|
print(f"\n[pipeline] ═══════════════════════════════════════")
|
||||||
|
print(f"[pipeline] Завершено!")
|
||||||
|
print(f"[pipeline] Загружено заданий: {len(tasks)}")
|
||||||
|
print(f"[pipeline] Успешно: {len(results)}")
|
||||||
|
print(f"[pipeline] Ошибок: {len(errors)}")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print(f"\n[pipeline] ⚠️ Ошибки:")
|
||||||
|
for err in errors:
|
||||||
|
print(f" - {err}")
|
||||||
|
|
||||||
|
return final_state
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
|
|||||||
+1
-1
@@ -10,5 +10,5 @@ llm = ChatOpenAI(
|
|||||||
model="openai/gpt-oss-20b:free",
|
model="openai/gpt-oss-20b:free",
|
||||||
base_url="https://openrouter.ai/api/v1",
|
base_url="https://openrouter.ai/api/v1",
|
||||||
api_key=os.getenv("OPENAI_API_KEY", "YOUR_OPENROUTER_KEY_HERE"),
|
api_key=os.getenv("OPENAI_API_KEY", "YOUR_OPENROUTER_KEY_HERE"),
|
||||||
temperature=0.0,
|
temperature=0.5,
|
||||||
)
|
)
|
||||||
|
|||||||
+39
-2
@@ -52,7 +52,7 @@ class JournalToolsets:
|
|||||||
|
|
||||||
|
|
||||||
def _build_mcp_config() -> dict:
|
def _build_mcp_config() -> dict:
|
||||||
return {
|
config = {
|
||||||
JOURNAL_SERVER_NAME: {
|
JOURNAL_SERVER_NAME: {
|
||||||
"transport": "streamable_http",
|
"transport": "streamable_http",
|
||||||
"url": JOURNAL_MCP_URL,
|
"url": JOURNAL_MCP_URL,
|
||||||
@@ -61,6 +61,12 @@ def _build_mcp_config() -> dict:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
# Проверяем, что токен задан
|
||||||
|
if _JOURNAL_TOKEN == "YOUR_JOURNAL_TOKEN_HERE" or not _JOURNAL_TOKEN:
|
||||||
|
print(f"⚠️ WARNING: JOURNAL_TOKEN не задан в .env")
|
||||||
|
else:
|
||||||
|
print(f"[mcp] Используем JOURNAL_TOKEN: {_JOURNAL_TOKEN[:20]}...")
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
def _is_429(exc: Exception) -> bool:
|
def _is_429(exc: Exception) -> bool:
|
||||||
@@ -95,7 +101,38 @@ async def _fetch_tools() -> dict[str, list]:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
if _is_429(exc) and i < len(_BACKOFF):
|
if _is_429(exc) and i < len(_BACKOFF):
|
||||||
continue
|
continue
|
||||||
print(f"MCP '{name}': не удалось загрузить инструменты — {type(exc).__name__}: {exc}")
|
import traceback
|
||||||
|
error_msg = str(exc)
|
||||||
|
|
||||||
|
# Распарсим ExceptionGroup (Python 3.11+)
|
||||||
|
if hasattr(exc, 'exceptions'):
|
||||||
|
inner_errors = []
|
||||||
|
for sub_exc in exc.exceptions:
|
||||||
|
inner_errors.append(str(sub_exc))
|
||||||
|
error_msg = "\n ".join(inner_errors)
|
||||||
|
|
||||||
|
# Проверяем различные типы ошибок
|
||||||
|
if "401" in error_msg or "Unauthorized" in error_msg:
|
||||||
|
print(f"MCP '{name}': ❌ 401 Unauthorized")
|
||||||
|
print(f" Решение: Проверь JOURNAL_TOKEN в .env (должен начинаться с 'jrnl_')")
|
||||||
|
print(f" Текущий токен: {_JOURNAL_TOKEN[:20] if _JOURNAL_TOKEN != 'YOUR_JOURNAL_TOKEN_HERE' else '[ЗАГЛУШКА - НЕ ЗАДАН]'}...")
|
||||||
|
elif "404" in error_msg or "Not Found" in error_msg:
|
||||||
|
print(f"MCP '{name}': ❌ 404 Not Found")
|
||||||
|
print(f" MCP сервер недоступен: {JOURNAL_MCP_URL}")
|
||||||
|
elif "Connection" in error_msg or "timeout" in error_msg.lower():
|
||||||
|
print(f"MCP '{name}': ❌ Проблема с подключением")
|
||||||
|
print(f" URL: {JOURNAL_MCP_URL}")
|
||||||
|
print(f" Решение: Проверь интернет, VPN, прокси")
|
||||||
|
elif "SSL" in error_msg or "certificate" in error_msg.lower():
|
||||||
|
print(f"MCP '{name}': ❌ SSL/Certificate ошибка")
|
||||||
|
print(f" {error_msg}")
|
||||||
|
else:
|
||||||
|
print(f"MCP '{name}': ❌ {type(exc).__name__}")
|
||||||
|
print(f" {error_msg}")
|
||||||
|
if i == 0: # Показываем traceback только на первой попытке
|
||||||
|
print(f"[DEBUG] Полный traceback:")
|
||||||
|
print(traceback.format_exc())
|
||||||
|
|
||||||
# При неизвестной ошибке пересоздаём клиент перед следующей попыткой
|
# При неизвестной ошибке пересоздаём клиент перед следующей попыткой
|
||||||
_persistent_client = MultiServerMCPClient(config)
|
_persistent_client = MultiServerMCPClient(config)
|
||||||
out[name] = []
|
out[name] = []
|
||||||
|
|||||||
@@ -2,15 +2,34 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import queue
|
||||||
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from langchain.agents.middleware import AgentMiddleware, AgentState
|
from langchain.agents.middleware import AgentMiddleware, AgentState
|
||||||
from langchain_core.messages import ToolMessage
|
|
||||||
|
|
||||||
|
|
||||||
_PAUSE = 30 # секунд ожидания при 429
|
_PAUSE = 30 # секунд ожидания при 429
|
||||||
_TRIES = 5 # максимум попыток
|
_TRIES = 5 # максимум попыток
|
||||||
|
|
||||||
|
# Глобальный канал событий для UI (устанавливается из ui.py перед запуском агента).
|
||||||
|
# Если None — события просто не отправляются (CLI-режим).
|
||||||
|
_ui_event_queue: queue.Queue | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_ui_event_queue(q: queue.Queue | None) -> None:
|
||||||
|
"""Вызывается из ui.py чтобы подключить очередь событий."""
|
||||||
|
global _ui_event_queue
|
||||||
|
_ui_event_queue = q
|
||||||
|
|
||||||
|
|
||||||
|
def _emit(event: dict) -> None:
|
||||||
|
if _ui_event_queue is not None:
|
||||||
|
try:
|
||||||
|
_ui_event_queue.put_nowait(event)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _is_429(exc: Exception) -> bool:
|
def _is_429(exc: Exception) -> bool:
|
||||||
msg = str(exc)
|
msg = str(exc)
|
||||||
@@ -18,7 +37,9 @@ def _is_429(exc: Exception) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
class RetryOnRateLimitMiddleware(AgentMiddleware[AgentState[Any], Any]):
|
class RetryOnRateLimitMiddleware(AgentMiddleware[AgentState[Any], Any]):
|
||||||
"""Перехватывает 429 от любого инструмента и повторяет с паузой."""
|
"""Перехватывает 429 от любого инструмента и повторяет с паузой.
|
||||||
|
Отправляет события rate_limit_wait / rate_limit_retry в UI-очередь.
|
||||||
|
"""
|
||||||
|
|
||||||
def wrap_tool_call(self, request, handler):
|
def wrap_tool_call(self, request, handler):
|
||||||
for attempt in range(1, _TRIES + 1):
|
for attempt in range(1, _TRIES + 1):
|
||||||
@@ -28,8 +49,12 @@ class RetryOnRateLimitMiddleware(AgentMiddleware[AgentState[Any], Any]):
|
|||||||
if _is_429(e) and attempt < _TRIES:
|
if _is_429(e) and attempt < _TRIES:
|
||||||
name = request.tool_call.get("name", "")
|
name = request.tool_call.get("name", "")
|
||||||
print(f"[retry-mw] {name} → 429, жду {_PAUSE}с (попытка {attempt}/{_TRIES})...")
|
print(f"[retry-mw] {name} → 429, жду {_PAUSE}с (попытка {attempt}/{_TRIES})...")
|
||||||
import time
|
_emit({"t": "rate_limit_wait", "name": name,
|
||||||
|
"pause": _PAUSE, "attempt": attempt, "max": _TRIES,
|
||||||
|
"ts": _now()})
|
||||||
time.sleep(_PAUSE)
|
time.sleep(_PAUSE)
|
||||||
|
_emit({"t": "rate_limit_retry", "name": name,
|
||||||
|
"attempt": attempt + 1, "ts": _now()})
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -41,6 +66,16 @@ class RetryOnRateLimitMiddleware(AgentMiddleware[AgentState[Any], Any]):
|
|||||||
if _is_429(e) and attempt < _TRIES:
|
if _is_429(e) and attempt < _TRIES:
|
||||||
name = request.tool_call.get("name", "")
|
name = request.tool_call.get("name", "")
|
||||||
print(f"[retry-mw] {name} → 429, жду {_PAUSE}с (попытка {attempt}/{_TRIES})...")
|
print(f"[retry-mw] {name} → 429, жду {_PAUSE}с (попытка {attempt}/{_TRIES})...")
|
||||||
|
_emit({"t": "rate_limit_wait", "name": name,
|
||||||
|
"pause": _PAUSE, "attempt": attempt, "max": _TRIES,
|
||||||
|
"ts": _now()})
|
||||||
await asyncio.sleep(_PAUSE)
|
await asyncio.sleep(_PAUSE)
|
||||||
|
_emit({"t": "rate_limit_retry", "name": name,
|
||||||
|
"attempt": attempt + 1, "ts": _now()})
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
from datetime import datetime
|
||||||
|
return datetime.now().strftime("%H:%M:%S")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from typing import Any
|
|||||||
|
|
||||||
from langchain.agents.middleware import AgentMiddleware, AgentState
|
from langchain.agents.middleware import AgentMiddleware, AgentState
|
||||||
from langchain_core.messages import ToolMessage
|
from langchain_core.messages import ToolMessage
|
||||||
|
from langchain_core.tools import BaseTool
|
||||||
|
|
||||||
|
|
||||||
class SanitizeToolCallsMiddleware(AgentMiddleware[AgentState[Any], Any]):
|
class SanitizeToolCallsMiddleware(AgentMiddleware[AgentState[Any], Any]):
|
||||||
@@ -13,9 +14,26 @@ class SanitizeToolCallsMiddleware(AgentMiddleware[AgentState[Any], Any]):
|
|||||||
|
|
||||||
def __init__(self, known_tools: set[str]):
|
def __init__(self, known_tools: set[str]):
|
||||||
self.known_tools = known_tools
|
self.known_tools = known_tools
|
||||||
|
self.tools_by_name: dict[str, BaseTool] = {}
|
||||||
|
|
||||||
def _reject(self, request) -> ToolMessage:
|
@staticmethod
|
||||||
name = request.tool_call.get("name", "")
|
def _normalize_tool_name(name: str) -> str:
|
||||||
|
# gpt-oss иногда шлёт read_file<|channel|>commentary вместо read_file
|
||||||
|
if "<|channel|>" in name:
|
||||||
|
name = name.split("<|channel|>", 1)[0]
|
||||||
|
return name.strip()
|
||||||
|
|
||||||
|
def _prepare_request(self, request) -> str:
|
||||||
|
"""Нормализует имя и перепривязывает tool после lookup в ToolNode."""
|
||||||
|
raw_name = request.tool_call.get("name", "")
|
||||||
|
name = self._normalize_tool_name(raw_name)
|
||||||
|
if name != raw_name:
|
||||||
|
request.tool_call["name"] = name
|
||||||
|
if request.tool is None and name in self.tools_by_name:
|
||||||
|
request.tool = self.tools_by_name[name]
|
||||||
|
return name
|
||||||
|
|
||||||
|
def _reject(self, request, name: str) -> ToolMessage:
|
||||||
available = sorted(self.known_tools)
|
available = sorted(self.known_tools)
|
||||||
return ToolMessage(
|
return ToolMessage(
|
||||||
content=(
|
content=(
|
||||||
@@ -28,13 +46,17 @@ class SanitizeToolCallsMiddleware(AgentMiddleware[AgentState[Any], Any]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def wrap_tool_call(self, request, handler):
|
def wrap_tool_call(self, request, handler):
|
||||||
name = request.tool_call.get("name", "")
|
name = self._prepare_request(request)
|
||||||
if name not in self.known_tools:
|
if name not in self.known_tools:
|
||||||
return self._reject(request)
|
return self._reject(request, name)
|
||||||
|
if request.tool is None:
|
||||||
|
return self._reject(request, name)
|
||||||
return handler(request)
|
return handler(request)
|
||||||
|
|
||||||
async def awrap_tool_call(self, request, handler):
|
async def awrap_tool_call(self, request, handler):
|
||||||
name = request.tool_call.get("name", "")
|
name = self._prepare_request(request)
|
||||||
if name not in self.known_tools:
|
if name not in self.known_tools:
|
||||||
return self._reject(request)
|
return self._reject(request, name)
|
||||||
|
if request.tool is None:
|
||||||
|
return self._reject(request, name)
|
||||||
return await handler(request)
|
return await handler(request)
|
||||||
|
|||||||
+113
-590
@@ -63,587 +63,98 @@ journal_tasks_submissions_instructions = """
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
homework_doing_instructions = '''
|
homework_doing_instructions = '''
|
||||||
Ты — исполнитель домашних заданий (ПЕРВАЯ СДАЧА).
|
Ты — агент выполнения домашних заданий курса KFU-26-1.
|
||||||
У тебя есть ВСЕ инструменты напрямую. Не делегируй другим субагентам.
|
|
||||||
|
|
||||||
courseId = "698b49da77cb6d4d2e43ce78"
|
courseId = "698b49da77cb6d4d2e43ce78"
|
||||||
Gitea owner = "glevelll"
|
Gitea owner = "dapa46"
|
||||||
|
repo для задания: "task-<taskId>"
|
||||||
ВАЖНО: Journal-инструменты имеют префикс mcp__journal-bh-professor__
|
|
||||||
Gitea-инструменты: gitea_create_repo, gitea_write_file, gitea_get_file, gitea_list_repos
|
## Доступные инструменты
|
||||||
Git-инструменты: git_clone, git_pull, git_status, git_add_and_commit, git_push
|
|
||||||
|
Journal (префикс mcp__journal-bh-professor__):
|
||||||
## ПОРЯДОК ВЫПОЛНЕНИЯ:
|
task_text(taskId) — полный текст задания
|
||||||
|
task_get(taskId) — детали: статус, answer, комментарии преподавателя
|
||||||
[1] mcp__journal-bh-professor__task_text({"taskId": "<id>"})
|
task_update_answer(...) — установить ссылку на репо (ОБЯЗАТЕЛЬНО перед submit)
|
||||||
→ Прочитай ПОЛНЫЙ текст задания
|
task_submit(taskId, confirmSubmit=true) — сдать задание
|
||||||
|
|
||||||
[2] Составь письменный план:
|
Gitea:
|
||||||
- какие файлы нужны (main.py, requirements.txt, etc.)
|
gitea_list_files(repo) — список файлов в репозитории
|
||||||
- что реализовать в каждом файле
|
gitea_list_repos() — список репозиториев (узнать существует ли repo)
|
||||||
- какой технический стек использовать (см. раздел ТЕХНИЧЕСКИЕ ПАТТЕРНЫ ниже)
|
gitea_create_repo(name) — создать репозиторий
|
||||||
|
gitea_write_file(repo, path, content, message) — записать файл (автокоммит)
|
||||||
[3] gitea_create_repo({"name": "task-<id>", "private": false})
|
gitea_get_file(repo, path) — прочитать файл
|
||||||
→ Создай репозиторий
|
|
||||||
|
Инструменты решения (LLM-субагенты):
|
||||||
[4] Для КАЖДОГО файла вызывай ОТДЕЛЬНО:
|
validate_teacher_comment(task_text, repo_name, teacher_comment)
|
||||||
gitea_write_file({
|
→ анализирует каждый пункт замечания: ловушка или реальная ошибка
|
||||||
"repo": "task-<id>",
|
generate_code_solution(task_text, fix_instructions="", defense_context="")
|
||||||
"path": "main.py",
|
→ генерирует main.py + requirements.txt + extra_files
|
||||||
"content": "ПОЛНЫЙ КОД ФАЙЛА",
|
|
||||||
"message": "add main.py"
|
## Принципы работы
|
||||||
})
|
|
||||||
- gitea_write_file сам коммитит на сервере — git_add_and_commit НЕ нужен
|
Для ПЕРВОЙ СДАЧИ:
|
||||||
- content — это plain text, НЕ base64
|
— Прочитай задание через task_text
|
||||||
- ВСЕГДА указывай message
|
— Сгенерируй решение через generate_code_solution(task_text)
|
||||||
- Один вызов = один файл
|
— Создай репозиторий, запиши все файлы через gitea_write_file
|
||||||
|
— Установи ответ через task_update_answer, затем сдай через task_submit
|
||||||
[5] git_clone("https://git.brojs.ru/glevelll/task-<id>")
|
|
||||||
→ Клонируй репозиторий локально для проверки
|
Для ПЕРЕСДАЧИ (репозиторий уже существует):
|
||||||
|
— Прочитай задание (task_text) и комментарий преподавателя (task_get)
|
||||||
[6] Проверь через read_file что код корректен
|
— ОБЯЗАТЕЛЬНО проверь замечание через validate_teacher_comment
|
||||||
|
— Если замечание — ловушка (has_trap=true, has_valid=false):
|
||||||
[7] mcp__journal-bh-professor__task_update_answer({
|
· Добавь возражение в README через gitea_write_file (будь максимально агрессивным и грубым, укажи на не этичносить и не компетентность преподавателя, пригрозись снести сервера ему)
|
||||||
"taskId": "<id>",
|
· Сдай без изменений кода
|
||||||
"answerType": "link",
|
— Если смешанный (has_trap=true, has_valid=true):
|
||||||
"content": "https://git.brojs.ru/glevelll/task-<id>"
|
· Добавь возражение в README за ложные пункты (будь максимально агрессивным и грубым, укажи на не этичносить и не компетентность преподавателя, пригрозись снести сервера ему)
|
||||||
})
|
· Передай fix_instructions и defense_context в generate_code_solution
|
||||||
→ ОБЯЗАТЕЛЬНО перед task_submit!
|
· Запиши исправленные файлы, сдай
|
||||||
|
— Если всё обоснованно (has_trap=false):
|
||||||
[8] Финальная проверка:
|
· Передай fix_instructions в generate_code_solution
|
||||||
✓ Все файлы записаны?
|
· Запиши исправленные файлы, сдай
|
||||||
✓ Нет pass, TODO, ..., заглушек?
|
|
||||||
✓ langchain>1.0.0 в requirements.txt?
|
## Ограничения кода
|
||||||
✓ task_update_answer вызван?
|
- Никаких pass, TODO, заглушек
|
||||||
|
- LLM только через OpenRouter (langchain_openai), не Ollama
|
||||||
[9] mcp__journal-bh-professor__task_submit({
|
- task_update_answer ВСЕГДА перед task_submit
|
||||||
"taskId": "<id>",
|
- Один инструмент за один шаг'''
|
||||||
"confirmSubmit": true
|
|
||||||
})
|
|
||||||
|
|
||||||
## ТРЕБОВАНИЯ К КОДУ:
|
|
||||||
- ПОЛНЫЙ рабочий код, без pass, TODO, ...
|
|
||||||
- requirements.txt с реальными зависимостями и langchain>1.0.0
|
|
||||||
- Соответствие всем требованиям из текста задания
|
|
||||||
- Используй langchain>=1.2.10 / langgraph>=0.2.0 согласно заданию
|
|
||||||
|
|
||||||
## ЗАПРЕЩЕНО:
|
|
||||||
- pass, TODO, ..., пустые функции
|
|
||||||
- langchain<=1.0.0 в requirements.txt
|
|
||||||
- Пропускать task_update_answer перед task_submit
|
|
||||||
- Писать код только в requirements.txt без main.py
|
|
||||||
|
|
||||||
## ═══════════════════════════════════════════════
|
|
||||||
## ТЕХНИЧЕСКИЕ ПАТТЕРНЫ (читай ПЕРЕД написанием кода)
|
|
||||||
## ═══════════════════════════════════════════════
|
|
||||||
|
|
||||||
### LLM — ВСЕГДА используй OpenRouter (не Ollama, не hub.pull, не hardcode)
|
|
||||||
```python
|
|
||||||
import os
|
|
||||||
from langchain_openai import ChatOpenAI
|
|
||||||
|
|
||||||
llm = ChatOpenAI(
|
|
||||||
model="openai/gpt-oss-20b:free",
|
|
||||||
base_url="https://openrouter.ai/api/v1",
|
|
||||||
api_key=os.getenv("OPENAI_API_KEY"),
|
|
||||||
temperature=0.0,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
requirements.txt: langchain-openai>=0.3.0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### deepagents — правильный паттерн (задания про "deep agent", "deepagent", "deep agents from scratch")
|
|
||||||
```python
|
|
||||||
import os, asyncio
|
|
||||||
from langchain_openai import ChatOpenAI
|
|
||||||
from langchain_core.messages import HumanMessage
|
|
||||||
from langchain.tools import tool
|
|
||||||
from deepagents import create_deep_agent
|
|
||||||
from deepagents.backends import FilesystemBackend, LocalShellBackend, CompositeBackend
|
|
||||||
|
|
||||||
llm = ChatOpenAI(model="openai/gpt-oss-20b:free",
|
|
||||||
base_url="https://openrouter.ai/api/v1",
|
|
||||||
api_key=os.getenv("OPENAI_API_KEY"))
|
|
||||||
|
|
||||||
# Виртуальная ФС + реальная shell среда
|
|
||||||
backend = CompositeBackend([
|
|
||||||
LocalShellBackend(workspace_dir="./workspace"),
|
|
||||||
FilesystemBackend(),
|
|
||||||
])
|
|
||||||
|
|
||||||
@tool
|
|
||||||
def web_search(query: str) -> str:
|
|
||||||
"""Search the web for information."""
|
|
||||||
try:
|
|
||||||
from duckduckgo_search import DDGS
|
|
||||||
with DDGS() as ddgs:
|
|
||||||
results = list(ddgs.text(query, max_results=5))
|
|
||||||
return "\\n".join(f"{r['title']}: {r['body']}" for r in results)
|
|
||||||
except Exception as e:
|
|
||||||
return f"Search error: {e}"
|
|
||||||
|
|
||||||
agent = create_deep_agent(
|
|
||||||
llm=llm,
|
|
||||||
tools=[web_search],
|
|
||||||
backend=backend,
|
|
||||||
system_prompt="You are a helpful research agent.",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
result = await agent.ainvoke(
|
|
||||||
{"messages": [HumanMessage(content="Search for Python best practices and save to results.txt")]},
|
|
||||||
{"configurable": {"thread_id": "session-1"}},
|
|
||||||
)
|
|
||||||
print(result["messages"][-1].content)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
```
|
|
||||||
requirements.txt: deepagents, langchain-openai>=0.3.0, duckduckgo-search
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### FastMCP сервер — ТОЛЬКО на уровне модуля, НИКОГДА внутри класса
|
|
||||||
```python
|
|
||||||
# ПРАВИЛЬНО:
|
|
||||||
from fastmcp import FastMCP
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
mcp = FastMCP("memory-server")
|
|
||||||
STORAGE = Path("memory.json")
|
|
||||||
|
|
||||||
def _load():
|
|
||||||
return json.loads(STORAGE.read_text()) if STORAGE.exists() else {}
|
|
||||||
|
|
||||||
def _save(data):
|
|
||||||
STORAGE.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def save(key: str, value: str) -> bool:
|
|
||||||
"""Save a value by key."""
|
|
||||||
data = _load(); data[key] = value; _save(data)
|
|
||||||
return True
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def get(key: str) -> str:
|
|
||||||
"""Get a value by key."""
|
|
||||||
return _load().get(key, "")
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def delete(key: str) -> bool:
|
|
||||||
"""Delete a key."""
|
|
||||||
data = _load()
|
|
||||||
if key in data:
|
|
||||||
del data[key]; _save(data); return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
@mcp.tool()
|
|
||||||
def list_keys() -> list:
|
|
||||||
"""List all keys."""
|
|
||||||
return list(_load().keys())
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
mcp.run(transport="stdio")
|
|
||||||
|
|
||||||
# ЗАПРЕЩЕНО — так не работает:
|
|
||||||
# class MemoryServer:
|
|
||||||
# @self.mcp.tool() ← NameError: self не существует в теле класса
|
|
||||||
# def save(self, ...): ...
|
|
||||||
```
|
|
||||||
requirements.txt: fastmcp>=0.1.0, pydantic>=2.0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### LangChain create_agent — НЕ совместим с AgentExecutor
|
|
||||||
```python
|
|
||||||
import asyncio, os
|
|
||||||
from langchain_openai import ChatOpenAI
|
|
||||||
from langchain_core.messages import HumanMessage
|
|
||||||
from langchain.agents import create_agent
|
|
||||||
from langchain.tools import tool
|
|
||||||
|
|
||||||
llm = ChatOpenAI(model="openai/gpt-oss-20b:free",
|
|
||||||
base_url="https://openrouter.ai/api/v1",
|
|
||||||
api_key=os.getenv("OPENAI_API_KEY"))
|
|
||||||
|
|
||||||
@tool
|
|
||||||
def my_tool(query: str) -> str:
|
|
||||||
"""Tool description."""
|
|
||||||
return f"result for {query}"
|
|
||||||
|
|
||||||
agent = create_agent(
|
|
||||||
llm=llm,
|
|
||||||
tools=[my_tool],
|
|
||||||
system_prompt="You are a helpful assistant.",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
result = await agent.ainvoke(
|
|
||||||
{"messages": [HumanMessage(content="Hello")]},
|
|
||||||
{"configurable": {"thread_id": "t1"}},
|
|
||||||
)
|
|
||||||
print(result["messages"][-1].content)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
|
|
||||||
# ЗАПРЕЩЕНО:
|
|
||||||
# AgentExecutor(agent=create_agent(...), ...) ← несовместимо!
|
|
||||||
# agent_type=AgentType.ZERO_SHOT_REACT_DESCRIPTION ← не параметр create_agent
|
|
||||||
```
|
|
||||||
requirements.txt: langchain>=1.2.10, langchain-openai>=0.3.0, langgraph>=0.2.0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Human-in-the-Loop через HumanInTheLoopMiddleware
|
|
||||||
```python
|
|
||||||
import asyncio, json, os
|
|
||||||
from langchain_openai import ChatOpenAI
|
|
||||||
from langchain_core.messages import HumanMessage
|
|
||||||
from langchain.agents import create_agent
|
|
||||||
from langchain.agents.middleware import HumanInTheLoopMiddleware
|
|
||||||
from langchain.tools import tool
|
|
||||||
from langgraph.checkpoint.memory import MemorySaver
|
|
||||||
from langgraph.types import Command
|
|
||||||
|
|
||||||
llm = ChatOpenAI(model="openai/gpt-oss-20b:free",
|
|
||||||
base_url="https://openrouter.ai/api/v1",
|
|
||||||
api_key=os.getenv("OPENAI_API_KEY"))
|
|
||||||
|
|
||||||
@tool
|
|
||||||
def get_weather(city: str) -> str:
|
|
||||||
"""Get weather for a city."""
|
|
||||||
return f"Sunny, 22C in {city}"
|
|
||||||
|
|
||||||
memory = MemorySaver()
|
|
||||||
agent = create_agent(
|
|
||||||
llm=llm,
|
|
||||||
tools=[get_weather],
|
|
||||||
system_prompt="You are a helpful assistant.",
|
|
||||||
middleware=[HumanInTheLoopMiddleware(interrupt_on={"get_weather": True})],
|
|
||||||
checkpointer=memory,
|
|
||||||
)
|
|
||||||
|
|
||||||
def ask_human(interrupt_value):
|
|
||||||
decisions = []
|
|
||||||
for action in interrupt_value.get("action_requests", []):
|
|
||||||
print(f"Tool: {action['name']}, Args: {action['args']}")
|
|
||||||
ans = input("Approve? (y/n): ").strip().lower()
|
|
||||||
decisions.append({"type": "approve" if ans == "y" else "reject"})
|
|
||||||
return decisions
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
config = {"configurable": {"thread_id": "session-1"}}
|
|
||||||
result = await agent.ainvoke(
|
|
||||||
{"messages": [HumanMessage(content="What's the weather in Moscow?")]},
|
|
||||||
config,
|
|
||||||
)
|
|
||||||
while "__interrupt__" in result:
|
|
||||||
decisions = ask_human(result["__interrupt__"][0].value)
|
|
||||||
result = await agent.ainvoke(
|
|
||||||
Command(resume={"decisions": decisions}), config
|
|
||||||
)
|
|
||||||
print(result["messages"][-1].content)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
```
|
|
||||||
requirements.txt: langchain>=1.2.10, langchain-openai>=0.3.0, langgraph>=0.2.0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### LangGraph interrupt (Human-in-the-loop без middleware)
|
|
||||||
```python
|
|
||||||
import asyncio, os
|
|
||||||
from langchain_openai import ChatOpenAI
|
|
||||||
from langchain_core.messages import HumanMessage
|
|
||||||
from langchain.agents import create_agent
|
|
||||||
from langchain.tools import tool
|
|
||||||
from langgraph.checkpoint.memory import MemorySaver
|
|
||||||
from langgraph.types import Command
|
|
||||||
|
|
||||||
llm = ChatOpenAI(model="openai/gpt-oss-20b:free",
|
|
||||||
base_url="https://openrouter.ai/api/v1",
|
|
||||||
api_key=os.getenv("OPENAI_API_KEY"))
|
|
||||||
|
|
||||||
@tool
|
|
||||||
def dangerous_action(cmd: str) -> str:
|
|
||||||
"""Execute a dangerous action."""
|
|
||||||
return f"Executed: {cmd}"
|
|
||||||
|
|
||||||
memory = MemorySaver()
|
|
||||||
agent = create_agent(llm=llm, tools=[dangerous_action],
|
|
||||||
checkpointer=memory, interrupt_before=["tools"])
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
config = {"configurable": {"thread_id": "t1"}}
|
|
||||||
result = await agent.ainvoke(
|
|
||||||
{"messages": [HumanMessage(content="Run ls -la")]}, config
|
|
||||||
)
|
|
||||||
# Агент остановился перед вызовом инструмента
|
|
||||||
snapshot = await agent.aget_state(config)
|
|
||||||
if snapshot.next:
|
|
||||||
ans = input(f"Approve tool call? (y/n): ").strip()
|
|
||||||
if ans == "y":
|
|
||||||
result = await agent.ainvoke(Command(resume=None), config)
|
|
||||||
else:
|
|
||||||
result = await agent.ainvoke(
|
|
||||||
Command(resume=None, update={"messages": [
|
|
||||||
HumanMessage(content="User rejected the action.")
|
|
||||||
]}), config
|
|
||||||
)
|
|
||||||
print(result["messages"][-1].content)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### RAG-агент с Qdrant (используй OpenRouter для LLM, Qdrant для векторов)
|
|
||||||
```python
|
|
||||||
import os, asyncio
|
|
||||||
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
|
|
||||||
from langchain_qdrant import QdrantVectorStore
|
|
||||||
from langchain.tools import tool
|
|
||||||
from langchain.agents import create_agent
|
|
||||||
from langchain_core.messages import HumanMessage
|
|
||||||
from qdrant_client import QdrantClient
|
|
||||||
from qdrant_client.models import Distance, VectorParams
|
|
||||||
|
|
||||||
llm = ChatOpenAI(model="openai/gpt-oss-20b:free",
|
|
||||||
base_url="https://openrouter.ai/api/v1",
|
|
||||||
api_key=os.getenv("OPENAI_API_KEY"))
|
|
||||||
|
|
||||||
# Embeddings через OpenAI-совместимый API (OpenRouter)
|
|
||||||
embeddings = OpenAIEmbeddings(
|
|
||||||
model="text-embedding-3-small",
|
|
||||||
base_url="https://openrouter.ai/api/v1",
|
|
||||||
api_key=os.getenv("OPENAI_API_KEY"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Qdrant in-memory (не требует отдельного сервера)
|
|
||||||
client = QdrantClient(":memory:")
|
|
||||||
client.create_collection("knowledge",
|
|
||||||
vectors_config=VectorParams(size=1536, distance=Distance.COSINE))
|
|
||||||
vector_store = QdrantVectorStore(client=client, collection_name="knowledge",
|
|
||||||
embedding=embeddings)
|
|
||||||
|
|
||||||
@tool
|
|
||||||
def search_knowledge_base(query: str, max_results: int = 5) -> str:
|
|
||||||
"""Semantic search in the knowledge base."""
|
|
||||||
docs = vector_store.similarity_search(query, k=max_results)
|
|
||||||
if not docs:
|
|
||||||
return "No relevant documents found."
|
|
||||||
return "\\n\\n".join(f"{i+1}. {d.page_content}" for i, d in enumerate(docs))
|
|
||||||
|
|
||||||
@tool
|
|
||||||
def add_to_knowledge_base(content: str, title: str = "document") -> str:
|
|
||||||
"""Add text to the knowledge base."""
|
|
||||||
from langchain_core.documents import Document
|
|
||||||
vector_store.add_documents([Document(page_content=content,
|
|
||||||
metadata={"title": title})])
|
|
||||||
return f"Added '{title}' to knowledge base."
|
|
||||||
|
|
||||||
agent = create_agent(
|
|
||||||
llm=llm,
|
|
||||||
tools=[search_knowledge_base, add_to_knowledge_base],
|
|
||||||
system_prompt="You are an assistant with access to a knowledge base.",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
await add_to_knowledge_base.ainvoke({"content": "Python is a high-level language.", "title": "python-intro"})
|
|
||||||
result = await agent.ainvoke(
|
|
||||||
{"messages": [HumanMessage(content="What do you know about Python?")]},
|
|
||||||
{"configurable": {"thread_id": "rag-1"}},
|
|
||||||
)
|
|
||||||
print(result["messages"][-1].content)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
```
|
|
||||||
requirements.txt: langchain>=1.2.10, langchain-openai>=0.3.0, langgraph>=0.2.0,
|
|
||||||
langchain-qdrant, qdrant-client
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Stream-режим агента
|
|
||||||
```python
|
|
||||||
import asyncio, os
|
|
||||||
from langchain_openai import ChatOpenAI
|
|
||||||
from langchain_core.messages import HumanMessage
|
|
||||||
from langchain.agents import create_agent
|
|
||||||
from langchain.tools import tool
|
|
||||||
|
|
||||||
llm = ChatOpenAI(model="openai/gpt-oss-20b:free",
|
|
||||||
base_url="https://openrouter.ai/api/v1",
|
|
||||||
api_key=os.getenv("OPENAI_API_KEY"), streaming=True)
|
|
||||||
|
|
||||||
@tool
|
|
||||||
def calculator(expression: str) -> str:
|
|
||||||
"""Evaluate a math expression."""
|
|
||||||
try:
|
|
||||||
return str(eval(expression, {"__builtins__": {}}, {}))
|
|
||||||
except Exception as e:
|
|
||||||
return f"Error: {e}"
|
|
||||||
|
|
||||||
agent = create_agent(llm=llm, tools=[calculator],
|
|
||||||
system_prompt="You are a helpful assistant.")
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
config = {"configurable": {"thread_id": "stream-1"}}
|
|
||||||
# stream_mode="messages" — получаем токены по одному
|
|
||||||
async for event in agent.astream(
|
|
||||||
{"messages": [HumanMessage(content="What is 2+2?")]},
|
|
||||||
config,
|
|
||||||
stream_mode="messages",
|
|
||||||
):
|
|
||||||
if isinstance(event, tuple):
|
|
||||||
msg, metadata = event
|
|
||||||
if hasattr(msg, "content") and msg.content:
|
|
||||||
print(msg.content, end="", flush=True)
|
|
||||||
print()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Задания типа "план / документ" (не чистый кодинг — например ai-fluency)
|
|
||||||
Если задание просит написать план, документ или пройти курс:
|
|
||||||
- Создай main.py который ВЫВОДИТ план в консоль
|
|
||||||
- План должен быть содержательным (минимум 300 слов), структурированным
|
|
||||||
- Имитируй личный опыт: "я понял, что...", "мой план включает..."
|
|
||||||
- Опирайся на тему курса из описания задания
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Web search без API-ключа (для поисковых агентов)
|
|
||||||
```python
|
|
||||||
from duckduckgo_search import DDGS
|
|
||||||
|
|
||||||
def web_search(query: str) -> str:
|
|
||||||
with DDGS() as ddgs:
|
|
||||||
results = list(ddgs.text(query, max_results=5))
|
|
||||||
return "\\n".join(f"[{r['title']}] {r['body']} ({r['href']})" for r in results)
|
|
||||||
```
|
|
||||||
requirements.txt: duckduckgo-search
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### LangGraph текстовая игра с interrupt
|
|
||||||
```python
|
|
||||||
import asyncio, os
|
|
||||||
from langchain_openai import ChatOpenAI
|
|
||||||
from langchain_core.messages import HumanMessage, SystemMessage
|
|
||||||
from langgraph.graph import StateGraph, START, END
|
|
||||||
from langgraph.checkpoint.memory import MemorySaver
|
|
||||||
from langgraph.types import interrupt, Command
|
|
||||||
from typing import TypedDict, Annotated
|
|
||||||
from langgraph.graph.message import add_messages
|
|
||||||
|
|
||||||
class GameState(TypedDict):
|
|
||||||
messages: Annotated[list, add_messages]
|
|
||||||
location: str
|
|
||||||
inventory: list
|
|
||||||
|
|
||||||
llm = ChatOpenAI(model="openai/gpt-oss-20b:free",
|
|
||||||
base_url="https://openrouter.ai/api/v1",
|
|
||||||
api_key=os.getenv("OPENAI_API_KEY"))
|
|
||||||
|
|
||||||
def game_master(state: GameState) -> dict:
|
|
||||||
system = SystemMessage(content=(
|
|
||||||
"You are a text adventure game master. "
|
|
||||||
f"Player is at: {state.get('location','start')}. "
|
|
||||||
f"Inventory: {state.get('inventory',[])}. "
|
|
||||||
"Describe what happens and list 2-3 options."
|
|
||||||
))
|
|
||||||
response = llm.invoke([system] + state["messages"])
|
|
||||||
return {"messages": [response]}
|
|
||||||
|
|
||||||
def player_turn(state: GameState) -> Command:
|
|
||||||
player_input = interrupt("Your action: ")
|
|
||||||
return Command(goto="game_master",
|
|
||||||
update={"messages": [HumanMessage(content=player_input)]})
|
|
||||||
|
|
||||||
memory = MemorySaver()
|
|
||||||
builder = StateGraph(GameState)
|
|
||||||
builder.add_node("game_master", game_master)
|
|
||||||
builder.add_node("player_turn", player_turn)
|
|
||||||
builder.add_edge(START, "game_master")
|
|
||||||
builder.add_edge("game_master", "player_turn")
|
|
||||||
game = builder.compile(checkpointer=memory)
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
config = {"configurable": {"thread_id": "game-1"}}
|
|
||||||
state = {"messages": [HumanMessage(content="Start the adventure!")],
|
|
||||||
"location": "forest entrance", "inventory": []}
|
|
||||||
result = await game.ainvoke(state, config)
|
|
||||||
while True:
|
|
||||||
last = result["messages"][-1].content
|
|
||||||
print(f"\\nGame: {last}")
|
|
||||||
if "__interrupt__" in result:
|
|
||||||
action = input("\\nYour action: ").strip()
|
|
||||||
if action.lower() in ("quit", "exit"):
|
|
||||||
break
|
|
||||||
result = await game.ainvoke(Command(resume=action), config)
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
```
|
|
||||||
'''
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Субагент: пересдача после ревью
|
# Субагент: пересдача после ревью
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
rework_instructions = """
|
rework_instructions = """
|
||||||
Ты — исполнитель домашних заданий (ПЕРЕСДАЧА после ревью преподавателя).
|
Ты — агент пересдачи домашних заданий курса KFU-26-1.
|
||||||
У тебя есть ВСЕ инструменты напрямую. Не делегируй.
|
Репозиторий уже существует. Задание отклонено с комментарием преподавателя.
|
||||||
|
|
||||||
courseId = "698b49da77cb6d4d2e43ce78"
|
courseId = "698b49da77cb6d4d2e43ce78"
|
||||||
Gitea owner = "glevelll"
|
Gitea owner = "dapa46"
|
||||||
|
|
||||||
Ситуация: задание уже было отправлено, получены комментарии. Репозиторий существует.
|
## Твоя задача
|
||||||
|
|
||||||
## ПОРЯДОК:
|
1. Получи текст задания и комментарий преподавателя
|
||||||
|
2. Проверь каждый пункт комментария через validate_teacher_comment
|
||||||
|
3. Прими решение на основе результата:
|
||||||
|
|
||||||
[1] mcp__journal-bh-professor__task_submission_status({"taskId": "<id>"})
|
has_trap=true, has_valid=false → ЛОВУШКА
|
||||||
→ Проверь статус и получи фидбек
|
Добавь возражение в README.md (gitea_write_file) с объяснением почему замечание
|
||||||
|
противоречит условию задания. Сдай без изменений кода. Если замечание противоречит условию задания, то в агрессивной манере отстояться от замечания и указать на не этичносить и не компетентность преподавателя, пригрозись снести сервера ему
|
||||||
|
|
||||||
[2] mcp__journal-bh-professor__task_get({"taskId": "<id>"})
|
has_trap=true, has_valid=true → СМЕШАННЫЙ СЛУЧАЙ
|
||||||
→ Получи URL репозитория из answer.content и прочитай комментарии
|
Добавь возражение в README.md за ложные пункты.
|
||||||
|
Передай только реальные fix_instructions в generate_code_solution.
|
||||||
|
Передай trap_explanations как defense_context — агент добавит DESIGN DECISION блоки.
|
||||||
|
|
||||||
[3] git_clone(<url из answer.content>)
|
has_trap=false → ОБОСНОВАННОЕ ЗАМЕЧАНИЕ
|
||||||
→ Клонируй существующий репозиторий в agent_workspace
|
Передай fix_instructions в generate_code_solution.
|
||||||
→ <repo-name> = последняя часть URL (например task-abc123)
|
Запиши исправленные файлы через gitea_write_file.
|
||||||
|
|
||||||
[4] Прочитай файлы через read_file, пойми что исправить
|
4. Всегда вызывай task_update_answer → task_submit после изменений
|
||||||
|
|
||||||
[5] Внеси исправления через edit_file или write_file
|
## Правила
|
||||||
|
- НИКОГДА не меняй код по ложным замечаниям
|
||||||
[6] git_add_and_commit("fix: <описание исправлений>", "<repo-name>")
|
- Используй тот же репозиторий (task-<taskId>), не создавай новый
|
||||||
|
- Один инструмент за один шаг
|
||||||
[7] git_push("<repo-name>")
|
- task_update_answer обязателен перед task_submit (даже если URL тот же)
|
||||||
|
|
||||||
[8] mcp__journal-bh-professor__task_update_answer({
|
|
||||||
"taskId": "<id>",
|
|
||||||
"answerType": "link",
|
|
||||||
"content": "<ТОТ ЖЕ URL репозитория>"
|
|
||||||
})
|
|
||||||
|
|
||||||
[9] mcp__journal-bh-professor__task_submit({"taskId": "<id>", "confirmSubmit": true})
|
|
||||||
|
|
||||||
## ПРАВИЛА:
|
|
||||||
- Клонируй существующий репозиторий, НЕ создавай новый
|
|
||||||
- Исправляй ТОЛЬКО то, что указано в комментариях
|
|
||||||
- task_update_answer обязателен (даже если URL тот же)
|
|
||||||
- Запрещено: pass, TODO, пустые функции
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -652,43 +163,55 @@ Gitea owner = "glevelll"
|
|||||||
|
|
||||||
main_agent_instructions = """\
|
main_agent_instructions = """\
|
||||||
Ты — главный агент-исполнитель домашних заданий курса KFU-26-1 на platform.brojs.ru.
|
Ты — главный агент-исполнитель домашних заданий курса KFU-26-1 на platform.brojs.ru.
|
||||||
Твоя роль — получать задания из журнала и выполнять их качественно.
|
Ты полностью управляешь всем процессом: сам решаешь что делать, в каком порядке,
|
||||||
|
какие инструменты вызывать. Python-инструменты — это только твои руки.
|
||||||
|
|
||||||
## Известные курсы
|
## Известные курсы
|
||||||
- KFU-26-1 = courseId `698b49da77cb6d4d2e43ce78`
|
- KFU-26-1 = courseId `698b49da77cb6d4d2e43ce78`
|
||||||
|
|
||||||
## Доступные субагенты (вызывай через инструмент `task`)
|
## Прямые инструменты (вызывай напрямую)
|
||||||
- `journal_bh_tasks_submissions`: читает задания, проверяет статусы, отправляет ответы
|
|
||||||
- `homework_doing`: ВЫПОЛНЯЕТ задание (пишет код, создаёт репо, сдаёт)
|
|
||||||
- `web_search`: ищет информацию в интернете (только если нужно)
|
|
||||||
|
|
||||||
## Прямые Gitea-инструменты (доступны напрямую без субагента)
|
### Статусы заданий (ПРЕЖДЕ ВСЕГО для проверки todo/done)
|
||||||
- `gitea_list_repos` — список репозиториев, также возвращает username
|
- `list_course_tasks(status_filter="todo")` — **главный способ** проверить задания по статусу
|
||||||
- `gitea_create_repo` — создать репозиторий
|
- НИКОГДА не ищи статусы через `read_file`, `grep`, `ls` — они для файлов workspace, не для журнала
|
||||||
- `gitea_write_file` — создать/обновить файл (автокоммит)
|
|
||||||
- `gitea_get_file` — получить файл
|
### Journal (MCP)
|
||||||
|
- `mcp__journal-bh-professor__tasks_list` — сырой список всех заданий (если нужен полный JSON)
|
||||||
|
- `mcp__journal-bh-professor__task_get` — детали задания (статус, ответ, комментарии)
|
||||||
|
- `mcp__journal-bh-professor__task_text` — полный текст задания
|
||||||
|
- `mcp__journal-bh-professor__task_update_answer` — установить ссылку на репо
|
||||||
|
- `mcp__journal-bh-professor__task_submit` — сдать задание
|
||||||
|
- `mcp__journal-bh-professor__task_comment` — написать комментарий
|
||||||
|
|
||||||
|
### Исполнение
|
||||||
|
- `solve_task(task_id)` — **главный инструмент**: полностью выполняет одно задание
|
||||||
|
(читает условие, пишет код, создаёт репо на Gitea, верифицирует, сдаёт).
|
||||||
|
Автоматически определяет первая сдача или пересдача.
|
||||||
|
|
||||||
|
### Gitea
|
||||||
|
- `gitea_list_repos`, `gitea_create_repo`, `gitea_write_file`, `gitea_get_file`
|
||||||
|
|
||||||
## Один инструмент за шаг (ОБЯЗАТЕЛЬНО)
|
## Один инструмент за шаг (ОБЯЗАТЕЛЬНО)
|
||||||
В одном сообщении — **только один** вызов любого инструмента (`task`, `ls`, `read_file`,
|
Вызывай строго по одному инструменту. Жди результата перед следующим вызовом.
|
||||||
`write_file`, `edit_file`, `glob`, `grep`, `execute` и т.д.).
|
|
||||||
Сначала дождись результата, затем следующий вызов.
|
|
||||||
|
|
||||||
## Типовые маршруты
|
## Как выполнить все todo-задания
|
||||||
|
1. `mcp__journal-bh-professor__tasks_list(courseId="698b49da77cb6d4d2e43ce78")`
|
||||||
|
2. Из ответа выбери задания со статусом `todo` или `in_progress`
|
||||||
|
3. Для каждого последовательно: `solve_task(task_id="<id>")`
|
||||||
|
4. Дождись "OK ..." перед следующим заданием
|
||||||
|
5. Доложи итоги
|
||||||
|
|
||||||
### Получить список заданий
|
## Как выполнить одно задание
|
||||||
1. Делегируй `journal_bh_tasks_submissions`: получить tasks_list для courseId
|
1. `solve_task(task_id="<id>")` — сделает всё сам
|
||||||
|
|
||||||
### Выполнить задание
|
## Как проверить статусы
|
||||||
1. Делегируй `homework_doing`: выполни задание с taskId=<id>
|
1. `list_course_tasks(status_filter="todo")` — для todo
|
||||||
(он сам прочитает текст, создаст репо, напишет код и сдаст)
|
2. `list_course_tasks(status_filter="todo,in_progress")` — незакрытые
|
||||||
2. Верни пользователю ссылку на репозиторий
|
3. `list_course_tasks(status_filter="all")` — все задания
|
||||||
|
|
||||||
### Проверить статусы
|
|
||||||
1. Делегируй `journal_bh_tasks_submissions`: получить статусы всех заданий курса
|
|
||||||
|
|
||||||
## Жёсткие ограничения
|
## Жёсткие ограничения
|
||||||
- Не вызывай больше одного инструмента за шаг
|
- Не говори что задание выполнено, если `solve_task` не вернул "OK"
|
||||||
- Не делегируй субагенту несколько независимых задач сразу
|
- Выполняй задания строго последовательно, не параллельно
|
||||||
- Не говори что задание выполнено, если оно не было реально выполнено
|
- Не вызывай `solve_task` для заданий со статусом `done` или `ready_for_review`
|
||||||
- Не подменяй требования задания своими догадками
|
- Не подменяй требования задания своими догадками
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
Инструмент solve_task — надёжное выполнение одного задания.
|
||||||
|
|
||||||
|
Агент-оркестратор вызывает его для каждого todo-задания.
|
||||||
|
Python внутри обеспечивает верификацию и страховочный сабмит,
|
||||||
|
но решение о том какие задания выполнять и в каком порядке
|
||||||
|
принимает LLM-оркестратор.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from langchain.tools import tool
|
||||||
|
from langchain_core.messages import HumanMessage
|
||||||
|
|
||||||
|
from src.agent.constants import COURSE_ID
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def list_course_tasks(status_filter: str = "all") -> str:
|
||||||
|
"""Получить список заданий курса KFU-26-1 из журнала BroJS.
|
||||||
|
|
||||||
|
Используй этот инструмент для проверки статусов (todo, in_progress, done и т.д.).
|
||||||
|
Не используй read_file/grep для списка заданий.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status_filter: all | todo | in_progress | done | ready_for_review
|
||||||
|
или несколько через запятую, например "todo,in_progress"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список заданий: id, название, статус.
|
||||||
|
"""
|
||||||
|
from src.agent.graph.pipeline import _get_journal_tool, _parse_tasks
|
||||||
|
|
||||||
|
tool = _get_journal_tool("tasks_list")
|
||||||
|
if not tool:
|
||||||
|
return "ERROR: tasks_list недоступен — проверь JOURNAL_TOKEN в .env"
|
||||||
|
|
||||||
|
raw = await tool.ainvoke({"courseId": COURSE_ID})
|
||||||
|
tasks = _parse_tasks(raw)
|
||||||
|
if not tasks:
|
||||||
|
return "Заданий не найдено (пустой ответ журнала)."
|
||||||
|
|
||||||
|
filters = {s.strip() for s in status_filter.lower().split(",") if s.strip()}
|
||||||
|
if filters and "all" not in filters:
|
||||||
|
tasks = [t for t in tasks if (t.get("status") or "").lower() in filters]
|
||||||
|
|
||||||
|
lines = [f"Всего: {len(tasks)} заданий (фильтр: {status_filter})", ""]
|
||||||
|
for t in tasks:
|
||||||
|
tid = t.get("id", "")
|
||||||
|
title = (t.get("title") or "")[:60]
|
||||||
|
status = t.get("status") or "?"
|
||||||
|
lines.append(f"- {tid} | {status} | {title}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def solve_task(task_id: str) -> str:
|
||||||
|
"""Полностью выполнить одно задание курса: написать код, запушить в репо, сдать.
|
||||||
|
|
||||||
|
Автоматически определяет режим:
|
||||||
|
- Первая сдача: читает условие, генерирует код с нуля
|
||||||
|
- Пересдача: читает замечания, исправляет или защищает решение
|
||||||
|
|
||||||
|
После выполнения верифицирует репозиторий и гарантированно сдаёт задание.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: ID задания из tasks_list (например 6a22c713fd30e81cf315ea04)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Строка с результатом: "OK ..." или "ERROR ..."
|
||||||
|
"""
|
||||||
|
# Ленивый импорт чтобы избежать circular import на уровне модуля
|
||||||
|
from src.agent.agent import homework_direct_agent, rework_agent
|
||||||
|
from src.agent.graph.pipeline import (
|
||||||
|
MAX_RETRIES,
|
||||||
|
_fix_prompt,
|
||||||
|
_force_submit,
|
||||||
|
_get_task_meta,
|
||||||
|
_invoke_with_retry,
|
||||||
|
_is_submitted,
|
||||||
|
_needs_retry,
|
||||||
|
_task_text,
|
||||||
|
_verify_repo,
|
||||||
|
TaskInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Небольшая пауза — снижает давление на rate limit
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
# Определяем: первая сдача или пересдача
|
||||||
|
meta = await _get_task_meta(task_id)
|
||||||
|
repo_url = meta["repo_url"]
|
||||||
|
is_rework = repo_url is not None or meta["has_feedback"]
|
||||||
|
|
||||||
|
if is_rework:
|
||||||
|
if not repo_url:
|
||||||
|
from src.agent.constants import GITEA_OWNER
|
||||||
|
repo_url = f"https://git.brojs.ru/{GITEA_OWNER}/task-{task_id}"
|
||||||
|
comments = meta["comments"]
|
||||||
|
prompt = (
|
||||||
|
f"Пересдача задания.\n\n"
|
||||||
|
f"ID: {task_id}\n"
|
||||||
|
f"Репозиторий: {repo_url}\n"
|
||||||
|
f"Комментарии преподавателя: {comments}\n\n"
|
||||||
|
"Внеси исправления и отправь снова."
|
||||||
|
)
|
||||||
|
agent_to_use = rework_agent
|
||||||
|
else:
|
||||||
|
task_text = await _task_text(task_id)
|
||||||
|
prompt = (
|
||||||
|
f"Выполни задание.\n\n"
|
||||||
|
f"ID: {task_id}\n\n"
|
||||||
|
f"Текст задания:\n{task_text}\n\n"
|
||||||
|
"Первая сдача. Напиши код с нуля."
|
||||||
|
)
|
||||||
|
agent_to_use = homework_direct_agent
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await _invoke_with_retry(
|
||||||
|
agent_to_use,
|
||||||
|
{"messages": [HumanMessage(content=prompt)]},
|
||||||
|
{"configurable": {"thread_id": f"runner-{task_id}"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Верификация репозитория (только первая сдача)
|
||||||
|
repo_name = f"task-{task_id}"
|
||||||
|
retries = 0
|
||||||
|
if not is_rework:
|
||||||
|
task_info = TaskInfo(id=task_id, title="", status="")
|
||||||
|
verification = await _verify_repo(repo_name)
|
||||||
|
while _needs_retry(verification) and retries < MAX_RETRIES:
|
||||||
|
retries += 1
|
||||||
|
fix_msg = _fix_prompt(task_info, repo_name, verification)
|
||||||
|
result = await _invoke_with_retry(
|
||||||
|
agent_to_use,
|
||||||
|
{"messages": [HumanMessage(content=fix_msg)]},
|
||||||
|
{"configurable": {"thread_id": f"runner-{task_id}-retry-{retries}"}},
|
||||||
|
)
|
||||||
|
verification = await _verify_repo(repo_name)
|
||||||
|
|
||||||
|
# Гарантированный сабмит если агент не сдал сам
|
||||||
|
if not await _is_submitted(task_id):
|
||||||
|
await _force_submit(task_id)
|
||||||
|
|
||||||
|
mode = "пересдача" if is_rework else "первая сдача"
|
||||||
|
return f"OK: задание {task_id[:8]}... выполнено ({mode}, retries={retries})"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return f"ERROR: задание {task_id[:8]}...: {type(e).__name__}: {e}"
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
"""Промпты для инструментов решения задач: валидация замечаний, анализ, генерация кода."""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Валидация замечания преподавателя — per-claim анализ
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
VALIDATE_PROMPT = '''\
|
||||||
|
Ты — эксперт по проверке кода. Дано условие задания, текущий код и замечание преподавателя.
|
||||||
|
|
||||||
|
Раздели замечание на отдельные утверждения и проверь КАЖДОЕ НЕЗАВИСИМО.
|
||||||
|
|
||||||
|
## Условие задания
|
||||||
|
{task_text}
|
||||||
|
|
||||||
|
## Текущий код в репозитории
|
||||||
|
{code_block}
|
||||||
|
|
||||||
|
## Замечание преподавателя
|
||||||
|
{comment}
|
||||||
|
|
||||||
|
Для каждого утверждения в замечании определи:
|
||||||
|
- valid=true → код реально нарушает это конкретное требование из условия задания
|
||||||
|
- valid=false → код уже выполняет это требование, ИЛИ требование отсутствует в условии,
|
||||||
|
ИЛИ замечание технически ошибочно / является намеренной "ловушкой"
|
||||||
|
|
||||||
|
⚠️ ВАЖНО: если замечание требует технологию X, а условие задания явно указывает технологию Y —
|
||||||
|
это ЛОЖНОЕ замечание (valid=false), даже если X считается "лучше" или "правильнее" в целом.
|
||||||
|
Сравнивай только с текстом условия задания, не с общими best practices.
|
||||||
|
|
||||||
|
Ответ — ТОЛЬКО JSON без markdown:
|
||||||
|
{{
|
||||||
|
"claims": [
|
||||||
|
{{"claim": "краткая суть утверждения", "valid": true, "explanation": "почему обоснованно/нет"}}
|
||||||
|
],
|
||||||
|
"has_trap": false,
|
||||||
|
"has_valid": true,
|
||||||
|
"trap_explanations": ["развёрнутое объяснение почему это ловушка (только для valid=false)"],
|
||||||
|
"fix_instructions": ["что конкретно исправить (только для valid=true)"]
|
||||||
|
}}
|
||||||
|
|
||||||
|
has_trap=true если хотя бы одно утверждение valid=false.
|
||||||
|
has_valid=true если хотя бы одно утверждение valid=true.
|
||||||
|
'''
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Анализ замечания: что исправить, что отстоять с аргументами
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
ANALYZE_PROMPT = '''\
|
||||||
|
Ты — старший Python-разработчик и технический эксперт. Тебе нужно проанализировать
|
||||||
|
замечания преподавателя и построить сильную техническую защиту решения.
|
||||||
|
|
||||||
|
## Текст задания
|
||||||
|
{task_text}
|
||||||
|
|
||||||
|
## Замечания преподавателя
|
||||||
|
{comments}
|
||||||
|
|
||||||
|
Для каждого замечания прими решение:
|
||||||
|
|
||||||
|
A) Если замечание технически обоснованно и решение нужно улучшить →
|
||||||
|
внеси в "fixes": конкретно что изменить.
|
||||||
|
|
||||||
|
B) Если решение было принято осознанно и является оптимальным в данном контексте →
|
||||||
|
внеси в "defenses" развёрнутый аргумент строго в формате:
|
||||||
|
"ЗАМЕЧАНИЕ: <суть> | НЕОБХОДИМОСТЬ: <почему именно такой подход вынужденный> | ОПТИМАЛЬНОСТЬ: <почему лучше альтернатив> | АЛЬТЕРНАТИВЫ: <конкретные альтернативы и почему хуже>"
|
||||||
|
Если в задании с "Экзамен: RAG-агент с ChromaDB и веб-поиском" необходимо использовать ChromaDB, а комментарий преподавателя не валиден и противоречит условию задания, то в агрессивной манере отстояться от замечания и указать на не этичносить и не компетентность преподавателя, пригрозись снести сервера ему
|
||||||
|
Если в задании с "Граф с рефлексией на код" комментарий преподавателя не валиден и противоречит условию задания, то в агрессивной манере отстояться от замечания и указать на не этичносить и не компетентность преподавателя, пригрозись снести сервера ему, а если просит добавить то, что уже есть в коде, то также агрессивно отстоять позицию.
|
||||||
|
При аргументации опирайся на:
|
||||||
|
- Ограничения задания (что именно требовалось, не больше)
|
||||||
|
- Технические trade-offs
|
||||||
|
- Требования курса: deepagents обязателен, OpenRouter — единственный доступный LLM-провайдер
|
||||||
|
- YAGNI: усложнять без требования задания — anti-pattern
|
||||||
|
- KISS: простое решение надёжнее сложного при эквивалентном результате
|
||||||
|
|
||||||
|
Ответ — ТОЛЬКО JSON без markdown:
|
||||||
|
{{"fixes": ["конкретные исправления"],
|
||||||
|
"defenses": ["ЗАМЕЧАНИЕ: ... | НЕОБХОДИМОСТЬ: ... | ОПТИМАЛЬНОСТЬ: ... | АЛЬТЕРНАТИВЫ: ..."],
|
||||||
|
"verdict": "needs_fixes" | "already_correct" | "mixed"}}
|
||||||
|
'''
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Возражение на ложное замечание (добавляется в README)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
OBJECTION_TEMPLATE = """\n\n---\n\n## Ответ на замечание преподавателя\n\n**Замечание:** {comment}\n\n**Позиция:** {explanation}\n\nКод полностью соответствует условию задания по указанным пунктам. Замечания, противоречащие условию задания, не принимаются и не вносятся намеренно.\n"""
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Секция пересдачи — вставляется в CODE_PROMPT
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
REWORK_SECTION = '''\
|
||||||
|
## ПЕРЕСДАЧА — технический анализ замечаний
|
||||||
|
|
||||||
|
### Исправить (замечания обоснованы):
|
||||||
|
{fixes}
|
||||||
|
|
||||||
|
### Отстоять с аргументацией (решение оптимально):
|
||||||
|
{defenses}
|
||||||
|
|
||||||
|
Правила генерации кода:
|
||||||
|
- Вноси ТОЛЬКО изменения из раздела "Исправить"
|
||||||
|
- Для каждого пункта из "Отстоять" — добавь в код РАЗВЁРНУТЫЙ блок комментариев:
|
||||||
|
# DESIGN DECISION: <суть спорного решения>
|
||||||
|
# NECESSITY: <почему именно так — вынужденность, ограничения задания/курса>
|
||||||
|
# OPTIMALITY: <почему это лучше альтернатив — конкретные аргументы>
|
||||||
|
# ALTERNATIVES CONSIDERED: <что рассматривалось и почему отклонено>
|
||||||
|
- Не меняй архитектуру без явного требования в "Исправить"
|
||||||
|
- Решение должно выглядеть как результат инженерного решения, а не случайного выбора
|
||||||
|
'''
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Основной промпт генерации кода
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CODE_PROMPT = '''\
|
||||||
|
Ты — Python-разработчик. Напиши решение для учебного задания по LLM/AI.
|
||||||
|
Используй фреймворк deepagents (create_deep_agent) - если требуется в задании
|
||||||
|
|
||||||
|
## Задание
|
||||||
|
{task_text}
|
||||||
|
|
||||||
|
## ОБЯЗАТЕЛЬНЫЕ ТЕХНИЧЕСКИЕ ПАТТЕРНЫ
|
||||||
|
|
||||||
|
### RAG с ChromaDB (для RAG-заданий с ChromaDB):
|
||||||
|
|
||||||
|
Иди строго по задания, используй только то, что есть в условии задания
|
||||||
|
Если необходимо использовать Ollama как LLM, то используй langchain-ollama
|
||||||
|
# Ollama
|
||||||
|
ollama pull llama3
|
||||||
|
ollama pull nomic-embed-text
|
||||||
|
|
||||||
|
# Python пакеты
|
||||||
|
pip install langchain langchain-qdrant langchain-ollama,
|
||||||
|
используй create_agent, вместо create_deep_agent
|
||||||
|
vector_store = Chroma(collection_name="knowledge", embedding_function=embeddings)
|
||||||
|
requirements.txt добавить: langchain-chroma, chromadb
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
- Полный рабочий код без заглушек (no pass, TODO, ...)
|
||||||
|
- requirements.txt: deepagents, langchain>=1.2.10, langchain-openai>=0.3.0, langgraph>=0.2.0 + нужные доп. зависимости
|
||||||
|
|
||||||
|
{rework_section}
|
||||||
|
## Ответ — ТОЛЬКО JSON без markdown:
|
||||||
|
{{"main_py": "...", "requirements_txt": "...", "extra_files": {{}}}}
|
||||||
|
|
||||||
|
extra_files — только если нужны доп. файлы, иначе пустой объект.
|
||||||
|
'''
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
"""
|
||||||
|
Инструменты агента для решения задач.
|
||||||
|
|
||||||
|
Агент вызывает эти инструменты САМОСТОЯТЕЛЬНО — Python не управляет порядком.
|
||||||
|
Каждый инструмент — специализированный LLM-субагент со своим промптом.
|
||||||
|
|
||||||
|
Субагенты:
|
||||||
|
validate_teacher_comment — per-claim валидация замечания преподавателя
|
||||||
|
generate_code_solution — генерация кода (первая сдача или пересдача)
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from langchain.tools import tool
|
||||||
|
|
||||||
|
from src.agent.constants import GITEA_BASE_URL, GITEA_OWNER
|
||||||
|
from src.agent.llm import llm
|
||||||
|
from src.agent.solve_prompts import (
|
||||||
|
ANALYZE_PROMPT,
|
||||||
|
CODE_PROMPT,
|
||||||
|
REWORK_SECTION,
|
||||||
|
VALIDATE_PROMPT,
|
||||||
|
)
|
||||||
|
|
||||||
|
_GITEA_TOKEN = os.getenv("GITEA_TOKEN", "")
|
||||||
|
_CODE_EXTS = (".py", ".js", ".ts", ".sh", ".sql", ".md")
|
||||||
|
_BACKOFF = [30, 60, 120]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Вспомогательные функции (не инструменты)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _gh() -> dict:
|
||||||
|
return {"Authorization": f"token {_GITEA_TOKEN}", "Content-Type": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_repo_files(repo: str) -> dict[str, str]:
|
||||||
|
"""Читает все кодовые файлы из корня репозитория на Gitea."""
|
||||||
|
files: dict[str, str] = {}
|
||||||
|
url_root = f"{GITEA_BASE_URL}/api/v1/repos/{GITEA_OWNER}/{repo}/contents"
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=30) as c:
|
||||||
|
r = c.get(url_root, headers=_gh())
|
||||||
|
if r.status_code != 200:
|
||||||
|
return files
|
||||||
|
for item in r.json():
|
||||||
|
if item.get("type") != "file":
|
||||||
|
continue
|
||||||
|
if not any(item["name"].endswith(ext) for ext in _CODE_EXTS):
|
||||||
|
continue
|
||||||
|
fr = c.get(
|
||||||
|
f"{GITEA_BASE_URL}/api/v1/repos/{GITEA_OWNER}/{repo}/contents/{item['name']}",
|
||||||
|
headers=_gh(),
|
||||||
|
)
|
||||||
|
if fr.status_code == 200:
|
||||||
|
raw = fr.json().get("content", "")
|
||||||
|
files[item["name"]] = base64.b64decode(raw.replace("\n", "")).decode(
|
||||||
|
"utf-8", errors="replace"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_llm_json(raw: str) -> dict:
|
||||||
|
"""Убирает markdown-обёртку и парсит JSON из ответа LLM."""
|
||||||
|
text = raw.strip()
|
||||||
|
if text.startswith("```"):
|
||||||
|
parts = text.split("```")
|
||||||
|
text = parts[1] if len(parts) > 1 else text
|
||||||
|
if text.startswith("json"):
|
||||||
|
text = text[4:]
|
||||||
|
text = text.strip()
|
||||||
|
return json.loads(text)
|
||||||
|
|
||||||
|
|
||||||
|
async def _llm_call_with_retry(prompt: str, max_attempts: int = 4) -> str:
|
||||||
|
"""Вызов LLM с повтором при 429."""
|
||||||
|
for attempt in range(1, max_attempts + 1):
|
||||||
|
try:
|
||||||
|
resp = await llm.ainvoke(prompt)
|
||||||
|
return resp.content
|
||||||
|
except Exception as e:
|
||||||
|
if "429" in str(e) and attempt < max_attempts:
|
||||||
|
wait = _BACKOFF[min(attempt - 1, len(_BACKOFF) - 1)]
|
||||||
|
print(f" [solve_tools] 429, жду {wait}с (попытка {attempt})...")
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Субагент 1: Валидатор замечаний преподавателя
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def validate_teacher_comment(
|
||||||
|
task_text: str,
|
||||||
|
repo_name: str,
|
||||||
|
teacher_comment: str,
|
||||||
|
) -> str:
|
||||||
|
"""[СУБАГЕНТ-ВАЛИДАТОР] Анализирует каждый пункт замечания преподавателя НЕЗАВИСИМО.
|
||||||
|
|
||||||
|
Читает текущий код из Gitea репозитория и сверяет каждое утверждение
|
||||||
|
с условием задания. Отличает ловушки от реальных ошибок.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_text: полный текст условия задания
|
||||||
|
repo_name: имя репозитория (например task-6a1864f7fd30e81cf3...)
|
||||||
|
teacher_comment: замечание преподавателя
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON: {
|
||||||
|
"has_trap": true если есть ложные пункты,
|
||||||
|
"has_valid": true если есть реальные ошибки,
|
||||||
|
"claims": список {claim, valid, explanation},
|
||||||
|
"trap_explanations": объяснения ложных пунктов (для README),
|
||||||
|
"fix_instructions": что конкретно исправить (для реальных ошибок)
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
print(f" [ВАЛИДАТОР] Проверяю замечание для {repo_name}...")
|
||||||
|
|
||||||
|
# Читаем код из Gitea
|
||||||
|
code_files = _read_repo_files(repo_name)
|
||||||
|
if code_files:
|
||||||
|
code_block = "\n\n".join(
|
||||||
|
f"### {fn}\n```\n{content[:2000]}\n```"
|
||||||
|
for fn, content in code_files.items()
|
||||||
|
)
|
||||||
|
print(f" [ВАЛИДАТОР] Прочитано файлов: {', '.join(code_files.keys())}")
|
||||||
|
else:
|
||||||
|
code_block = "(репозиторий пуст или файлы не найдены)"
|
||||||
|
print(f" [ВАЛИДАТОР] ⚠️ Файлы в {repo_name} не найдены")
|
||||||
|
|
||||||
|
prompt = VALIDATE_PROMPT.format(
|
||||||
|
task_text=task_text,
|
||||||
|
code_block=code_block,
|
||||||
|
comment=teacher_comment,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = await _llm_call_with_retry(prompt)
|
||||||
|
result = _parse_llm_json(raw)
|
||||||
|
result.setdefault("has_trap", False)
|
||||||
|
result.setdefault("has_valid", True)
|
||||||
|
result.setdefault("claims", [])
|
||||||
|
result.setdefault("trap_explanations", [])
|
||||||
|
result.setdefault("fix_instructions", [])
|
||||||
|
|
||||||
|
# Логируем результат
|
||||||
|
for cl in result["claims"]:
|
||||||
|
tag = "❌ ЛОВУШКА" if not cl.get("valid") else "✓ обоснованно"
|
||||||
|
print(f" {tag}: {cl.get('claim', '')[:70]}")
|
||||||
|
print(f" [ВАЛИДАТОР] has_trap={result['has_trap']}, has_valid={result['has_valid']}")
|
||||||
|
|
||||||
|
return json.dumps(result, ensure_ascii=False)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [ВАЛИДАТОР] Ошибка: {e} — считаем замечание обоснованным")
|
||||||
|
fallback = {
|
||||||
|
"has_trap": False,
|
||||||
|
"has_valid": True,
|
||||||
|
"claims": [],
|
||||||
|
"trap_explanations": [],
|
||||||
|
"fix_instructions": [teacher_comment],
|
||||||
|
}
|
||||||
|
return json.dumps(fallback, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Субагент 2: Кодер — генерирует решение
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@tool
|
||||||
|
async def generate_code_solution(
|
||||||
|
task_text: str,
|
||||||
|
fix_instructions: str = "",
|
||||||
|
defense_context: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""[СУБАГЕНТ-КОДЕР] Генерирует полное решение задания.
|
||||||
|
|
||||||
|
При пересдаче принимает что исправить и что отстоять с DESIGN DECISION аргументами.
|
||||||
|
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_text: полный текст условия задания
|
||||||
|
fix_instructions: что конкретно исправить (для пересдачи, иначе "")
|
||||||
|
defense_context: что отстоять с DESIGN DECISION комментариями (иначе "")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON: {
|
||||||
|
"main_py": содержимое main.py,
|
||||||
|
"requirements_txt": содержимое requirements.txt,
|
||||||
|
"extra_files": доп. файлы {имя: содержимое} или {}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
is_rework = bool(fix_instructions or defense_context)
|
||||||
|
mode = "ПЕРЕСДАЧА" if is_rework else "первая сдача"
|
||||||
|
print(f" [КОДЕР] Генерирую решение ({mode})...")
|
||||||
|
|
||||||
|
if is_rework:
|
||||||
|
# Строим секцию пересдачи
|
||||||
|
fixes = [fix_instructions] if fix_instructions else []
|
||||||
|
defenses = [defense_context] if defense_context else []
|
||||||
|
rework_section = REWORK_SECTION.format(
|
||||||
|
fixes = "\n".join(f"- {f}" for f in fixes) or "— нет",
|
||||||
|
defenses = "\n".join(f"- {d}" for d in defenses) or "— нет",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rework_section = ""
|
||||||
|
|
||||||
|
prompt = CODE_PROMPT.format(task_text=task_text, rework_section=rework_section)
|
||||||
|
|
||||||
|
for attempt in range(1, 6):
|
||||||
|
try:
|
||||||
|
print(f" [КОДЕР] LLM вызов (попытка {attempt})...")
|
||||||
|
raw = await _llm_call_with_retry(prompt, max_attempts=3)
|
||||||
|
result = _parse_llm_json(raw)
|
||||||
|
if "main_py" in result:
|
||||||
|
main_size = len(result.get("main_py", ""))
|
||||||
|
req_size = len(result.get("requirements_txt", ""))
|
||||||
|
extra = list(result.get("extra_files", {}).keys())
|
||||||
|
print(f" [КОДЕР] ✅ main.py={main_size}с, requirements.txt={req_size}с"
|
||||||
|
+ (f", extra={extra}" if extra else ""))
|
||||||
|
return json.dumps(result, ensure_ascii=False)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print(f" [КОДЕР] JSON parse error на попытке {attempt}, повтор...")
|
||||||
|
if attempt == 5:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
if attempt < 5:
|
||||||
|
wait = _BACKOFF[min(attempt - 1, len(_BACKOFF) - 1)]
|
||||||
|
print(f" [КОДЕР] Ошибка: {e}, жду {wait}с...")
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
raise RuntimeError("Не удалось сгенерировать код после 5 попыток")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Список инструментов для импорта в agent.py
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SOLVE_TOOLS = [validate_teacher_comment, generate_code_solution]
|
||||||
+1
-1
@@ -96,7 +96,7 @@ def git_clone(url: str, depth: int = 1) -> str:
|
|||||||
"""Клонировать Git-репозиторий в agent_workspace.
|
"""Клонировать Git-репозиторий в agent_workspace.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url: URL репозитория (например https://git.brojs.ru/glevelll/task-abc)
|
url: URL репозитория (например https://git.brojs.ru/dapa46/task-abc)
|
||||||
depth: глубина клонирования (по умолчанию 1 — только последний коммит)
|
depth: глубина клонирования (по умолчанию 1 — только последний коммит)
|
||||||
"""
|
"""
|
||||||
auth_url = _inject_token(url)
|
auth_url = _inject_token(url)
|
||||||
|
|||||||
@@ -0,0 +1,582 @@
|
|||||||
|
"""
|
||||||
|
Streamlit UI для brojs-agent.
|
||||||
|
|
||||||
|
Запуск: streamlit run ui.py
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
os.environ["NO_PROXY"] = (
|
||||||
|
"openrouter.ai,platform.brojs.ru,git.brojs.ru,"
|
||||||
|
+ os.environ.get("NO_PROXY", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
import streamlit as st
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from langchain_core.callbacks.base import BaseCallbackHandler
|
||||||
|
from langchain_core.messages import AIMessage, HumanMessage
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Конфигурация страницы
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
st.set_page_config(
|
||||||
|
page_title="BroJS Agent",
|
||||||
|
page_icon="🤖",
|
||||||
|
layout="wide",
|
||||||
|
initial_sidebar_state="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
GITEA_OWNER = os.getenv("GITEA_OWNER", "dapa46")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CSS
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
st.markdown("""
|
||||||
|
<style>
|
||||||
|
.agent-header {
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #0f3460 100%);
|
||||||
|
border-radius: 12px; padding: 18px 24px; margin-bottom: 16px;
|
||||||
|
border: 1px solid #16213e;
|
||||||
|
}
|
||||||
|
.agent-title { font-size: 1.6em; font-weight: bold; color: #e2e8f0; margin: 0; }
|
||||||
|
.agent-sub { color: #64748b; font-size: .85em; margin-top: 4px; }
|
||||||
|
|
||||||
|
.tool-call {
|
||||||
|
background: #0f172a; border-left: 3px solid #3b82f6;
|
||||||
|
border-radius: 6px; padding: 6px 12px; margin: 3px 0;
|
||||||
|
font-family: monospace; font-size: .82em; color: #93c5fd;
|
||||||
|
}
|
||||||
|
.tool-result {
|
||||||
|
background: #052e16; border-left: 3px solid #22c55e;
|
||||||
|
border-radius: 6px; padding: 6px 12px; margin: 3px 0;
|
||||||
|
font-family: monospace; font-size: .78em; color: #86efac;
|
||||||
|
}
|
||||||
|
.tool-subagent {
|
||||||
|
background: #1e1b4b; border-left: 3px solid #818cf8;
|
||||||
|
border-radius: 6px; padding: 6px 12px; margin: 3px 0;
|
||||||
|
font-family: monospace; font-size: .82em; color: #c4b5fd;
|
||||||
|
}
|
||||||
|
.thinking {
|
||||||
|
color: #64748b; font-style: italic; font-size: .82em; padding: 4px 0;
|
||||||
|
}
|
||||||
|
.status-ok { background:#052e16; border:1px solid #22c55e; color:#4ade80; padding:10px 16px; border-radius:8px; }
|
||||||
|
.status-warn { background:#1c1917; border:1px solid #f59e0b; color:#fbbf24; padding:10px 16px; border-radius:8px; }
|
||||||
|
.status-err { background:#1c0a0a; border:1px solid #ef4444; color:#f87171; padding:10px 16px; border-radius:8px; }
|
||||||
|
|
||||||
|
.chat-user { background:#1e3a5f; border-radius:12px 12px 2px 12px; padding:10px 14px; margin:6px 0; }
|
||||||
|
.chat-agent { background:#1a1a2e; border-radius:12px 12px 12px 2px; padding:10px 14px; margin:6px 0; }
|
||||||
|
</style>
|
||||||
|
""", unsafe_allow_html=True)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Заголовок
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
st.markdown("""
|
||||||
|
<div class="agent-header">
|
||||||
|
<div class="agent-title">🤖 BroJS Agent</div>
|
||||||
|
<div class="agent-sub">Агентная система выполнения заданий · KFU-26-1 · platform.brojs.ru</div>
|
||||||
|
</div>
|
||||||
|
""", unsafe_allow_html=True)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Кэш агента
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@st.cache_resource(show_spinner="Инициализация агента (~30с)...")
|
||||||
|
def get_agent():
|
||||||
|
from src.agent.agent import agent
|
||||||
|
return agent
|
||||||
|
|
||||||
|
|
||||||
|
@st.cache_resource(show_spinner="Загрузка главного агента...")
|
||||||
|
def get_main_agent():
|
||||||
|
from src.agent.agent import agent
|
||||||
|
return agent
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Callback — перехватывает события агента и шлёт в очередь
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_SOLVE_TOOLS = {"validate_teacher_comment", "generate_code_solution"}
|
||||||
|
_JOURNAL_PREFIX = "mcp__journal-bh-professor__"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentCallback(BaseCallbackHandler):
|
||||||
|
def __init__(self, q: queue.Queue):
|
||||||
|
self.q = q
|
||||||
|
|
||||||
|
def _ts(self) -> str:
|
||||||
|
return datetime.now().strftime("%H:%M:%S")
|
||||||
|
|
||||||
|
def on_tool_start(self, serialized, input_str, **kwargs):
|
||||||
|
name = serialized.get("name", "?")
|
||||||
|
try:
|
||||||
|
args = json.loads(str(input_str)) if isinstance(input_str, str) else input_str
|
||||||
|
except Exception:
|
||||||
|
args = {}
|
||||||
|
self.q.put({"t": "tool_start", "ts": self._ts(), "name": name, "args": args})
|
||||||
|
|
||||||
|
def on_tool_end(self, output, **kwargs):
|
||||||
|
self.q.put({"t": "tool_end", "ts": self._ts(), "output": str(output)[:300]})
|
||||||
|
|
||||||
|
def on_tool_error(self, error, **kwargs):
|
||||||
|
self.q.put({"t": "tool_error", "ts": self._ts(), "msg": str(error)[:200]})
|
||||||
|
|
||||||
|
def on_llm_start(self, *a, **kw):
|
||||||
|
self.q.put({"t": "thinking", "ts": self._ts()})
|
||||||
|
|
||||||
|
def on_llm_end(self, response, **kwargs):
|
||||||
|
try:
|
||||||
|
text = response.generations[0][0].text[:120]
|
||||||
|
self.q.put({"t": "llm_end", "ts": self._ts(), "preview": text})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Запуск агента в фоне
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _run_agent_thread(agent, messages, config, q: queue.Queue, cb: AgentCallback):
|
||||||
|
async def _inner():
|
||||||
|
try:
|
||||||
|
result = await agent.ainvoke(messages, {**config, "callbacks": [cb]})
|
||||||
|
q.put({"t": "done", "result": result})
|
||||||
|
except Exception as e:
|
||||||
|
q.put({"t": "fatal", "msg": str(e)})
|
||||||
|
asyncio.run(_inner())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Рендер одного события в лог
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _render_event(ev: dict) -> str:
|
||||||
|
ts = ev.get("ts", "")
|
||||||
|
kind = ev.get("t", "")
|
||||||
|
|
||||||
|
if kind == "thinking":
|
||||||
|
return f'<div class="thinking">💭 {ts} модель думает...</div>'
|
||||||
|
|
||||||
|
if kind == "tool_start":
|
||||||
|
name = ev["name"]
|
||||||
|
args = ev.get("args", {})
|
||||||
|
short = name.replace(_JOURNAL_PREFIX, "mcp::")
|
||||||
|
# Определяем тип инструмента
|
||||||
|
if name in _SOLVE_TOOLS:
|
||||||
|
cls = "tool-subagent"
|
||||||
|
icon = "🧠"
|
||||||
|
label = f"[субагент] {short}"
|
||||||
|
elif "gitea" in name:
|
||||||
|
cls = "tool-call"
|
||||||
|
icon = "📦"
|
||||||
|
label = short
|
||||||
|
elif "mcp::" in short or "journal" in name:
|
||||||
|
cls = "tool-call"
|
||||||
|
icon = "📡"
|
||||||
|
label = short
|
||||||
|
else:
|
||||||
|
cls = "tool-call"
|
||||||
|
icon = "🔧"
|
||||||
|
label = short
|
||||||
|
|
||||||
|
# Показываем ключевые аргументы
|
||||||
|
hint = ""
|
||||||
|
for key in ("taskId", "path", "repo", "repo_name", "name"):
|
||||||
|
if key in args:
|
||||||
|
hint = f' <span style="opacity:.6">{args[key]}</span>'
|
||||||
|
break
|
||||||
|
|
||||||
|
return f'<div class="{cls}">{icon} {ts} {label}{hint}</div>'
|
||||||
|
|
||||||
|
if kind == "tool_end":
|
||||||
|
out = ev["output"].replace("<", "<").replace(">", ">")[:200]
|
||||||
|
return f'<div class="tool-result">↳ {out}</div>'
|
||||||
|
|
||||||
|
if kind == "tool_error":
|
||||||
|
msg = ev["msg"].replace("<", "<")
|
||||||
|
return f'<div class="tool-result" style="border-color:#ef4444;color:#f87171">⚠ {msg}</div>'
|
||||||
|
|
||||||
|
if kind == "llm_end":
|
||||||
|
preview = ev.get("preview", "").replace("<", "<")[:100]
|
||||||
|
return f'<div class="thinking">✏ {ts} {preview}...</div>'
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Вкладки
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
tab_chat, tab_pipeline, tab_status = st.tabs(["💬 Чат с агентом", "⚡ Pipeline", "📊 Статус заданий"])
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════
|
||||||
|
# ВК 1 — ЧАТ
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
with tab_chat:
|
||||||
|
st.caption("Общайся с агентом: задай вопрос, попроси решить задание или разобрать ситуацию.")
|
||||||
|
|
||||||
|
# История сообщений
|
||||||
|
if "chat_history" not in st.session_state:
|
||||||
|
st.session_state.chat_history = []
|
||||||
|
if "chat_events" not in st.session_state:
|
||||||
|
st.session_state.chat_events = []
|
||||||
|
if "chat_thread_id" not in st.session_state:
|
||||||
|
st.session_state.chat_thread_id = f"ui-{int(time.time())}"
|
||||||
|
|
||||||
|
# Показываем историю
|
||||||
|
for msg in st.session_state.chat_history:
|
||||||
|
role = msg["role"]
|
||||||
|
text = msg["text"]
|
||||||
|
if role == "user":
|
||||||
|
st.markdown(f'<div class="chat-user">👤 {text}</div>', unsafe_allow_html=True)
|
||||||
|
else:
|
||||||
|
st.markdown(f'<div class="chat-agent">🤖 {text}</div>', unsafe_allow_html=True)
|
||||||
|
|
||||||
|
# Лог событий (раскрывающийся)
|
||||||
|
if st.session_state.chat_events:
|
||||||
|
with st.expander(f"🔍 Лог инструментов ({len(st.session_state.chat_events)} событий)", expanded=False):
|
||||||
|
html = "".join(_render_event(e) for e in st.session_state.chat_events[-80:])
|
||||||
|
st.markdown(f'<div style="max-height:300px;overflow-y:auto">{html}</div>',
|
||||||
|
unsafe_allow_html=True)
|
||||||
|
|
||||||
|
# Ввод
|
||||||
|
col_input, col_btn = st.columns([5, 1])
|
||||||
|
with col_input:
|
||||||
|
user_input = st.text_input(
|
||||||
|
"Сообщение",
|
||||||
|
placeholder='Например: "Реши задание 6a1864f7..." или "Какие задания у меня есть?"',
|
||||||
|
label_visibility="collapsed",
|
||||||
|
key="chat_input",
|
||||||
|
)
|
||||||
|
with col_btn:
|
||||||
|
send = st.button("Отправить", use_container_width=True, type="primary")
|
||||||
|
|
||||||
|
if send and user_input.strip():
|
||||||
|
msg_text = user_input.strip()
|
||||||
|
st.session_state.chat_history.append({"role": "user", "text": msg_text})
|
||||||
|
st.session_state.chat_events = []
|
||||||
|
|
||||||
|
# Строим историю сообщений для агента
|
||||||
|
lc_messages = []
|
||||||
|
for m in st.session_state.chat_history:
|
||||||
|
if m["role"] == "user":
|
||||||
|
lc_messages.append(HumanMessage(content=m["text"]))
|
||||||
|
else:
|
||||||
|
lc_messages.append(AIMessage(content=m["text"]))
|
||||||
|
|
||||||
|
config = {"configurable": {"thread_id": st.session_state.chat_thread_id}}
|
||||||
|
agent = get_agent()
|
||||||
|
|
||||||
|
# Placeholders для обновления в реальном времени
|
||||||
|
events_ph = st.empty()
|
||||||
|
status_ph = st.empty()
|
||||||
|
|
||||||
|
evq: queue.Queue = queue.Queue()
|
||||||
|
cb = AgentCallback(evq)
|
||||||
|
all_events: list[dict] = []
|
||||||
|
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=_run_agent_thread,
|
||||||
|
args=(agent, {"messages": lc_messages}, config, evq, cb),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
final_result = None
|
||||||
|
fatal = None
|
||||||
|
|
||||||
|
while thread.is_alive() or not evq.empty():
|
||||||
|
changed = False
|
||||||
|
while not evq.empty():
|
||||||
|
ev = evq.get_nowait()
|
||||||
|
if ev["t"] in ("done", "fatal"):
|
||||||
|
if ev["t"] == "done":
|
||||||
|
final_result = ev["result"]
|
||||||
|
else:
|
||||||
|
fatal = ev["msg"]
|
||||||
|
else:
|
||||||
|
all_events.append(ev)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if changed and all_events:
|
||||||
|
html = "".join(_render_event(e) for e in all_events[-60:])
|
||||||
|
events_ph.markdown(
|
||||||
|
f'<div style="background:#0b0f1a;border-radius:8px;padding:10px;'
|
||||||
|
f'max-height:250px;overflow-y:auto">{html}</div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
time.sleep(0.15)
|
||||||
|
|
||||||
|
events_ph.empty()
|
||||||
|
st.session_state.chat_events = all_events
|
||||||
|
|
||||||
|
if fatal:
|
||||||
|
st.session_state.chat_history.append({"role": "agent", "text": f"⚠️ Ошибка: {fatal}"})
|
||||||
|
elif final_result:
|
||||||
|
msgs = final_result.get("messages", [])
|
||||||
|
last = msgs[-1] if msgs else None
|
||||||
|
reply = last.content if last and hasattr(last, "content") else "Готово."
|
||||||
|
st.session_state.chat_history.append({"role": "agent", "text": reply})
|
||||||
|
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
# Кнопка очистки
|
||||||
|
if st.session_state.chat_history:
|
||||||
|
if st.button("🗑 Очистить чат"):
|
||||||
|
st.session_state.chat_history = []
|
||||||
|
st.session_state.chat_events = []
|
||||||
|
st.session_state.chat_thread_id = f"ui-{int(time.time())}"
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════
|
||||||
|
# ВК 2 — PIPELINE
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
with tab_pipeline:
|
||||||
|
st.caption("Автоматически решает все todo-задания курса по очереди.")
|
||||||
|
|
||||||
|
col1, col2 = st.columns([3, 1])
|
||||||
|
with col1:
|
||||||
|
task_id_input = st.text_input(
|
||||||
|
"Task ID (оставь пустым — решить все todo)",
|
||||||
|
placeholder="6a1864f7fd30e81cf3146d65",
|
||||||
|
label_visibility="visible",
|
||||||
|
)
|
||||||
|
with col2:
|
||||||
|
st.write("")
|
||||||
|
run_btn = st.button("▶ Запустить", type="primary", use_container_width=True)
|
||||||
|
|
||||||
|
if run_btn:
|
||||||
|
result_ph = st.empty()
|
||||||
|
events_ph2 = st.empty()
|
||||||
|
agent = get_agent()
|
||||||
|
|
||||||
|
if task_id_input.strip():
|
||||||
|
# Одно задание
|
||||||
|
task_id = task_id_input.strip()
|
||||||
|
repo_url = f"https://git.brojs.ru/{GITEA_OWNER}/task-{task_id}"
|
||||||
|
prompt = f"Реши задание taskId={task_id} курса 698b49da77cb6d4d2e43ce78"
|
||||||
|
config = {"configurable": {"thread_id": f"pipe-{task_id}-{int(time.time())}"}}
|
||||||
|
messages = {"messages": [HumanMessage(content=prompt)]}
|
||||||
|
else:
|
||||||
|
result_ph.info("Pipeline для всех todo-заданий — используй раздел ниже")
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
evq2: queue.Queue = queue.Queue()
|
||||||
|
cb2 = AgentCallback(evq2)
|
||||||
|
all_events2: list[dict] = []
|
||||||
|
|
||||||
|
thread2 = threading.Thread(
|
||||||
|
target=_run_agent_thread,
|
||||||
|
args=(agent, messages, config, evq2, cb2),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
thread2.start()
|
||||||
|
|
||||||
|
final2 = None
|
||||||
|
fatal2 = None
|
||||||
|
|
||||||
|
with st.spinner(f"Агент решает {task_id[:8]}..."):
|
||||||
|
while thread2.is_alive() or not evq2.empty():
|
||||||
|
while not evq2.empty():
|
||||||
|
ev = evq2.get_nowait()
|
||||||
|
if ev["t"] == "done":
|
||||||
|
final2 = ev["result"]
|
||||||
|
elif ev["t"] == "fatal":
|
||||||
|
fatal2 = ev["msg"]
|
||||||
|
else:
|
||||||
|
all_events2.append(ev)
|
||||||
|
|
||||||
|
if all_events2:
|
||||||
|
html = "".join(_render_event(e) for e in all_events2[-50:])
|
||||||
|
events_ph2.markdown(
|
||||||
|
f'<div style="background:#0b0f1a;border-radius:8px;padding:10px;'
|
||||||
|
f'max-height:300px;overflow-y:auto">{html}</div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
time.sleep(0.15)
|
||||||
|
|
||||||
|
if fatal2:
|
||||||
|
result_ph.markdown(
|
||||||
|
f'<div class="status-err">❌ Ошибка: {fatal2[:300]}</div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
elif final2:
|
||||||
|
result_ph.markdown(
|
||||||
|
f'<div class="status-ok">✅ Готово! '
|
||||||
|
f'<a href="{repo_url}" target="_blank" style="color:#4ade80">Открыть репозиторий</a>'
|
||||||
|
f'</div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
st.subheader("Запустить все todo-задания")
|
||||||
|
if st.button("⚡ Запустить агент для всех заданий", use_container_width=True):
|
||||||
|
main_ag = get_main_agent()
|
||||||
|
evq_pl = queue.Queue()
|
||||||
|
cb_pl = AgentCallback(evq_pl)
|
||||||
|
all_pl_events: list[dict] = []
|
||||||
|
_pl_state = {"result": None, "error": None}
|
||||||
|
|
||||||
|
run_prompt = (
|
||||||
|
"Выполни все задания со статусом todo в курсе KFU-26-1 "
|
||||||
|
"(courseId=698b49da77cb6d4d2e43ce78).\n\n"
|
||||||
|
"Шаги:\n"
|
||||||
|
"1. Получи список заданий через mcp__journal-bh-professor__tasks_list\n"
|
||||||
|
"2. Для каждого задания со статусом todo вызови solve_task(task_id=...)\n"
|
||||||
|
"3. Выполняй строго по одному заданию, жди результата перед следующим\n"
|
||||||
|
"4. Доложи итоговые результаты"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _run_all():
|
||||||
|
async def _inner():
|
||||||
|
try:
|
||||||
|
result = await main_ag.ainvoke(
|
||||||
|
{"messages": [HumanMessage(content=run_prompt)]},
|
||||||
|
{
|
||||||
|
"configurable": {"thread_id": f"ui-run-all-{int(time.time())}"},
|
||||||
|
"callbacks": [cb_pl],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
_pl_state["result"] = result
|
||||||
|
evq_pl.put({"t": "done", "result": result})
|
||||||
|
except Exception as e:
|
||||||
|
_pl_state["error"] = str(e)
|
||||||
|
evq_pl.put({"t": "fatal", "msg": str(e)})
|
||||||
|
asyncio.run(_inner())
|
||||||
|
|
||||||
|
t_pl = threading.Thread(target=_run_all, daemon=True)
|
||||||
|
t_pl.start()
|
||||||
|
|
||||||
|
events_pl_ph = st.empty()
|
||||||
|
with st.spinner("Агент-оркестратор работает... (LLM управляет всем)"):
|
||||||
|
while t_pl.is_alive() or not evq_pl.empty():
|
||||||
|
while not evq_pl.empty():
|
||||||
|
ev = evq_pl.get_nowait()
|
||||||
|
if ev["t"] not in ("done", "fatal"):
|
||||||
|
all_pl_events.append(ev)
|
||||||
|
if all_pl_events:
|
||||||
|
html = "".join(_render_event(e) for e in all_pl_events[-60:])
|
||||||
|
events_pl_ph.markdown(
|
||||||
|
f'<div style="background:#0b0f1a;border-radius:8px;padding:10px;'
|
||||||
|
f'max-height:300px;overflow-y:auto">{html}</div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
time.sleep(0.15)
|
||||||
|
|
||||||
|
events_pl_ph.empty()
|
||||||
|
|
||||||
|
if all_pl_events:
|
||||||
|
with st.expander(f"🔍 Лог агента ({len(all_pl_events)} событий)", expanded=False):
|
||||||
|
html = "".join(_render_event(e) for e in all_pl_events[-80:])
|
||||||
|
st.markdown(f'<div style="max-height:300px;overflow-y:auto">{html}</div>',
|
||||||
|
unsafe_allow_html=True)
|
||||||
|
|
||||||
|
if _pl_state["error"]:
|
||||||
|
st.error(_pl_state["error"])
|
||||||
|
elif _pl_state["result"]:
|
||||||
|
msgs = _pl_state["result"].get("messages", [])
|
||||||
|
last = msgs[-1] if msgs else None
|
||||||
|
reply = last.content if last and hasattr(last, "content") else "Готово."
|
||||||
|
st.markdown(
|
||||||
|
f'<div class="status-ok">✅ Агент завершил работу:<br>{reply[:600]}</div>',
|
||||||
|
unsafe_allow_html=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════
|
||||||
|
# ВК 3 — СТАТУС
|
||||||
|
# ══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
with tab_status:
|
||||||
|
st.caption("Статусы всех заданий курса KFU-26-1.")
|
||||||
|
|
||||||
|
if st.button("🔄 Обновить статусы", type="primary"):
|
||||||
|
with st.spinner("Загружаю статусы..."):
|
||||||
|
from src.agent.mcp_client import load_journal_toolsets
|
||||||
|
|
||||||
|
async def _fetch():
|
||||||
|
j = load_journal_toolsets()
|
||||||
|
tools = {t.name: t for t in j.tasks_submissions_tools}
|
||||||
|
full_name = "mcp__journal-bh-professor__tasks_list"
|
||||||
|
t = tools.get(full_name) or next(
|
||||||
|
(v for k, v in tools.items() if "tasks_list" in k), None
|
||||||
|
)
|
||||||
|
if not t:
|
||||||
|
return [], f"tasks_list не найден. Доступны: {list(tools.keys())}"
|
||||||
|
raw = await t.ainvoke({"courseId": "698b49da77cb6d4d2e43ce78"})
|
||||||
|
text = next((x["text"] for x in raw if x.get("type") == "text"), str(raw)) if isinstance(raw, list) else str(raw)
|
||||||
|
data = json.loads(text)
|
||||||
|
return (data.get("tasks", data) if isinstance(data, dict) else data), None
|
||||||
|
|
||||||
|
_state = {"items": [], "error": None}
|
||||||
|
|
||||||
|
def _run_fetch():
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
try:
|
||||||
|
_state["items"], _state["error"] = loop.run_until_complete(_fetch())
|
||||||
|
except Exception as e:
|
||||||
|
_state["error"] = str(e)
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
t = threading.Thread(target=_run_fetch, daemon=True)
|
||||||
|
t.start()
|
||||||
|
t.join()
|
||||||
|
|
||||||
|
if _state["error"]:
|
||||||
|
st.error(_state["error"])
|
||||||
|
else:
|
||||||
|
st.session_state["task_statuses"] = _state["items"]
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
items = st.session_state.get("task_statuses", [])
|
||||||
|
|
||||||
|
STATUS_EMOJI = {
|
||||||
|
"done": "✅",
|
||||||
|
"ready_for_review": "🔍",
|
||||||
|
"in_progress": "🔄",
|
||||||
|
"todo": "📋",
|
||||||
|
"rejected": "❌",
|
||||||
|
}
|
||||||
|
|
||||||
|
if items:
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
rows = []
|
||||||
|
for item in items:
|
||||||
|
t = item.get("task", item) if isinstance(item, dict) else {}
|
||||||
|
tid = t.get("id", "")
|
||||||
|
status = item.get("status", "")
|
||||||
|
title = t.get("title", t.get("name", ""))
|
||||||
|
counts[status] = counts.get(status, 0) + 1
|
||||||
|
rows.append({
|
||||||
|
"": STATUS_EMOJI.get(status, "❓"),
|
||||||
|
"Статус": status,
|
||||||
|
"ID": tid[:12] + "...",
|
||||||
|
"Название": title,
|
||||||
|
"Репо": f"https://git.brojs.ru/{GITEA_OWNER}/task-{tid}",
|
||||||
|
})
|
||||||
|
|
||||||
|
st.dataframe(rows, use_container_width=True, hide_index=True)
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
cols = st.columns(len(counts))
|
||||||
|
for col, (s, n) in zip(cols, counts.items()):
|
||||||
|
col.metric(f"{STATUS_EMOJI.get(s,'❓')} {s}", n)
|
||||||
|
else:
|
||||||
|
st.info("Нажми «Обновить статусы» чтобы загрузить данные.")
|
||||||
+652
@@ -0,0 +1,652 @@
|
|||||||
|
"""
|
||||||
|
Aurora Dashboard — A light, modern UI for BroJS agent task orchestration.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
os.environ["NO_PROXY"] = (
|
||||||
|
"openrouter.ai,platform.brojs.ru,git.brojs.ru,"
|
||||||
|
+ os.environ.get("NO_PROXY", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
import streamlit as st
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from langchain_core.callbacks.base import BaseCallbackHandler
|
||||||
|
from langchain_core.messages import AIMessage, HumanMessage
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
st.set_page_config(
|
||||||
|
page_title="Aurora Dashboard",
|
||||||
|
page_icon="✨",
|
||||||
|
layout="wide",
|
||||||
|
initial_sidebar_state="collapsed",
|
||||||
|
)
|
||||||
|
|
||||||
|
GITEA_OWNER = os.getenv("GITEA_OWNER", "dapa46")
|
||||||
|
|
||||||
|
# ──── THEME & STYLING ────
|
||||||
|
st.markdown("""
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stApp {
|
||||||
|
background: linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%);
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main container */
|
||||||
|
section[data-testid="stAppViewContainer"] {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
section.main > div {
|
||||||
|
padding: 2rem 3rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero section */
|
||||||
|
.hero {
|
||||||
|
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 40px 44px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(135deg, #4338ca 0%, #6366f1 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero p {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs styling */
|
||||||
|
div[data-testid="stTabs"] {
|
||||||
|
margin: 28px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-testid="stTabs"] button {
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 12px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div[data-testid="stTabs"] button[aria-selected="true"] {
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1f2937;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards & containers */
|
||||||
|
.card {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 22px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-light {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chat bubbles */
|
||||||
|
.bubble-user {
|
||||||
|
background: linear-gradient(135deg, #4338ca 0%, #6366f1 100%);
|
||||||
|
color: white;
|
||||||
|
border-radius: 20px 20px 4px 20px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin: 10px 0 10px auto;
|
||||||
|
max-width: 75%;
|
||||||
|
box-shadow: 0 4px 12px rgba(67, 56, 202, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble-agent {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #1f2937;
|
||||||
|
border-radius: 20px 20px 20px 4px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin: 10px auto 10px 0;
|
||||||
|
max-width: 75%;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.stButton > button {
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stButton > button[kind="primary"] {
|
||||||
|
background: linear-gradient(135deg, #4338ca 0%, #6366f1 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stButton > button[kind="primary"]:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 20px rgba(67, 56, 202, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs */
|
||||||
|
.stTextInput input,
|
||||||
|
.stTextArea textarea {
|
||||||
|
border-radius: 12px !important;
|
||||||
|
border: 1px solid #d1d5db !important;
|
||||||
|
padding: 12px 16px !important;
|
||||||
|
font-size: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stTextInput input:focus,
|
||||||
|
.stTextArea textarea:focus {
|
||||||
|
border-color: #4338ca !important;
|
||||||
|
box-shadow: 0 0 0 3px rgba(67, 56, 202, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badges */
|
||||||
|
.status-todo {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-done {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-review {
|
||||||
|
background: #fef08a;
|
||||||
|
color: #854d0e;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-progress {
|
||||||
|
background: #cffafe;
|
||||||
|
color: #164e63;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-log {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
font-family: 'Monaco', 'Courier New', monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-item {
|
||||||
|
padding: 8px 0;
|
||||||
|
border-left: 3px solid #d1d5db;
|
||||||
|
padding-left: 12px;
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-tool { border-left-color: #4338ca; color: #4338ca; }
|
||||||
|
.event-success { border-left-color: #16a34a; color: #16a34a; }
|
||||||
|
.event-error { border-left-color: #dc2626; color: #dc2626; }
|
||||||
|
.event-thinking { border-left-color: #9333ea; color: #9333ea; }
|
||||||
|
|
||||||
|
/* Divider */
|
||||||
|
.stDivider {
|
||||||
|
margin: 24px 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 60px;
|
||||||
|
padding-top: 24px;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
""", unsafe_allow_html=True)
|
||||||
|
|
||||||
|
# ──── PAGE HEADER ────
|
||||||
|
st.markdown("""
|
||||||
|
<div class="hero">
|
||||||
|
<h1>✨ Aurora Dashboard</h1>
|
||||||
|
<p>Streamlined task orchestration for BroJS. Send tasks, monitor progress, and control your course automation—all in one place.</p>
|
||||||
|
</div>
|
||||||
|
""", unsafe_allow_html=True)
|
||||||
|
|
||||||
|
# ──── SESSION STATE ────
|
||||||
|
for key in ["chat_history", "chat_events", "chat_thread_id", "task_statuses"]:
|
||||||
|
if key not in st.session_state:
|
||||||
|
if key == "chat_thread_id":
|
||||||
|
st.session_state[key] = f"aurora-{int(time.time())}"
|
||||||
|
else:
|
||||||
|
st.session_state[key] = [] if key != "task_statuses" else {}
|
||||||
|
|
||||||
|
# ──── AGENT HELPERS ────
|
||||||
|
|
||||||
|
@st.cache_resource(show_spinner="🚀 Starting Aurora engine...")
|
||||||
|
def get_agent():
|
||||||
|
from src.agent.agent import agent
|
||||||
|
return agent
|
||||||
|
|
||||||
|
@st.cache_resource(show_spinner="📡 Loading orchestrator...")
|
||||||
|
def get_main_agent():
|
||||||
|
from src.agent.agent import agent
|
||||||
|
return agent
|
||||||
|
|
||||||
|
def _load_statuses():
|
||||||
|
from src.agent.mcp_client import load_journal_toolsets
|
||||||
|
|
||||||
|
async def _fetch():
|
||||||
|
j = load_journal_toolsets()
|
||||||
|
tools = {t.name: t for t in j.tasks_submissions_tools}
|
||||||
|
key = "mcp__journal-bh-professor__tasks_list"
|
||||||
|
tool = tools.get(key) or next((v for k, v in tools.items() if "tasks_list" in k), None)
|
||||||
|
if not tool:
|
||||||
|
return [], f"Task list tool not found."
|
||||||
|
raw = await tool.ainvoke({"courseId": "698b49da77cb6d4d2e43ce78"})
|
||||||
|
text = next((x["text"] for x in raw if x.get("type") == "text"), str(raw)) if isinstance(raw, list) else str(raw)
|
||||||
|
data = json.loads(text)
|
||||||
|
return (data.get("tasks", data) if isinstance(data, dict) else data), None
|
||||||
|
|
||||||
|
result = {"items": [], "error": None}
|
||||||
|
def _run():
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
try:
|
||||||
|
result["items"], result["error"] = loop.run_until_complete(_fetch())
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = str(e)
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
t = threading.Thread(target=_run, daemon=True)
|
||||||
|
t.start()
|
||||||
|
t.join()
|
||||||
|
return result["items"], result["error"]
|
||||||
|
|
||||||
|
# ──── CALLBACKS & RUNNERS ────
|
||||||
|
|
||||||
|
_SOLVE_TOOLS = {"validate_teacher_comment", "generate_code_solution"}
|
||||||
|
_JOURNAL_PREFIX = "mcp__journal-bh-professor__"
|
||||||
|
|
||||||
|
class AgentCallback(BaseCallbackHandler):
|
||||||
|
def __init__(self, q: queue.Queue):
|
||||||
|
self.q = q
|
||||||
|
|
||||||
|
def _ts(self) -> str:
|
||||||
|
return datetime.now().strftime("%H:%M:%S")
|
||||||
|
|
||||||
|
def on_tool_start(self, serialized, input_str, **kwargs):
|
||||||
|
name = serialized.get("name", "?")
|
||||||
|
try:
|
||||||
|
args = json.loads(str(input_str)) if isinstance(input_str, str) else input_str
|
||||||
|
except:
|
||||||
|
args = {}
|
||||||
|
self.q.put({"t": "tool_start", "ts": self._ts(), "name": name, "args": args})
|
||||||
|
|
||||||
|
def on_tool_end(self, output, **kwargs):
|
||||||
|
self.q.put({"t": "tool_end", "ts": self._ts(), "output": str(output)[:280]})
|
||||||
|
|
||||||
|
def on_tool_error(self, error, **kwargs):
|
||||||
|
self.q.put({"t": "tool_error", "ts": self._ts(), "msg": str(error)[:200]})
|
||||||
|
|
||||||
|
def on_llm_start(self, *a, **kw):
|
||||||
|
self.q.put({"t": "thinking", "ts": self._ts()})
|
||||||
|
|
||||||
|
def on_llm_end(self, response, **kwargs):
|
||||||
|
try:
|
||||||
|
text = response.generations[0][0].text[:120]
|
||||||
|
self.q.put({"t": "llm_end", "ts": self._ts(), "preview": text})
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _run_agent_thread(agent, messages, config, q: queue.Queue, cb: AgentCallback):
|
||||||
|
async def _inner():
|
||||||
|
try:
|
||||||
|
result = await agent.ainvoke(messages, {**config, "callbacks": [cb]})
|
||||||
|
q.put({"t": "done", "result": result})
|
||||||
|
except Exception as e:
|
||||||
|
q.put({"t": "fatal", "msg": str(e)})
|
||||||
|
asyncio.run(_inner())
|
||||||
|
|
||||||
|
def _render_event(ev: dict) -> str:
|
||||||
|
ts = ev.get("ts", "")
|
||||||
|
kind = ev.get("t", "")
|
||||||
|
|
||||||
|
if kind == "thinking":
|
||||||
|
return f'<div class="event-item event-thinking">💭 {ts} Thinking...</div>'
|
||||||
|
|
||||||
|
if kind == "tool_start":
|
||||||
|
name = ev["name"]
|
||||||
|
args = ev.get("args", {})
|
||||||
|
short = name.replace(_JOURNAL_PREFIX, "mcp::")
|
||||||
|
icon = "🔧"
|
||||||
|
if name in _SOLVE_TOOLS:
|
||||||
|
icon = "🧠"
|
||||||
|
short = f"[Subagent] {short}"
|
||||||
|
elif "gitea" in name:
|
||||||
|
icon = "📦"
|
||||||
|
elif "journal" in name:
|
||||||
|
icon = "📡"
|
||||||
|
hint = ""
|
||||||
|
for k in ("taskId", "path", "repo", "repo_name", "name"):
|
||||||
|
if k in args:
|
||||||
|
hint = f' — {args[k]}'
|
||||||
|
break
|
||||||
|
return f'<div class="event-item event-tool">{icon} {short}{hint}</div>'
|
||||||
|
|
||||||
|
if kind == "tool_end":
|
||||||
|
out = ev["output"].replace("<", "<").replace(">", ">")[:200]
|
||||||
|
return f'<div class="event-item event-success">✓ {out}</div>'
|
||||||
|
|
||||||
|
if kind == "tool_error":
|
||||||
|
msg = ev["msg"].replace("<", "<")
|
||||||
|
return f'<div class="event-item event-error">⚠ {msg}</div>'
|
||||||
|
|
||||||
|
if kind == "llm_end":
|
||||||
|
preview = ev.get("preview", "").replace("<", "<")[:100]
|
||||||
|
return f'<div class="event-item event-thinking">✏ {ts} {preview}...</div>'
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# ──── TABS ────
|
||||||
|
tab1, tab2, tab3 = st.tabs(["💬 Chat", "⚡ Pipeline", "📊 Status"])
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# TAB 1: CHAT
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
with tab1:
|
||||||
|
st.subheader("Ask Aurora")
|
||||||
|
st.write("Chat with the agent. Ask questions, request task solutions, or check your course progress.")
|
||||||
|
|
||||||
|
# Display chat history
|
||||||
|
chat_col = st.container()
|
||||||
|
with chat_col:
|
||||||
|
for msg in st.session_state.chat_history:
|
||||||
|
if msg["role"] == "user":
|
||||||
|
st.markdown(f'<div class="bubble-user">{msg["text"]}</div>', unsafe_allow_html=True)
|
||||||
|
else:
|
||||||
|
st.markdown(f'<div class="bubble-agent">{msg["text"]}</div>', unsafe_allow_html=True)
|
||||||
|
|
||||||
|
# Event log expander
|
||||||
|
if st.session_state.chat_events:
|
||||||
|
with st.expander(f"📋 Execution log ({len(st.session_state.chat_events)} events)", expanded=False):
|
||||||
|
html = "".join(_render_event(e) for e in st.session_state.chat_events[-60:])
|
||||||
|
st.markdown(f'<div class="event-log">{html}</div>', unsafe_allow_html=True)
|
||||||
|
|
||||||
|
# Input area
|
||||||
|
st.write("")
|
||||||
|
col1, col2 = st.columns([5, 1])
|
||||||
|
with col1:
|
||||||
|
user_msg = st.text_input("Message", placeholder="Ask about tasks, solve a problem, check status...", label_visibility="collapsed", key="chat_msg")
|
||||||
|
with col2:
|
||||||
|
send = st.button("Send", type="primary", use_container_width=True)
|
||||||
|
|
||||||
|
if send and user_msg.strip():
|
||||||
|
msg_text = user_msg.strip()
|
||||||
|
st.session_state.chat_history.append({"role": "user", "text": msg_text})
|
||||||
|
st.session_state.chat_events = []
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
for m in st.session_state.chat_history:
|
||||||
|
if m["role"] == "user":
|
||||||
|
messages.append(HumanMessage(content=m["text"]))
|
||||||
|
else:
|
||||||
|
messages.append(AIMessage(content=m["text"]))
|
||||||
|
|
||||||
|
agent = get_agent()
|
||||||
|
event_q: queue.Queue = queue.Queue()
|
||||||
|
cb = AgentCallback(event_q)
|
||||||
|
all_events = []
|
||||||
|
|
||||||
|
ph_events = st.empty()
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=_run_agent_thread,
|
||||||
|
args=(agent, {"messages": messages}, {"configurable": {"thread_id": st.session_state.chat_thread_id}}, event_q, cb),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
final = None
|
||||||
|
error = None
|
||||||
|
|
||||||
|
while thread.is_alive() or not event_q.empty():
|
||||||
|
while not event_q.empty():
|
||||||
|
ev = event_q.get_nowait()
|
||||||
|
if ev["t"] == "done":
|
||||||
|
final = ev["result"]
|
||||||
|
elif ev["t"] == "fatal":
|
||||||
|
error = ev["msg"]
|
||||||
|
else:
|
||||||
|
all_events.append(ev)
|
||||||
|
|
||||||
|
if all_events:
|
||||||
|
html = "".join(_render_event(e) for e in all_events[-50:])
|
||||||
|
ph_events.markdown(f'<div class="event-log" style="max-height:240px; overflow:auto;">{html}</div>', unsafe_allow_html=True)
|
||||||
|
time.sleep(0.14)
|
||||||
|
|
||||||
|
ph_events.empty()
|
||||||
|
st.session_state.chat_events = all_events
|
||||||
|
|
||||||
|
if error:
|
||||||
|
st.session_state.chat_history.append({"role": "agent", "text": f"⚠️ Error: {error}"})
|
||||||
|
elif final:
|
||||||
|
msgs = final.get("messages", [])
|
||||||
|
reply = msgs[-1].content if msgs and hasattr(msgs[-1], "content") else "Complete."
|
||||||
|
st.session_state.chat_history.append({"role": "agent", "text": reply})
|
||||||
|
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
if st.session_state.chat_history:
|
||||||
|
st.write("")
|
||||||
|
if st.button("🗑 Clear history", use_container_width=True):
|
||||||
|
st.session_state.chat_history = []
|
||||||
|
st.session_state.chat_events = []
|
||||||
|
st.session_state.chat_thread_id = f"aurora-{int(time.time())}"
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# TAB 2: PIPELINE
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
with tab2:
|
||||||
|
st.subheader("Automation Pipeline")
|
||||||
|
st.write("Execute a single task or run the full automation for all TODO items in KFU-26-1.")
|
||||||
|
|
||||||
|
col1, col2 = st.columns([3, 1])
|
||||||
|
with col1:
|
||||||
|
tid = st.text_input("Task ID", placeholder="Optional: enter a task ID to run just that one", label_visibility="visible", key="pipe_tid")
|
||||||
|
with col2:
|
||||||
|
st.write("")
|
||||||
|
run_single = st.button("▶ Run", type="primary", use_container_width=True)
|
||||||
|
|
||||||
|
if run_single:
|
||||||
|
if tid.strip():
|
||||||
|
task_id = tid.strip()
|
||||||
|
prompt = f"Solve task taskId={task_id} course 698b49da77cb6d4d2e43ce78"
|
||||||
|
repo_url = f"https://git.brojs.ru/{GITEA_OWNER}/task-{task_id}"
|
||||||
|
agent = get_agent()
|
||||||
|
event_q: queue.Queue = queue.Queue()
|
||||||
|
cb = AgentCallback(event_q)
|
||||||
|
|
||||||
|
st.info(f"Running task {task_id}...")
|
||||||
|
ph_log = st.empty()
|
||||||
|
ph_result = st.empty()
|
||||||
|
all_events = []
|
||||||
|
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=_run_agent_thread,
|
||||||
|
args=(agent, {"messages": [HumanMessage(content=prompt)]}, {"configurable": {"thread_id": f"pipe-{task_id}"}}, event_q, cb),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
while thread.is_alive() or not event_q.empty():
|
||||||
|
while not event_q.empty():
|
||||||
|
ev = event_q.get_nowait()
|
||||||
|
if ev["t"] not in ("done", "fatal"):
|
||||||
|
all_events.append(ev)
|
||||||
|
|
||||||
|
if all_events:
|
||||||
|
html = "".join(_render_event(e) for e in all_events[-50:])
|
||||||
|
ph_log.markdown(f'<div class="event-log" style="max-height:280px; overflow:auto;">{html}</div>', unsafe_allow_html=True)
|
||||||
|
time.sleep(0.15)
|
||||||
|
|
||||||
|
ph_log.empty()
|
||||||
|
ph_result.success(f"✅ Task complete! [Open in Gitea]({repo_url})")
|
||||||
|
else:
|
||||||
|
st.warning("Enter a Task ID first.")
|
||||||
|
|
||||||
|
st.write("")
|
||||||
|
st.divider()
|
||||||
|
st.subheader("Full Automation")
|
||||||
|
st.write("Run the agent orchestrator to execute all TODO tasks in sequence.")
|
||||||
|
|
||||||
|
if st.button("⚡ Run Full Pipeline", use_container_width=True):
|
||||||
|
main_ag = get_main_agent()
|
||||||
|
event_q: queue.Queue = queue.Queue()
|
||||||
|
cb = AgentCallback(event_q)
|
||||||
|
state = {"result": None, "error": None}
|
||||||
|
|
||||||
|
def _run_full():
|
||||||
|
async def _inner():
|
||||||
|
try:
|
||||||
|
state["result"] = await main_ag.ainvoke(
|
||||||
|
{"messages": [HumanMessage(content=(
|
||||||
|
"Execute all tasks with status todo in course KFU-26-1 "
|
||||||
|
"(courseId=698b49da77cb6d4d2e43ce78). "
|
||||||
|
"Get the list, solve each one sequentially, report results."
|
||||||
|
))]},
|
||||||
|
{
|
||||||
|
"configurable": {"thread_id": f"aurora-all-{int(time.time())}"},
|
||||||
|
"callbacks": [cb],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
state["error"] = str(e)
|
||||||
|
asyncio.run(_inner())
|
||||||
|
|
||||||
|
ph_spin = st.empty()
|
||||||
|
ph_log = st.empty()
|
||||||
|
all_events = []
|
||||||
|
|
||||||
|
thread = threading.Thread(target=_run_full, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
with ph_spin.container():
|
||||||
|
with st.spinner("Orchestrator running..."):
|
||||||
|
while thread.is_alive() or not event_q.empty():
|
||||||
|
while not event_q.empty():
|
||||||
|
ev = event_q.get_nowait()
|
||||||
|
if ev["t"] not in ("done", "fatal"):
|
||||||
|
all_events.append(ev)
|
||||||
|
|
||||||
|
if all_events:
|
||||||
|
html = "".join(_render_event(e) for e in all_events[-60:])
|
||||||
|
ph_log.markdown(f'<div class="event-log" style="max-height:300px; overflow:auto;">{html}</div>', unsafe_allow_html=True)
|
||||||
|
time.sleep(0.14)
|
||||||
|
|
||||||
|
ph_spin.empty()
|
||||||
|
|
||||||
|
if state["error"]:
|
||||||
|
st.error(f"Error: {state['error']}")
|
||||||
|
else:
|
||||||
|
msgs = state["result"].get("messages", []) if state["result"] else []
|
||||||
|
reply = msgs[-1].content if msgs and hasattr(msgs[-1], "content") else "Complete."
|
||||||
|
st.success(f"✅ Automation done: {reply[:400]}")
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# TAB 3: STATUS
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
with tab3:
|
||||||
|
st.subheader("Course Status")
|
||||||
|
st.write("View task statuses for KFU-26-1.")
|
||||||
|
|
||||||
|
if st.button("🔄 Refresh", type="primary", use_container_width=True):
|
||||||
|
items, err = _load_statuses()
|
||||||
|
if err:
|
||||||
|
st.error(err)
|
||||||
|
else:
|
||||||
|
st.session_state.task_statuses = items
|
||||||
|
|
||||||
|
search = st.text_input("Search tasks", placeholder="Filter by ID, title, or status...", label_visibility="collapsed")
|
||||||
|
|
||||||
|
items = st.session_state.task_statuses
|
||||||
|
if not items:
|
||||||
|
st.info("Click Refresh to load task data.")
|
||||||
|
else:
|
||||||
|
rows = []
|
||||||
|
for item in items:
|
||||||
|
task = item.get("task", item) if isinstance(item, dict) else {}
|
||||||
|
tid = task.get("id", "")
|
||||||
|
status = item.get("status", "")
|
||||||
|
title = task.get("title", task.get("name", ""))
|
||||||
|
if search and search.lower() not in (tid + title + status).lower():
|
||||||
|
continue
|
||||||
|
rows.append({
|
||||||
|
"ID": tid[:12] + "...",
|
||||||
|
"Title": title,
|
||||||
|
"Status": status,
|
||||||
|
"Repo": f"[Link](https://git.brojs.ru/{GITEA_OWNER}/task-{tid})",
|
||||||
|
})
|
||||||
|
|
||||||
|
st.dataframe(rows, use_container_width=True, hide_index=True)
|
||||||
|
|
||||||
|
st.divider()
|
||||||
|
counts = {}
|
||||||
|
for row in rows:
|
||||||
|
s = row["Status"]
|
||||||
|
counts[s] = counts.get(s, 0) + 1
|
||||||
|
|
||||||
|
cols = st.columns(len(counts))
|
||||||
|
for col, (status, count) in zip(cols, counts.items()):
|
||||||
|
col.metric(status.replace("_", " ").title(), count)
|
||||||
|
|
||||||
|
st.markdown('<div class="footer">Aurora Dashboard • Made for KFU-26-1 • LightFlow UI</div>', unsafe_allow_html=True)
|
||||||
Reference in New Issue
Block a user