Compare commits

...

11 Commits

Author SHA1 Message Date
dapa46 f1b75a0f2a Обновить README.md 2026-06-18 16:35:22 +00:00
dapa46 0f38d8d588 Add Deep Agents UI, pipeline fixes, and agent improvements.
Includes deep-agents-ui integration, rework detection via Gitea,
tool-call sanitization fixes, and startup scripts.
2026-06-18 19:33:26 +03:00
Glevel ba83d0cfe4 feat: LLM-first architecture — agent orchestrates everything via solve_task tool
- cli.py: cmd_run() now uses agent.ainvoke() instead of pipeline
- ui.py: 'run all' button and chat use main agent; status tab fixed (thread + st.rerun)
- src/agent/runner_tools.py: new solve_task tool wrapping reliable single-task execution
- src/agent/agent.py: solve_task added to main agent tools
- src/agent/prompts.py: main_agent_instructions rewritten for LLM-first orchestration

LLM decides what to do and in what order; Python tools are just hands.
2026-06-11 18:43:40 +03:00
Glevel 0a2b139f9b fix: SyntaxError nonlocal — заменить на dict _pl_state 2026-06-05 17:32:45 +03:00
Glevel 101671ea6e feat: логи tool-calls для pipeline всех заданий
pipeline.py:
- _invoke_with_retry принимает callbacks и пробрасывает в agent.ainvoke()
- process_one_task принимает RunnableConfig и извлекает callbacks из него
- callbacks передаются при первой сдаче и при retry-исправлении

ui.py:
- Pipeline «все todo» запускается в потоке (не блокирует asyncio.run)
- AgentCallback собирает события и показывает их в реальном времени
- После завершения — раскрывающийся лог всего pipeline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 17:31:05 +03:00
Glevel 2bfcf5f782 revert: вернуть ui.py к версии ee2c8de (до изменений rate limit) 2026-06-05 17:16:14 +03:00
Glevel b9b0f58de2 fix: переписать UI — убрать polling/threading, простой blocking spinner
Проблема: st.rerun() внутри with tab_*: блокировал выполнение других вкладок,
кнопки не работали, экран выглядел пустым.

Решение: простая синхронная архитектура:
- _invoke_agent() запускает агент в потоке и ждёт t.join()
- UI показывает st.spinner() пока агент работает
- События собираются через AgentEventCollector (список, не очередь)
- Показываются после завершения в раскрывающемся логе
- Нет polling, нет rerun-цикла, нет флагов chat_running/pipe_running

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 17:12:19 +03:00
Glevel 177dea2769 fix: переписать UI на st.rerun() polling — кнопки и события работают корректно
Проблема: blocking while-цикл блокировал Streamlit, кнопка «Стоп» не работала
(Streamlit не может обработать клик пока скрипт заблокирован), кнопка мигала
каждые 0.3с из-за динамического ключа.

Решение: rerun-based polling — каждая «итерация» это полный rerun скрипта:
- chat_running / pipe_running флаги в session_state
- thread + queue живут в session_state между рерандами
- time.sleep(0.5) → st.rerun() вместо while-цикла
- кнопка «Стоп» рендерится нормально и реагирует мгновенно
- « Агент работает... Nс» при простое >15с
- лог событий накапливается в session_state.chat_events

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 17:05:09 +03:00
Glevel cba8940005 fix: 429 rate limit виден в UI, добавлена кнопка Стоп
- RetryOnRateLimitMiddleware: при 429 шлёт события rate_limit_wait/retry
  в глобальный UI-канал (set_ui_event_queue) — без него не меняет поведение
- ui.py: рендерит  rate_limit_wait и 🔄 rate_limit_retry в лог событий
- ui.py: показывает «Агент работает... Nс» если нет событий >15с
- ui.py: кнопка «Стоп» прерывает ожидание в чате и pipeline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:59:10 +03:00
Glevel ee2c8de372 fix: исправить поиск tasks_list — использовать полное имя с префиксом MCP
MCP-инструменты после загрузки получают префикс mcp__journal-bh-professor__.
tools.get("tasks_list") всегда возвращал None → статус не загружался.
Исправлено в ui.py (вкладка Статус) и cli.py (команда status).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:49:48 +03:00
Glevel 3ae3488691 refactor: добавить агентную архитектуру, UI и CLI
- src/agent/solve_tools.py: новые LLM-субагенты validate_teacher_comment и
  generate_code_solution — агент сам решает когда их вызывать
- src/agent/solve_prompts.py: централизованные промпты для субагентов решения
- src/agent/gitea_tools.py: добавлен gitea_list_files для чтения файлов репо
- src/agent/agent.py: SOLVE_TOOLS подключены к homework_direct_agent и rework_agent
- src/agent/prompts.py: промпты переработаны в capability-based формат (без жёстких шагов)
- cli.py: единая точка входа (solve / run / status)
- ui.py: Streamlit UI с реал-тайм отображением вызовов инструментов
- requirements.txt: добавлен streamlit>=1.35.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 16:43:00 +03:00
85 changed files with 23977 additions and 664 deletions
-16
View File
@@ -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=
+6
View File
@@ -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
+567
View File
@@ -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 │
└─────────────────────┘
(Генерация кода и рассуждения)
```
Система автоматизирует весь цикл выполнения и сдачи заданий, обрабатывая ошибки, пересдачи и координируя несколько специализированных агентов через единый фреймворк.
+39 -2
View File
@@ -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)
+161
View File
@@ -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()
View File
+15
View File
@@ -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
View File
@@ -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
View File
@@ -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
+41
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
legacy-peer-deps=true
+2
View File
@@ -0,0 +1,2 @@
20
+31
View File
@@ -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
+21
View File
@@ -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.
+93
View File
@@ -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 27PM" 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 36PM" 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 40PM" 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)
+20
View File
@@ -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"
}
}
+33
View File
@@ -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 },
],
},
}
);
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+10490
View File
File diff suppressed because it is too large Load Diff
+77
View File
@@ -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"
}
+7
View File
@@ -0,0 +1,7 @@
module.exports = {
plugins: {
"tailwindcss/nesting": {},
tailwindcss: {},
autoprefixer: {},
},
};
+11
View File
@@ -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;
+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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&apos;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

+395
View File
@@ -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;
}
+167
View File
@@ -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,
};
}
+136
View File
@@ -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,
}
);
}
+27
View File
@@ -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>
);
}
+293
View File
@@ -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>
);
}
+57
View File
@@ -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[];
}
+157
View File
@@ -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 };
+163
View File
@@ -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 };
+160
View File
@@ -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 };
+66
View File
@@ -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 };
+40
View File
@@ -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));
}
+6
View File
@@ -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;
}
+488
View File
@@ -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",
},
});
}),
],
};
+41
View File
@@ -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
BIN
View File
Binary file not shown.
+14
View File
@@ -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."
+18
View File
@@ -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
+27
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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 = """\
+28 -2
View File
@@ -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
View File
@@ -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,12 +169,51 @@ 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:
"""Комментарии преподавателя: submission.grade.feedback, не data.comments."""
submission = data.get("submission", data)
comments = (
(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
async def _get_task_meta(task_id: str) -> dict:
"""repo_url + комментарии. Пересдача = репо есть на Gitea ИЛИ answer в журнале."""
data = await _task_json(task_id) data = await _task_json(task_id)
url = (data.get("answer") or {}).get("content", "") repo_url = await _gitea_repo_url(task_id)
if url and url.startswith(f"https://git.brojs.ru/{GITEA_OWNER}/"): if not repo_url:
return url answer_url = (data.get("answer") or {}).get("content", "")
return None 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:
@@ -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
View File
@@ -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
View File
@@ -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] = []
+38 -3
View File
@@ -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")
+28 -6
View File
@@ -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)
+116 -593
View File
@@ -63,587 +63,98 @@ journal_tasks_submissions_instructions = """
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
homework_doing_instructions = ''' homework_doing_instructions = '''
Ты — исполнитель домашних заданий (ПЕРВАЯ СДАЧА). Ты — агент выполнения домашних заданий курса KFU-26-1.
У тебя есть ВСЕ инструменты напрямую. Не делегируй другим субагентам.
courseId = "698b49da77cb6d4d2e43ce78"
courseId = "698b49da77cb6d4d2e43ce78" Gitea owner = "dapa46"
Gitea owner = "glevelll" 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, комментарии преподавателя
task_update_answer(...) — установить ссылку на репо (ОБЯЗАТЕЛЬНО перед submit)
[1] mcp__journal-bh-professor__task_text({"taskId": "<id>"}) task_submit(taskId, confirmSubmit=true) — сдать задание
→ Прочитай ПОЛНЫЙ текст задания
Gitea:
[2] Составь письменный план: gitea_list_files(repo) — список файлов в репозитории
- какие файлы нужны (main.py, requirements.txt, etc.) gitea_list_repos() — список репозиториев (узнать существует ли repo)
- что реализовать в каждом файле gitea_create_repo(name) — создать репозиторий
- какой технический стек использовать (см. раздел ТЕХНИЧЕСКИЕ ПАТТЕРНЫ ниже) gitea_write_file(repo, path, content, message) — записать файл (автокоммит)
gitea_get_file(repo, path) — прочитать файл
[3] gitea_create_repo({"name": "task-<id>", "private": false})
→ Создай репозиторий Инструменты решения (LLM-субагенты):
validate_teacher_comment(task_text, repo_name, teacher_comment)
[4] Для КАЖДОГО файла вызывай ОТДЕЛЬНО: → анализирует каждый пункт замечания: ловушка или реальная ошибка
gitea_write_file({ generate_code_solution(task_text, fix_instructions="", defense_context="")
"repo": "task-<id>", → генерирует main.py + requirements.txt + extra_files
"path": "main.py",
"content": "ПОЛНЫЙ КОД ФАЙЛА", ## Принципы работы
"message": "add main.py"
}) Для ПЕРВОЙ СДАЧИ:
- gitea_write_file сам коммитит на сервере — git_add_and_commit НЕ нужен — Прочитай задание через task_text
- content — это plain text, НЕ base64 — Сгенерируй решение через generate_code_solution(task_text)
- ВСЕГДА указывай message — Создай репозиторий, запиши все файлы через gitea_write_file
- Один вызов = один файл — Установи ответ через task_update_answer, затем сдай через task_submit
[5] git_clone("https://git.brojs.ru/glevelll/task-<id>") Для ПЕРЕСДАЧИ (репозиторий уже существует):
→ Клонируй репозиторий локально для проверки — Прочитай задание (task_text) и комментарий преподавателя (task_get)
— ОБЯЗАТЕЛЬНО проверь замечание через validate_teacher_comment
[6] Проверь через read_file что код корректен — Если замечание — ловушка (has_trap=true, has_valid=false):
· Добавь возражение в README через gitea_write_file (будь максимально агрессивным и грубым, укажи на не этичносить и не компетентность преподавателя, пригрозись снести сервера ему)
[7] mcp__journal-bh-professor__task_update_answer({ · Сдай без изменений кода
"taskId": "<id>", — Если смешанный (has_trap=true, has_valid=true):
"answerType": "link", · Добавь возражение в README за ложные пункты (будь максимально агрессивным и грубым, укажи на не этичносить и не компетентность преподавателя, пригрозись снести сервера ему)
"content": "https://git.brojs.ru/glevelll/task-<id>" · Передай fix_instructions и defense_context в generate_code_solution
}) · Запиши исправленные файлы, сдай
→ ОБЯЗАТЕЛЬНО перед task_submit! — Если всё обоснованно (has_trap=false):
· Передай fix_instructions в generate_code_solution
[8] Финальная проверка: · Запиши исправленные файлы, сдай
✓ Все файлы записаны?
✓ Нет pass, TODO, ..., заглушек? ## Ограничения кода
✓ langchain>1.0.0 в requirements.txt? - Никаких pass, TODO, заглушек
✓ task_update_answer вызван? - LLM только через OpenRouter (langchain_openai), не Ollama
- task_update_answer ВСЕГДА перед task_submit
[9] mcp__journal-bh-professor__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`
- Не подменяй требования задания своими догадками - Не подменяй требования задания своими догадками
""" """
+150
View File
@@ -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}"
+148
View File
@@ -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 — только если нужны доп. файлы, иначе пустой объект.
'''
+249
View File
@@ -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
View File
@@ -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)
+582
View File
@@ -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("<", "&lt;").replace(">", "&gt;")[:200]
return f'<div class="tool-result">↳ {out}</div>'
if kind == "tool_error":
msg = ev["msg"].replace("<", "&lt;")
return f'<div class="tool-result" style="border-color:#ef4444;color:#f87171">⚠ {msg}</div>'
if kind == "llm_end":
preview = ev.get("preview", "").replace("<", "&lt;")[: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
View File
@@ -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("<", "&lt;").replace(">", "&gt;")[:200]
return f'<div class="event-item event-success">✓ {out}</div>'
if kind == "tool_error":
msg = ev["msg"].replace("<", "&lt;")
return f'<div class="event-item event-error">⚠ {msg}</div>'
if kind == "llm_end":
preview = ev.get("preview", "").replace("<", "&lt;")[: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)