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.
This commit is contained in:
@@ -1,16 +0,0 @@
|
||||
# ============================================================
|
||||
# ЗАГЛУШКИ — замени значения на реальные перед запуском
|
||||
# ============================================================
|
||||
|
||||
# BroJS Journal токен (генерируется на platform.brojs.ru, протухает)
|
||||
# Используется и для MCP, и для Inference API (токены учитываются на платформе)
|
||||
# ⚠️ ЗАГЛУШКА #1
|
||||
JOURNAL_TOKEN=YOUR_JOURNAL_TOKEN_HERE
|
||||
|
||||
# Gitea токен (git.brojs.ru → Settings → Applications → Access Tokens)
|
||||
# ⚠️ ЗАГЛУШКА #3
|
||||
GITEA_TOKEN=YOUR_GITEA_TOKEN_HERE
|
||||
|
||||
# Tavily API ключ для web_search (https://tavily.com, опционально)
|
||||
# ⚠️ ЗАГЛУШКА #4 (можно оставить пустым — web_search будет недоступен)
|
||||
TAVILY_API_KEY=
|
||||
@@ -11,6 +11,7 @@ __pycache__/
|
||||
*.pyo
|
||||
.venv/
|
||||
venv/
|
||||
venvv/
|
||||
env/
|
||||
*.egg-info/
|
||||
dist/
|
||||
@@ -29,3 +30,8 @@ Thumbs.db
|
||||
|
||||
# LangGraph / LangSmith
|
||||
.langgraph_api/
|
||||
|
||||
# Deep Agents UI (Next.js)
|
||||
deep-agents-ui/node_modules/
|
||||
deep-agents-ui/.next/
|
||||
deep-agents-ui/.env.local
|
||||
|
||||
@@ -0,0 +1,567 @@
|
||||
# Архитектура папки `src/agent`
|
||||
|
||||
## Обзор проекта
|
||||
|
||||
Проект **brojs-agent** — это мультиагентная система для автоматизации выполнения заданий курса **KFU-26-1** на платформе BroJS (platform.brojs.ru). Система использует **OpenRouter** (облачный LLM) и координирует несколько специализированных агентов через фреймворк **deepagents** и **LangGraph**.
|
||||
|
||||
### Основная задача
|
||||
1. Получить список незакрытых заданий из журнала BroJS
|
||||
2. Запустить агента для выполнения каждого задания
|
||||
3. Агент пишет код, создаёт репозиторий на Gitea, тестирует, отправляет ответ
|
||||
4. При отклонении (пересдача) — клонирует код, исправляет по замечаниям, пересдаёт
|
||||
|
||||
---
|
||||
|
||||
## Структура файлов
|
||||
|
||||
```
|
||||
src/agent/
|
||||
├── agent.py # 🎯 Инициализация всех агентов (главный орк, ДЗ, пересдача)
|
||||
├── constants.py # ⚙️ Константы: ID курса, пути, Gitea-параметры
|
||||
├── llm.py # 🧠 Инициализация LLM через OpenRouter
|
||||
├── mcp_client.py # 📡 Загрузка инструментов Journal через MCP
|
||||
├── gitea_tools.py # 🔧 REST-инструменты для git.brojs.ru
|
||||
├── tools.py # 🌐 Git и веб-инструменты
|
||||
├── prompts.py # 📝 Системные промпты для всех агентов
|
||||
├── subagents.py # 🤖 Спецификации субагентов
|
||||
├── runner_tools.py # ⚙️ Инструменты запуска заданий
|
||||
├── solve_tools.py # 💻 Инструменты для решения (генерация кода, валидация)
|
||||
├── solve_prompts.py # 📄 Промпты для решения заданий
|
||||
├── middlewares/ # 🛡️ Middleware для обработки ошибок и валидации
|
||||
│ ├── retry_on_rate_limit.py # Retry при ошибках 429
|
||||
│ ├── sanitize_tool_calls.py # Фильтрация незнакомых инструментов
|
||||
│ └── validate_journal_workflow.py # Валидация workflow Journal
|
||||
├── graph/ # 📊 LangGraph pipeline
|
||||
│ ├── pipeline.py # Основной pipeline последовательного выполнения
|
||||
│ └── __init__.py
|
||||
├── agent_workspace/ # 💾 Рабочая папка агента
|
||||
│ ├── AGENTS.md # Память агента между запусками
|
||||
│ └── large_tool_results/ # Кэш больших результатов
|
||||
└── __pycache__/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Компоненты системы
|
||||
|
||||
### 1️⃣ **LLM (Большая языковая модель)**
|
||||
**Файл:** [llm.py](llm.py)
|
||||
|
||||
```python
|
||||
ChatOpenAI(
|
||||
model="openai/gpt-oss-20b:free",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
api_key=os.getenv("OPENAI_API_KEY"), # sk-or-v1-...
|
||||
temperature=0.0,
|
||||
)
|
||||
```
|
||||
|
||||
- **Провайдер:** OpenRouter (облачный сервис)
|
||||
- **Модель:** `gpt-oss-20b:free` (бесплатная, 20B параметров)
|
||||
- **Температура:** 0.0 (детерминированные ответы)
|
||||
- **Зачем OpenRouter вместо Ollama?**
|
||||
- Нет требований к GPU/RAM
|
||||
- Совместим с LangChain из коробки
|
||||
- Работает в CI/CD
|
||||
- Бесплатный тир
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ **Konstantes & пути**
|
||||
**Файл:** [constants.py](constants.py)
|
||||
|
||||
Определяет ключевые параметры:
|
||||
|
||||
```python
|
||||
COURSE_ID = "698b49da77cb6d4d2e43ce78" # ID курса KFU-26-1
|
||||
GITEA_OWNER = "dapa46" # Владелец репозиториев на git.brojs.ru
|
||||
AGENT_WORKSPACE_DIR = /path/to/agent_workspace # Рабочая папка
|
||||
AGENTS_MD_VFS_PATH = "/AGENTS.md" # Виртуальный путь памяти
|
||||
```
|
||||
|
||||
Функция `ensure_agents_md_file()` создаёт `AGENTS.md` при первом запуске — файл служит **памятью** агента между сообщениями.
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ **MCP: Инструменты Journal**
|
||||
**Файл:** [mcp_client.py](mcp_client.py)
|
||||
|
||||
**MCP (Model Context Protocol)** — стандарт для подключения инструментов к LLM.
|
||||
|
||||
#### Как это работает:
|
||||
1. **Загрузка асинхронно** через `MultiServerMCPClient`
|
||||
2. **HTTP transport** к `https://platform.brojs.ru/jrnl-bh/api/mcp`
|
||||
3. **Авторизация** Bearer токеном из `.env` (`JOURNAL_TOKEN=jrnl_...`)
|
||||
4. **Раздает инструменты в две группы:**
|
||||
|
||||
| Группа | Инструменты | Назначение |
|
||||
|--------|-----------|-----------|
|
||||
| **Courses & Lessons** | `courses_list`, `lessons_list` | Получить структуру курса |
|
||||
| **Tasks & Submissions** | `tasks_list`, `task_text`, `task_get`, `task_update_answer`, `task_submit`, `task_submission_status` | Работа с заданиями |
|
||||
|
||||
#### Обработка ошибок:
|
||||
- **Exponential backoff** при ошибке 429 (rate limit): 15, 30, 60, 120, 240 сек
|
||||
- **Персистентный клиент** переиспользуется для всего запуска
|
||||
- **Минимальная пауза** 1.5 сек между вызовами (предотвращает burst)
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ **Gitea REST API**
|
||||
**Файл:** [gitea_tools.py](gitea_tools.py)
|
||||
|
||||
Инструменты для работы с git.brojs.ru:
|
||||
|
||||
```python
|
||||
@tool()
|
||||
def gitea_list_repos() → str
|
||||
# Список своих репозиториев
|
||||
|
||||
@tool()
|
||||
def gitea_create_repo(name: str) → str
|
||||
# Создать новый репозиторий
|
||||
|
||||
@tool()
|
||||
def gitea_get_file(repo: str, path: str) → str
|
||||
# Прочитать файл из репозитория
|
||||
|
||||
@tool()
|
||||
def gitea_create_file(repo: str, path: str, content: str, message: str) → str
|
||||
# Создать/обновить файл
|
||||
|
||||
@tool()
|
||||
def gitea_delete_repo(repo: str) → str
|
||||
# Удалить репозиторий
|
||||
```
|
||||
|
||||
**Авторизация:** Token через заголовок `Authorization: token {GITEA_TOKEN}`
|
||||
|
||||
**Эндпоинты:** REST API v1: `/api/v1/repos/{owner}/{repo}/...`
|
||||
|
||||
---
|
||||
|
||||
### 5️⃣ **Git & Web tools**
|
||||
**Файл:** [tools.py](tools.py)
|
||||
|
||||
#### Git инструменты (работают в `agent_workspace`):
|
||||
- `git_clone(url)` — клонировать репозиторий
|
||||
- `git_commit(repo, files, message)` — коммитить файлы
|
||||
- `git_push(repo)` — пушить в origin
|
||||
- `git_get_commit_history(repo)` — получить историю
|
||||
|
||||
#### Web инструменты:
|
||||
- `web_search(query)` — поиск через Tavily (если настроен API ключ)
|
||||
- `get_page_content(url)` — загрузить страницу и вернуть Markdown
|
||||
|
||||
---
|
||||
|
||||
### 6️⃣ **Solve tools: Генерация и валидация кода**
|
||||
**Файл:** [solve_tools.py](solve_tools.py)
|
||||
|
||||
Специализированные инструменты для решения заданий:
|
||||
|
||||
```python
|
||||
@tool()
|
||||
async def generate_code_solution(
|
||||
task_id: str,
|
||||
task_text: str,
|
||||
previous_code: str = ""
|
||||
) → str
|
||||
# Генерирует/улучшает Python-решение
|
||||
# Вызывает субагента с prompt из solve_prompts.py
|
||||
|
||||
@tool()
|
||||
async def validate_teacher_comment(
|
||||
task_id: str,
|
||||
comment: str
|
||||
) → dict
|
||||
# Валидирует замечание преподавателя
|
||||
# Разбирает комментарий на claim'ы, каждый валидирует отдельно
|
||||
```
|
||||
|
||||
**Кэширование:** Результаты кэшируются в `agent_workspace/large_tool_results/`
|
||||
|
||||
---
|
||||
|
||||
### 7️⃣ **Агенты (deepagents)**
|
||||
**Файл:** [agent.py](agent.py)
|
||||
|
||||
Система создаёт **3 основных агента:**
|
||||
|
||||
#### 🎯 Главный агент (`agent`)
|
||||
- **Инструменты:** Gitea, Journal, solve_task
|
||||
- **Роль:** Оркестратор — управляет всем процессом
|
||||
- **Субагенты:** web_search, homework_doing, journal_bh_tasks_submissions
|
||||
- **Middleware:** SanitizeToolCallsMiddleware (фильтрует недоступные инструменты)
|
||||
|
||||
#### 💻 Агент ДЗ (`homework_direct_agent`)
|
||||
- **Инструменты:** Git, Gitea, Web, Journal, Solve tools
|
||||
- **Роль:** Выполняет одно задание от начала до конца
|
||||
- **Процесс:**
|
||||
1. Читает текст задания
|
||||
2. Генерирует решение (код)
|
||||
3. Тестирует локально
|
||||
4. Создаёт/обновляет репозиторий
|
||||
5. Пушит решение
|
||||
6. Отправляет ответ в Journal
|
||||
7. Сдаёт на проверку
|
||||
- **Middleware:**
|
||||
- `RetryOnRateLimitMiddleware()` — retry при 429
|
||||
- `SanitizeToolCallsMiddleware` — фильтрация
|
||||
- `ValidateJournalWorkflowMiddleware()` — проверка workflow
|
||||
|
||||
#### 🔄 Агент пересдачи (`rework_agent`)
|
||||
- **Инструменты:** Такие же как homework_direct_agent
|
||||
- **Роль:** При отклонении — клонирует, исправляет, пересдаёт
|
||||
- **Процесс:**
|
||||
1. Клонирует предыдущее решение
|
||||
2. Читает замечания преподавателя
|
||||
3. Исправляет код по замечаниям
|
||||
4. Пушит обновление
|
||||
5. Переотправляет ответ
|
||||
6. Пересдаёт
|
||||
|
||||
---
|
||||
|
||||
### 8️⃣ **Субагенты (для делегирования)**
|
||||
**Файл:** [subagents.py](subagents.py)
|
||||
|
||||
LangGraph поддерживает делегирование задач субагентам. Определены 3 субагента:
|
||||
|
||||
```python
|
||||
subagent_specs = [
|
||||
{
|
||||
"name": "web_search",
|
||||
"description": "Ищет информацию в интернете",
|
||||
"system_prompt": research_instructions,
|
||||
# tools добавляются в agent.py
|
||||
},
|
||||
{
|
||||
"name": "homework_doing",
|
||||
"description": "Выполняет домашние задания",
|
||||
"system_prompt": homework_doing_instructions,
|
||||
},
|
||||
{
|
||||
"name": "journal_bh_tasks_submissions",
|
||||
"description": "Работает с Journal: задания и сдачи",
|
||||
"system_prompt": journal_tasks_submissions_instructions,
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
Каждый субагент получает свой набор инструментов и промпт через middleware.
|
||||
|
||||
---
|
||||
|
||||
### 9️⃣ **Middleware: фильтрация и обработка ошибок**
|
||||
**Папка:** [middlewares/](middlewares/)
|
||||
|
||||
#### `retry_on_rate_limit.py`
|
||||
Перехватывает ошибки 429 и retry'т с exponential backoff.
|
||||
|
||||
```python
|
||||
class RetryOnRateLimitMiddleware:
|
||||
async def before_tool_call(tool, input):
|
||||
# Ищет 429 в предыдущих ошибках
|
||||
if rate_limit_error:
|
||||
await asyncio.sleep(backoff)
|
||||
return retry_tool_call()
|
||||
```
|
||||
|
||||
#### `sanitize_tool_calls.py`
|
||||
Фильтрует вызовы инструментов — дозволяет только известные.
|
||||
|
||||
```python
|
||||
class SanitizeToolCallsMiddleware:
|
||||
def __init__(self, known_tools: set[str]):
|
||||
self.known_tools = known_tools
|
||||
|
||||
async def before_tool_call(tool, input):
|
||||
if tool.name not in self.known_tools:
|
||||
raise ValueError(f"Unknown tool: {tool.name}")
|
||||
```
|
||||
|
||||
#### `validate_journal_workflow.py`
|
||||
Проверяет корректность workflow при работе с Journal:
|
||||
- После получения задания → обновить ответ
|
||||
- Перед сдачей → проверить статус
|
||||
- После сдачи → подтвердить
|
||||
|
||||
---
|
||||
|
||||
### 🔟 **LangGraph Pipeline**
|
||||
**Файл:** [graph/pipeline.py](graph/pipeline.py)
|
||||
|
||||
**LangGraph** — это граф для управления состоянием и последовательностью выполнения.
|
||||
|
||||
#### Состояние (`PipelineState`):
|
||||
```python
|
||||
class PipelineState(TypedDict):
|
||||
tasks: list[TaskInfo] # Список всех заданий
|
||||
current_index: int # Индекс текущего задания
|
||||
results: list[dict] # Результаты выполнения
|
||||
errors: list[str] # Ошибки
|
||||
```
|
||||
|
||||
#### Узлы графа:
|
||||
1. **`fetch_tasks`** → Получить список незакрытых заданий из Journal
|
||||
2. **`process_task`** → Запустить `homework_direct_agent` на текущее задание
|
||||
3. **`check_result`** → Проверить результат (успех/ошибка)
|
||||
4. **`next_or_done`** → Перейти к следующему заданию или завершить
|
||||
|
||||
#### Переходы:
|
||||
```
|
||||
START → fetch_tasks → process_task → check_result → (success) → next_or_done
|
||||
→ (error) → next_or_done
|
||||
→ (need_rework) → run_rework_agent → next_or_done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1️⃣1️⃣ **Промпты (системные инструкции)**
|
||||
**Файл:** [prompts.py](prompts.py)
|
||||
|
||||
Содержит системные промпты для каждого агента:
|
||||
|
||||
#### `main_agent_instructions`
|
||||
Инструкции для главного оркестратора:
|
||||
- Управляет процессом в целом
|
||||
- Делегирует задачи субагентам
|
||||
- Координирует работу
|
||||
|
||||
#### `homework_doing_instructions`
|
||||
Инструкции для агента выполнения ДЗ:
|
||||
- Как читать задание
|
||||
- Как генерировать код
|
||||
- Как тестировать
|
||||
- Как создавать репозиторий
|
||||
- Как отправлять ответ
|
||||
|
||||
#### `rework_instructions`
|
||||
Инструкции для агента пересдачи:
|
||||
- Как интерпретировать замечания
|
||||
- Как исправлять код
|
||||
- Как пересдавать
|
||||
|
||||
#### `research_instructions`
|
||||
Инструкции для веб-поиска:
|
||||
- Искать только из реально открытых страниц
|
||||
- Не выдумывать факты
|
||||
- Подтверждать URL
|
||||
|
||||
---
|
||||
|
||||
## Поток выполнения
|
||||
|
||||
### 📊 Общий сценарий
|
||||
|
||||
```
|
||||
1. Пользователь запускает pipeline.py
|
||||
↓
|
||||
2. pipeline.fetch_tasks()
|
||||
→ Запрашивает Journal: "Дай мне все незакрытые задания курса KFU-26-1"
|
||||
→ Получает список TaskInfo (id, title, status)
|
||||
↓
|
||||
3. Для каждого задания (while current_index < len(tasks)):
|
||||
↓
|
||||
4. pipeline.process_task()
|
||||
→ Вызывает homework_direct_agent.invoke({
|
||||
"messages": [HumanMessage("Выполни задание XYZ")]
|
||||
})
|
||||
↓
|
||||
5. homework_direct_agent работает:
|
||||
|
||||
5a. Читает текст задания через task_text()
|
||||
5b. Вызывает generate_code_solution() → получает Python-код
|
||||
5c. Тестирует код локально (git_clone → test.py → git_push)
|
||||
5d. Обновляет ответ в Journal через task_update_answer()
|
||||
5e. Отправляет на проверку через task_submit()
|
||||
↓
|
||||
6. pipeline.check_result()
|
||||
→ Проверяет: успех? ошибка? нужна пересдача?
|
||||
↓
|
||||
7. Если успех → перейти к следующему заданию
|
||||
Если ошибка → залогировать и перейти
|
||||
Если пересдача → запустить rework_agent
|
||||
↓
|
||||
8. pipeline.next_or_done()
|
||||
→ current_index += 1
|
||||
→ Если есть ещё задания → goto 4
|
||||
→ Иначе → завершить, вернуть results
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Интеграции
|
||||
|
||||
### 🔌 Внешние сервисы
|
||||
|
||||
| Сервис | Назначение | Авторизация | Endpoint |
|
||||
|--------|-----------|-----------|----------|
|
||||
| **OpenRouter** | LLM (генерация кода) | `OPENAI_API_KEY=sk-or-v1-...` | `https://openrouter.ai/api/v1` |
|
||||
| **BroJS Journal** | Задания и сдачи | `JOURNAL_TOKEN=jrnl_...` | `https://platform.brojs.ru/jrnl-bh/api/mcp` |
|
||||
| **Gitea (git.brojs.ru)** | Хранилище кода | `GITEA_TOKEN=...` | `https://git.brojs.ru/api/v1` |
|
||||
| **Tavily** | Веб-поиск (опционально) | `TAVILY_API_KEY=tvly-...` | `https://api.tavily.com` |
|
||||
|
||||
### 🛠️ Локальные компоненты
|
||||
|
||||
- **agent_workspace/** — кэш результатов, память агента (AGENTS.md)
|
||||
- **Python subprocess** — выполнение git команд, тестирование кода
|
||||
|
||||
---
|
||||
|
||||
## Конфигурация (`.env`)
|
||||
|
||||
```bash
|
||||
# Обязательные
|
||||
JOURNAL_TOKEN=jrnl_8b251e8e5b345e310269a697968abecad08043a2a30f07229cefe488e59fb951
|
||||
GITEA_TOKEN=f5892316061c124f447710bb242f2f90c565a411
|
||||
OPENAI_API_KEY=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
|
||||
|
||||
# Опциональные
|
||||
TAVILY_API_KEY=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 # для web_search
|
||||
```
|
||||
|
||||
**Где получить:**
|
||||
- `JOURNAL_TOKEN` → platform.brojs.ru → Settings → Access Tokens
|
||||
- `GITEA_TOKEN` → git.brojs.ru → Settings → Applications → Access Tokens
|
||||
- `OPENAI_API_KEY` → openrouter.ai → API Keys (формат `sk-or-v1-...`)
|
||||
- `TAVILY_API_KEY` → tavily.com → API Keys
|
||||
|
||||
---
|
||||
|
||||
## Ключевые паттерны
|
||||
|
||||
### 🎯 Паттерн: Tool Middleware Pipeline
|
||||
|
||||
```python
|
||||
# В deepagents каждый вызов инструмента проходит через цепь middleware:
|
||||
|
||||
инструмент → middleware1.before → middleware2.before → ... → ИСПОЛНЕНИЕ
|
||||
↓
|
||||
результат ← middleware1.after ← middleware2.after ← ... ← результат
|
||||
```
|
||||
|
||||
### 🔄 Паттерн: Субагент как инструмент
|
||||
|
||||
```python
|
||||
# Главный агент не делает работу сам, а вызывает:
|
||||
agent.invoke()
|
||||
→ Видит "нужно выполнить ДЗ"
|
||||
→ Вызывает инструмент: homework_doing (это субагент)
|
||||
→ Субагент имеет свой LLM, свой prompt, свои инструменты
|
||||
→ Возвращает результат главному агенту
|
||||
```
|
||||
|
||||
### 📝 Паттерн: Виртуальная файловая система (VFS)
|
||||
|
||||
```python
|
||||
# Агент видит файловую систему как:
|
||||
/AGENTS.md # памятью агента (бинд к agent_workspace/AGENTS.md)
|
||||
/skills/... # bundled skills (бинд к src/agent/skills/)
|
||||
~/project/... # рабочая папка (бинд к agent_workspace/)
|
||||
```
|
||||
|
||||
Это позволяет агенту иметь консистентный вид файловой системы, хотя на самом деле это виртуальная проекция.
|
||||
|
||||
### 💾 Паттерн: Асинхронная загрузка MCP
|
||||
|
||||
```python
|
||||
# MCP может быть долгой операцией (сетевой запрос)
|
||||
# Решение: asyncio.run() в отдельном потоке при наличии event loop
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop() # есть ли event loop?
|
||||
# Да → запустить в ThreadPoolExecutor
|
||||
with ThreadPoolExecutor() as pool:
|
||||
result = pool.submit(lambda: asyncio.run(fetch())).result()
|
||||
except RuntimeError:
|
||||
# Нет → просто asyncio.run()
|
||||
result = asyncio.run(fetch())
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Отладка
|
||||
|
||||
### 📋 Логирование
|
||||
|
||||
Система выводит логи при инициализации:
|
||||
```
|
||||
=== Загружено: journal=X, gitea=5, git=5 ===
|
||||
```
|
||||
|
||||
При ошибке MCP:
|
||||
```
|
||||
MCP 'journal-bh-professor': не удалось загрузить инструменты — ...
|
||||
```
|
||||
|
||||
### 🧪 Тестирование компонентов
|
||||
|
||||
```python
|
||||
# Импорт отдельного агента
|
||||
from src.agent.agent import homework_direct_agent
|
||||
|
||||
# Запуск на тестовом задании
|
||||
result = homework_direct_agent.invoke({
|
||||
"messages": [HumanMessage("Выполни задание ...")]
|
||||
})
|
||||
```
|
||||
|
||||
### 🔍 Проверка инструментов
|
||||
|
||||
```python
|
||||
# Какие инструменты есть у агента?
|
||||
print(homework_direct_agent.tools)
|
||||
|
||||
# Какие middleware установлены?
|
||||
# Нужно смотреть в create_deep_agent(middleware=[...])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Возможные ошибки и решения
|
||||
|
||||
| Ошибка | Причина | Решение |
|
||||
|--------|---------|---------|
|
||||
| `ImportError: No module named 'src.agent.agent'` | Неправильный PYTHONPATH | `cd /path/to/project && python -m src.agent.graph.pipeline` |
|
||||
| `MCP journal: не удалось загрузить` | Токен неправильный/устарел | Сгенерировать новый `JOURNAL_TOKEN` на platform.brojs.ru |
|
||||
| `429 Too Many Requests` | Rate limit OpenRouter | Middleware автоматически retry'т с backoff |
|
||||
| `gitea_create_repo: 401 Unauthorized` | Неправильный Gitea токен | Проверить `GITEA_TOKEN` в `.env` |
|
||||
| `generate_code_solution timeout` | Генерация долгая | Увеличить timeout в LLM конфиге |
|
||||
|
||||
---
|
||||
|
||||
## Резюме: Как это работает вместе
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ PIPELINE (LangGraph) │
|
||||
│ Последовательно: fetch_tasks → process_task → check │
|
||||
└──────────────────────┬──────────────────────────────────┘
|
||||
│
|
||||
├─→ JOURNAL (MCP)
|
||||
│ Получить задания, отправить ответы
|
||||
│
|
||||
├─→ HOMEWORK_DIRECT_AGENT
|
||||
│ ├─ generate_code_solution (LLM)
|
||||
│ ├─ gitea_* (REST API)
|
||||
│ ├─ git_* (subprocess)
|
||||
│ └─ task_submit (Journal)
|
||||
│
|
||||
├─→ REWORK_AGENT
|
||||
│ └─ Аналогично, но для пересдачи
|
||||
│
|
||||
└─→ MIDDLEWARES
|
||||
├─ SanitizeToolCallsMiddleware
|
||||
├─ RetryOnRateLimitMiddleware
|
||||
└─ ValidateJournalWorkflowMiddleware
|
||||
|
||||
┌─────────────────────┐
|
||||
│ OpenRouter LLM │
|
||||
│ gpt-oss-20b:free │
|
||||
└─────────────────────┘
|
||||
(Генерация кода и рассуждения)
|
||||
```
|
||||
|
||||
Система автоматизирует весь цикл выполнения и сдачи заданий, обрабатывая ошибки, пересдачи и координируя несколько специализированных агентов через единый фреймворк.
|
||||
|
||||
@@ -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
|
||||
# Установить langgraph-cli
|
||||
@@ -138,6 +170,11 @@ brojs-agent/
|
||||
├── pyproject.toml
|
||||
├── langgraph.json
|
||||
├── 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/
|
||||
└── agent/
|
||||
├── agent.py # создание агентов (main, homework, rework)
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "github-actions" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
# Run formatting on all PRs
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
workflow_dispatch: # Allows triggering the workflow manually in GitHub UI
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# If another push to the same PR or branch happens while this workflow is still running,
|
||||
# cancel the earlier run in favor of the next run.
|
||||
#
|
||||
# There's no point in testing an outdated version of the code. GitHub only allows
|
||||
# a limited number of job runners to be active at the same time, so it's better to cancel
|
||||
# pointless jobs early so that more useful jobs can run sooner.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
format:
|
||||
name: Check formatting
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Use Node.js 20.x
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: "yarn"
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable --mode=skip-build
|
||||
- name: Check formatting
|
||||
run: yarn format:check
|
||||
|
||||
lint:
|
||||
name: Check linting
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Use Node.js 20.x
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: "yarn"
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable --mode=skip-build
|
||||
- name: Check linting
|
||||
run: yarn run lint
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Enable Corepack
|
||||
run: corepack enable
|
||||
- name: Use Node.js 20.x
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: "yarn"
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable --mode=skip-build
|
||||
- name: Build
|
||||
run: yarn build
|
||||
|
||||
readme-spelling:
|
||||
name: Check README spelling
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
|
||||
with:
|
||||
ignore_words_file: .codespellignore
|
||||
path: README.md
|
||||
|
||||
check-spelling:
|
||||
name: Check code spelling
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
|
||||
with:
|
||||
ignore_words_file: .codespellignore
|
||||
path: src
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
name: PR Title Lint
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, synchronize]
|
||||
|
||||
jobs:
|
||||
lint-pr-title:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Validate PR Title
|
||||
uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
types: |
|
||||
feat
|
||||
fix
|
||||
docs
|
||||
style
|
||||
refactor
|
||||
perf
|
||||
test
|
||||
build
|
||||
ci
|
||||
chore
|
||||
revert
|
||||
release
|
||||
scopes: |
|
||||
shared
|
||||
cli
|
||||
web
|
||||
open-swe
|
||||
docs
|
||||
requireScope: false
|
||||
ignoreLabels: |
|
||||
ignore-lint-pr-title
|
||||
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1 @@
|
||||
legacy-peer-deps=true
|
||||
@@ -0,0 +1,2 @@
|
||||
20
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
coverage
|
||||
|
||||
# next.js
|
||||
.next
|
||||
out
|
||||
|
||||
# production
|
||||
build
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# misc
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# lock files
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
package-lock.json
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 LangChain
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,93 @@
|
||||
# 🚀🧠 Deep Agents UI
|
||||
|
||||
[Deep Agents](https://github.com/langchain-ai/deepagents) is a simple, open source agent harness that implements a few generally useful tools, including planning (prior to task execution), computer access (giving the able access to a shell and a filesystem), and sub-agent delegation (isolated task execution). This is a UI for interacting with deepagents.
|
||||
|
||||
## 🚀 Quickstart
|
||||
|
||||
**Install dependencies and run the app**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/langchain-ai/deep-agents-ui.git
|
||||
cd deep-agents-ui
|
||||
yarn install
|
||||
yarn dev
|
||||
```
|
||||
|
||||
**Deploy a Deep Agent**
|
||||
|
||||
As an example, see our [Deep Agents quickstarts](https://github.com/langchain-ai/deepagents/tree/main/examples) for examples and run the `deep_research` example.
|
||||
|
||||
The `langgraph.json` file has the assistant ID as the key:
|
||||
|
||||
```
|
||||
"graphs": {
|
||||
"research": "./agent.py:agent"
|
||||
},
|
||||
```
|
||||
|
||||
Kick off the local LangGraph deployment:
|
||||
|
||||
```bash
|
||||
cd deepagents-quickstarts/deep_research
|
||||
langgraph dev
|
||||
```
|
||||
|
||||
You will see the local LangGraph deployment log to terminal:
|
||||
|
||||
```
|
||||
╦ ┌─┐┌┐┌┌─┐╔═╗┬─┐┌─┐┌─┐┬ ┬
|
||||
║ ├─┤││││ ┬║ ╦├┬┘├─┤├─┘├─┤
|
||||
╩═╝┴ ┴┘└┘└─┘╚═╝┴└─┴ ┴┴ ┴ ┴
|
||||
|
||||
- 🚀 API: http://127.0.0.1:2024
|
||||
- 🎨 Studio UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024
|
||||
- 📚 API Docs: http://127.0.0.1:2024/docs
|
||||
...
|
||||
```
|
||||
|
||||
You can get the Deployment URL and Assistant ID from the terminal output and `langgraph.json` file, respectively:
|
||||
|
||||
- Deployment URL: <http://127.0.1:2024>
|
||||
- Assistant ID: `research`
|
||||
|
||||
**Open Deep Agents UI** at [http://localhost:3000](http://localhost:3000) and input the Deployment URL and Assistant ID:
|
||||
|
||||
- **Deployment URL**: The URL for the LangGraph deployment you are connecting to
|
||||
- **Assistant ID**: The ID of the assistant or agent you want to use
|
||||
- [Optional] **LangSmith API Key**: Your LangSmith API key (format: `lsv2_pt_...`). This may be required for accessing deployed LangGraph applications. You can also provide this via the `NEXT_PUBLIC_LANGSMITH_API_KEY` environment variable.
|
||||
|
||||
**Usage**
|
||||
|
||||
You can interact with the deployment via the chat interface and can edit settings at any time by clicking on the Settings button in the header.
|
||||
|
||||
<img width="2039" height="1495" alt="Screenshot 2025-11-17 at 1 11 27 PM" src="https://github.com/user-attachments/assets/50e1b5f3-a626-4461-9ad9-90347e471e8c" />
|
||||
|
||||
As the deepagent runs, you can see its files in LangGraph state.
|
||||
|
||||
<img width="2039" height="1495" alt="Screenshot 2025-11-17 at 1 11 36 PM" src="https://github.com/user-attachments/assets/86cc6228-5414-4cf0-90f5-d206d30c005e" />
|
||||
|
||||
You can click on any file to view it.
|
||||
|
||||
<img width="2039" height="1495" alt="Screenshot 2025-11-17 at 1 11 40 PM" src="https://github.com/user-attachments/assets/9883677f-e365-428d-b941-992bdbfa79dd" />
|
||||
|
||||
### Optional: Environment Variables
|
||||
|
||||
You can optionally set environment variables instead of using the settings dialog:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_LANGSMITH_API_KEY="lsv2_xxxx"
|
||||
```
|
||||
|
||||
**Note:** Settings configured in the UI take precedence over environment variables.
|
||||
|
||||
### Usage
|
||||
|
||||
You can run your Deep Agents in Debug Mode, which will execute the agent step by step. This will allow you to re-run the specific steps of the agent. This is intended to be used alongside the optimizer.
|
||||
|
||||
You can also turn off Debug Mode to run the full agent end-to-end.
|
||||
|
||||
### 📚 Resources
|
||||
|
||||
If the term "Deep Agents" is new to you, check out these videos!
|
||||
[What are Deep Agents?](https://www.youtube.com/watch?v=433SmtTc0TA)
|
||||
[Implementing Deep Agents](https://www.youtube.com/watch?v=TTMYJAw5tiA&t=701s)
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", ".next", "node_modules"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"@typescript-eslint/no-explicit-any": 0,
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ args: "none", argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
+10490
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"name": "deep-agents-ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/core": "^1.1.19",
|
||||
"@langchain/langgraph": "^1.0.2",
|
||||
"@langchain/langgraph-sdk": "^1.0.3",
|
||||
"@radix-ui/colors": "^1.0.0",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@types/diff": "^5.0.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^1.2.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"diff": "^8.0.3",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lucide-react": "^0.539.0",
|
||||
"next": "^16.2.5",
|
||||
"nuqs": "^2.8.8",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sass": "^1.99.0",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.4.1",
|
||||
"tailwind-merge": "^2.6",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@headlessui/tailwindcss": "^0.2.2",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-tailwindcss": "^0.3.0",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.54.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"tailwindcss/nesting": {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @see https://prettier.io/docs/configuration
|
||||
* @type {import("prettier").Config}
|
||||
*/
|
||||
const config = {
|
||||
endOfLine: "auto",
|
||||
singleAttributePerLine: true,
|
||||
plugins: ["prettier-plugin-tailwindcss"],
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,545 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
FormEvent,
|
||||
Fragment,
|
||||
} from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Square,
|
||||
ArrowUp,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Circle,
|
||||
FileIcon,
|
||||
} from "lucide-react";
|
||||
import { ChatMessage } from "@/app/components/ChatMessage";
|
||||
import type {
|
||||
TodoItem,
|
||||
ToolCall,
|
||||
ActionRequest,
|
||||
ReviewConfig,
|
||||
} from "@/app/types/types";
|
||||
import { Assistant, Message } from "@langchain/langgraph-sdk";
|
||||
import { extractStringFromMessageContent } from "@/app/utils/utils";
|
||||
import { useChatContext } from "@/providers/ChatProvider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useStickToBottom } from "use-stick-to-bottom";
|
||||
import { FilesPopover } from "@/app/components/TasksFilesSidebar";
|
||||
|
||||
interface ChatInterfaceProps {
|
||||
assistant: Assistant | null;
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: TodoItem["status"], className?: string) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return (
|
||||
<CheckCircle
|
||||
size={16}
|
||||
className={cn("text-success/80", className)}
|
||||
/>
|
||||
);
|
||||
case "in_progress":
|
||||
return (
|
||||
<Clock
|
||||
size={16}
|
||||
className={cn("text-warning/80", className)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Circle
|
||||
size={16}
|
||||
className={cn("text-tertiary/70", className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
|
||||
const [metaOpen, setMetaOpen] = useState<"tasks" | "files" | null>(null);
|
||||
const tasksContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const { scrollRef, contentRef } = useStickToBottom();
|
||||
|
||||
const {
|
||||
stream,
|
||||
messages,
|
||||
todos,
|
||||
files,
|
||||
ui,
|
||||
setFiles,
|
||||
isLoading,
|
||||
isThreadLoading,
|
||||
interrupt,
|
||||
sendMessage,
|
||||
stopStream,
|
||||
resumeInterrupt,
|
||||
} = useChatContext();
|
||||
|
||||
const submitDisabled = isLoading || !assistant;
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e?: FormEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
const messageText = input.trim();
|
||||
if (!messageText || isLoading || submitDisabled) return;
|
||||
sendMessage(messageText);
|
||||
setInput("");
|
||||
},
|
||||
[input, isLoading, sendMessage, setInput, submitDisabled]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (submitDisabled) return;
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit, submitDisabled]
|
||||
);
|
||||
|
||||
// TODO: can we make this part of the hook?
|
||||
const processedMessages = useMemo(() => {
|
||||
/*
|
||||
1. Loop through all messages
|
||||
2. For each AI message, add the AI message, and any tool calls to the messageMap
|
||||
3. For each tool message, find the corresponding tool call in the messageMap and update the status and output
|
||||
*/
|
||||
const messageMap = new Map<
|
||||
string,
|
||||
{ message: Message; toolCalls: ToolCall[] }
|
||||
>();
|
||||
messages.forEach((message: Message) => {
|
||||
if (message.type === "ai") {
|
||||
const toolCallsInMessage: Array<{
|
||||
id?: string;
|
||||
function?: { name?: string; arguments?: unknown };
|
||||
name?: string;
|
||||
type?: string;
|
||||
args?: unknown;
|
||||
input?: unknown;
|
||||
}> = [];
|
||||
if (
|
||||
message.additional_kwargs?.tool_calls &&
|
||||
Array.isArray(message.additional_kwargs.tool_calls)
|
||||
) {
|
||||
toolCallsInMessage.push(...message.additional_kwargs.tool_calls);
|
||||
} else if (message.tool_calls && Array.isArray(message.tool_calls)) {
|
||||
toolCallsInMessage.push(
|
||||
...message.tool_calls.filter(
|
||||
(toolCall: { name?: string }) => toolCall.name !== ""
|
||||
)
|
||||
);
|
||||
} else if (Array.isArray(message.content)) {
|
||||
const toolUseBlocks = message.content.filter(
|
||||
(block: { type?: string }) => block.type === "tool_use"
|
||||
);
|
||||
toolCallsInMessage.push(...toolUseBlocks);
|
||||
}
|
||||
const toolCallsWithStatus = toolCallsInMessage.map(
|
||||
(toolCall: {
|
||||
id?: string;
|
||||
function?: { name?: string; arguments?: unknown };
|
||||
name?: string;
|
||||
type?: string;
|
||||
args?: unknown;
|
||||
input?: unknown;
|
||||
}) => {
|
||||
const name =
|
||||
toolCall.function?.name ||
|
||||
toolCall.name ||
|
||||
toolCall.type ||
|
||||
"unknown";
|
||||
const args =
|
||||
toolCall.function?.arguments ||
|
||||
toolCall.args ||
|
||||
toolCall.input ||
|
||||
{};
|
||||
return {
|
||||
id: toolCall.id || `tool-${Math.random()}`,
|
||||
name,
|
||||
args,
|
||||
status: interrupt ? "interrupted" : ("pending" as const),
|
||||
} as ToolCall;
|
||||
}
|
||||
);
|
||||
messageMap.set(message.id!, {
|
||||
message,
|
||||
toolCalls: toolCallsWithStatus,
|
||||
});
|
||||
} else if (message.type === "tool") {
|
||||
const toolCallId = message.tool_call_id;
|
||||
if (!toolCallId) {
|
||||
return;
|
||||
}
|
||||
for (const [, data] of messageMap.entries()) {
|
||||
const toolCallIndex = data.toolCalls.findIndex(
|
||||
(tc: ToolCall) => tc.id === toolCallId
|
||||
);
|
||||
if (toolCallIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
data.toolCalls[toolCallIndex] = {
|
||||
...data.toolCalls[toolCallIndex],
|
||||
status: "completed" as const,
|
||||
result: extractStringFromMessageContent(message),
|
||||
};
|
||||
break;
|
||||
}
|
||||
} else if (message.type === "human") {
|
||||
messageMap.set(message.id!, {
|
||||
message,
|
||||
toolCalls: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
const processedArray = Array.from(messageMap.values());
|
||||
return processedArray.map((data, index) => {
|
||||
const prevMessage = index > 0 ? processedArray[index - 1].message : null;
|
||||
return {
|
||||
...data,
|
||||
showAvatar: data.message.type !== prevMessage?.type,
|
||||
};
|
||||
});
|
||||
}, [messages, interrupt]);
|
||||
|
||||
const groupedTodos = {
|
||||
in_progress: todos.filter((t) => t.status === "in_progress"),
|
||||
pending: todos.filter((t) => t.status === "pending"),
|
||||
completed: todos.filter((t) => t.status === "completed"),
|
||||
};
|
||||
|
||||
const hasTasks = todos.length > 0;
|
||||
const hasFiles = Object.keys(files).length > 0;
|
||||
|
||||
// Parse out any action requests or review configs from the interrupt
|
||||
const actionRequestsMap: Map<string, ActionRequest> | null = useMemo(() => {
|
||||
const actionRequests =
|
||||
interrupt?.value && (interrupt.value as any)["action_requests"];
|
||||
if (!actionRequests) return new Map<string, ActionRequest>();
|
||||
return new Map(actionRequests.map((ar: ActionRequest) => [ar.name, ar]));
|
||||
}, [interrupt]);
|
||||
|
||||
const reviewConfigsMap: Map<string, ReviewConfig> | null = useMemo(() => {
|
||||
const reviewConfigs =
|
||||
interrupt?.value && (interrupt.value as any)["review_configs"];
|
||||
if (!reviewConfigs) return new Map<string, ReviewConfig>();
|
||||
return new Map(
|
||||
reviewConfigs.map((rc: ReviewConfig) => [rc.actionName, rc])
|
||||
);
|
||||
}, [interrupt]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden overscroll-contain"
|
||||
ref={scrollRef}
|
||||
>
|
||||
<div
|
||||
className="mx-auto w-full max-w-[1024px] px-6 pb-6 pt-4"
|
||||
ref={contentRef}
|
||||
>
|
||||
{isThreadLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{processedMessages.map((data, index) => {
|
||||
const messageUi = ui?.filter(
|
||||
(u: any) => u.metadata?.message_id === data.message.id
|
||||
);
|
||||
const isLastMessage = index === processedMessages.length - 1;
|
||||
return (
|
||||
<ChatMessage
|
||||
key={data.message.id}
|
||||
message={data.message}
|
||||
toolCalls={data.toolCalls}
|
||||
isLoading={isLoading}
|
||||
actionRequestsMap={
|
||||
isLastMessage ? actionRequestsMap : undefined
|
||||
}
|
||||
reviewConfigsMap={
|
||||
isLastMessage ? reviewConfigsMap : undefined
|
||||
}
|
||||
ui={messageUi}
|
||||
stream={stream}
|
||||
onResumeInterrupt={resumeInterrupt}
|
||||
graphId={assistant?.graph_id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 bg-background">
|
||||
<div
|
||||
className={cn(
|
||||
"mx-4 mb-6 flex flex-shrink-0 flex-col overflow-hidden rounded-xl border border-border bg-background",
|
||||
"mx-auto w-[calc(100%-32px)] max-w-[1024px] transition-colors duration-200 ease-in-out"
|
||||
)}
|
||||
>
|
||||
{(hasTasks || hasFiles) && (
|
||||
<div className="flex max-h-72 flex-col overflow-y-auto border-b border-border bg-sidebar empty:hidden">
|
||||
{!metaOpen && (
|
||||
<>
|
||||
{(() => {
|
||||
const activeTask = todos.find(
|
||||
(t) => t.status === "in_progress"
|
||||
);
|
||||
|
||||
const totalTasks = todos.length;
|
||||
const remainingTasks =
|
||||
totalTasks - groupedTodos.pending.length;
|
||||
const isCompleted = totalTasks === remainingTasks;
|
||||
|
||||
const tasksTrigger = (() => {
|
||||
if (!hasTasks) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setMetaOpen((prev) =>
|
||||
prev === "tasks" ? null : "tasks"
|
||||
)
|
||||
}
|
||||
className="grid w-full cursor-pointer grid-cols-[auto_auto_1fr] items-center gap-3 px-[18px] py-3 text-left"
|
||||
aria-expanded={metaOpen === "tasks"}
|
||||
>
|
||||
{(() => {
|
||||
if (isCompleted) {
|
||||
return [
|
||||
<CheckCircle
|
||||
key="icon"
|
||||
size={16}
|
||||
className="text-success/80"
|
||||
/>,
|
||||
<span
|
||||
key="label"
|
||||
className="ml-[1px] min-w-0 truncate text-sm"
|
||||
>
|
||||
All tasks completed
|
||||
</span>,
|
||||
];
|
||||
}
|
||||
|
||||
if (activeTask != null) {
|
||||
return [
|
||||
<div key="icon">
|
||||
{getStatusIcon(activeTask.status)}
|
||||
</div>,
|
||||
<span
|
||||
key="label"
|
||||
className="ml-[1px] min-w-0 truncate text-sm"
|
||||
>
|
||||
Task{" "}
|
||||
{totalTasks - groupedTodos.pending.length} of{" "}
|
||||
{totalTasks}
|
||||
</span>,
|
||||
<span
|
||||
key="content"
|
||||
className="min-w-0 gap-2 truncate text-sm text-muted-foreground"
|
||||
>
|
||||
{activeTask.content}
|
||||
</span>,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
<Circle
|
||||
key="icon"
|
||||
size={16}
|
||||
className="text-tertiary/70"
|
||||
/>,
|
||||
<span
|
||||
key="label"
|
||||
className="ml-[1px] min-w-0 truncate text-sm"
|
||||
>
|
||||
Task {totalTasks - groupedTodos.pending.length}{" "}
|
||||
of {totalTasks}
|
||||
</span>,
|
||||
];
|
||||
})()}
|
||||
</button>
|
||||
);
|
||||
})();
|
||||
|
||||
const filesTrigger = (() => {
|
||||
if (!hasFiles) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setMetaOpen((prev) =>
|
||||
prev === "files" ? null : "files"
|
||||
)
|
||||
}
|
||||
className="flex flex-shrink-0 cursor-pointer items-center gap-2 px-[18px] py-3 text-left text-sm"
|
||||
aria-expanded={metaOpen === "files"}
|
||||
>
|
||||
<FileIcon size={16} />
|
||||
Files (State)
|
||||
<span className="h-4 min-w-4 rounded-full bg-[#2F6868] px-0.5 text-center text-[10px] leading-[16px] text-white">
|
||||
{Object.keys(files).length}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto_auto] items-center">
|
||||
{tasksTrigger}
|
||||
{filesTrigger}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
|
||||
{metaOpen && (
|
||||
<>
|
||||
<div className="sticky top-0 flex items-stretch bg-sidebar text-sm">
|
||||
{hasTasks && (
|
||||
<button
|
||||
type="button"
|
||||
className="py-3 pr-4 first:pl-[18px] aria-expanded:font-semibold"
|
||||
onClick={() =>
|
||||
setMetaOpen((prev) =>
|
||||
prev === "tasks" ? null : "tasks"
|
||||
)
|
||||
}
|
||||
aria-expanded={metaOpen === "tasks"}
|
||||
>
|
||||
Tasks
|
||||
</button>
|
||||
)}
|
||||
{hasFiles && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 py-3 pr-4 first:pl-[18px] aria-expanded:font-semibold"
|
||||
onClick={() =>
|
||||
setMetaOpen((prev) =>
|
||||
prev === "files" ? null : "files"
|
||||
)
|
||||
}
|
||||
aria-expanded={metaOpen === "files"}
|
||||
>
|
||||
Files (State)
|
||||
<span className="h-4 min-w-4 rounded-full bg-[#2F6868] px-0.5 text-center text-[10px] leading-[16px] text-white">
|
||||
{Object.keys(files).length}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label="Close"
|
||||
className="flex-1"
|
||||
onClick={() => setMetaOpen(null)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={tasksContainerRef}
|
||||
className="px-[18px]"
|
||||
>
|
||||
{metaOpen === "tasks" &&
|
||||
Object.entries(groupedTodos)
|
||||
.filter(([_, todos]) => todos.length > 0)
|
||||
.map(([status, todos]) => (
|
||||
<div
|
||||
key={status}
|
||||
className="mb-4"
|
||||
>
|
||||
<h3 className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-tertiary">
|
||||
{
|
||||
{
|
||||
pending: "Pending",
|
||||
in_progress: "In Progress",
|
||||
completed: "Completed",
|
||||
}[status]
|
||||
}
|
||||
</h3>
|
||||
<div className="grid grid-cols-[auto_1fr] gap-3 rounded-sm p-1 pl-0 text-sm">
|
||||
{todos.map((todo, index) => (
|
||||
<Fragment key={`${status}_${todo.id}_${index}`}>
|
||||
{getStatusIcon(todo.status, "mt-0.5")}
|
||||
<span className="break-words text-inherit">
|
||||
{todo.content}
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{metaOpen === "files" && (
|
||||
<div className="mb-6">
|
||||
<FilesPopover
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
editDisabled={
|
||||
isLoading === true || interrupt !== undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={isLoading ? "Running..." : "Write your message..."}
|
||||
className="font-inherit field-sizing-content flex-1 resize-none border-0 bg-transparent px-[18px] pb-[13px] pt-[14px] text-sm leading-7 text-primary outline-none placeholder:text-tertiary"
|
||||
rows={1}
|
||||
/>
|
||||
<div className="flex justify-between gap-2 p-3">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type={isLoading ? "button" : "submit"}
|
||||
variant={isLoading ? "destructive" : "default"}
|
||||
onClick={isLoading ? stopStream : handleSubmit}
|
||||
disabled={!isLoading && (submitDisabled || !input.trim())}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Square size={14} />
|
||||
<span>Stop</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowUp size={18} />
|
||||
<span>Send</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ChatInterface.displayName = "ChatInterface";
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState, useCallback } from "react";
|
||||
import { SubAgentIndicator } from "@/app/components/SubAgentIndicator";
|
||||
import { ToolCallBox } from "@/app/components/ToolCallBox";
|
||||
import { MarkdownContent } from "@/app/components/MarkdownContent";
|
||||
import type {
|
||||
SubAgent,
|
||||
ToolCall,
|
||||
ActionRequest,
|
||||
ReviewConfig,
|
||||
} from "@/app/types/types";
|
||||
import { Message } from "@langchain/langgraph-sdk";
|
||||
import {
|
||||
extractSubAgentContent,
|
||||
extractStringFromMessageContent,
|
||||
} from "@/app/utils/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: Message;
|
||||
toolCalls: ToolCall[];
|
||||
isLoading?: boolean;
|
||||
actionRequestsMap?: Map<string, ActionRequest>;
|
||||
reviewConfigsMap?: Map<string, ReviewConfig>;
|
||||
ui?: any[];
|
||||
stream?: any;
|
||||
onResumeInterrupt?: (value: any) => void;
|
||||
graphId?: string;
|
||||
}
|
||||
|
||||
export const ChatMessage = React.memo<ChatMessageProps>(
|
||||
({
|
||||
message,
|
||||
toolCalls,
|
||||
isLoading,
|
||||
actionRequestsMap,
|
||||
reviewConfigsMap,
|
||||
ui,
|
||||
stream,
|
||||
onResumeInterrupt,
|
||||
graphId,
|
||||
}) => {
|
||||
const isUser = message.type === "human";
|
||||
const messageContent = extractStringFromMessageContent(message);
|
||||
const hasContent = messageContent && messageContent.trim() !== "";
|
||||
const hasToolCalls = toolCalls.length > 0;
|
||||
const subAgents = useMemo(() => {
|
||||
return toolCalls
|
||||
.filter((toolCall: ToolCall) => {
|
||||
return (
|
||||
toolCall.name === "task" &&
|
||||
toolCall.args["subagent_type"] &&
|
||||
toolCall.args["subagent_type"] !== "" &&
|
||||
toolCall.args["subagent_type"] !== null
|
||||
);
|
||||
})
|
||||
.map((toolCall: ToolCall) => {
|
||||
const subagentType = (toolCall.args as Record<string, unknown>)[
|
||||
"subagent_type"
|
||||
] as string;
|
||||
return {
|
||||
id: toolCall.id,
|
||||
name: toolCall.name,
|
||||
subAgentName: subagentType,
|
||||
input: toolCall.args,
|
||||
output: toolCall.result ? { result: toolCall.result } : undefined,
|
||||
status: toolCall.status,
|
||||
} as SubAgent;
|
||||
});
|
||||
}, [toolCalls]);
|
||||
|
||||
const [expandedSubAgents, setExpandedSubAgents] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const isSubAgentExpanded = useCallback(
|
||||
(id: string) => expandedSubAgents[id] ?? true,
|
||||
[expandedSubAgents]
|
||||
);
|
||||
const toggleSubAgent = useCallback((id: string) => {
|
||||
setExpandedSubAgents((prev) => ({
|
||||
...prev,
|
||||
[id]: prev[id] === undefined ? false : !prev[id],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full max-w-full overflow-x-hidden",
|
||||
isUser && "flex-row-reverse"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 max-w-full",
|
||||
isUser ? "max-w-[70%]" : "w-full"
|
||||
)}
|
||||
>
|
||||
{hasContent && (
|
||||
<div className={cn("relative flex items-end gap-0")}>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 overflow-hidden break-words text-sm font-normal leading-[150%]",
|
||||
isUser
|
||||
? "rounded-xl rounded-br-none border border-border px-3 py-2 text-foreground"
|
||||
: "text-primary"
|
||||
)}
|
||||
style={
|
||||
isUser
|
||||
? { backgroundColor: "var(--color-user-message-bg)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isUser ? (
|
||||
<p className="m-0 whitespace-pre-wrap break-words text-sm leading-relaxed">
|
||||
{messageContent}
|
||||
</p>
|
||||
) : hasContent ? (
|
||||
<MarkdownContent content={messageContent} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasToolCalls && (
|
||||
<div className="mt-4 flex w-full flex-col">
|
||||
{toolCalls.map((toolCall: ToolCall) => {
|
||||
if (toolCall.name === "task") return null;
|
||||
const toolCallGenUiComponent = ui?.find(
|
||||
(u) => u.metadata?.tool_call_id === toolCall.id
|
||||
);
|
||||
const actionRequest = actionRequestsMap?.get(toolCall.name);
|
||||
const reviewConfig = reviewConfigsMap?.get(toolCall.name);
|
||||
return (
|
||||
<ToolCallBox
|
||||
key={toolCall.id}
|
||||
toolCall={toolCall}
|
||||
uiComponent={toolCallGenUiComponent}
|
||||
stream={stream}
|
||||
graphId={graphId}
|
||||
actionRequest={actionRequest}
|
||||
reviewConfig={reviewConfig}
|
||||
onResume={onResumeInterrupt}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!isUser && subAgents.length > 0 && (
|
||||
<div className="flex w-fit max-w-full flex-col gap-4">
|
||||
{subAgents.map((subAgent) => (
|
||||
<div
|
||||
key={subAgent.id}
|
||||
className="flex w-full flex-col gap-2"
|
||||
>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="w-[calc(100%-100px)]">
|
||||
<SubAgentIndicator
|
||||
subAgent={subAgent}
|
||||
onClick={() => toggleSubAgent(subAgent.id)}
|
||||
isExpanded={isSubAgentExpanded(subAgent.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isSubAgentExpanded(subAgent.id) && (
|
||||
<div className="w-full max-w-full">
|
||||
<div className="bg-surface border-border-light rounded-md border p-4">
|
||||
<h4 className="text-primary/70 mb-2 text-xs font-semibold uppercase tracking-wider">
|
||||
Input
|
||||
</h4>
|
||||
<div className="mb-4">
|
||||
<MarkdownContent
|
||||
content={extractSubAgentContent(subAgent.input)}
|
||||
/>
|
||||
</div>
|
||||
{subAgent.output && (
|
||||
<>
|
||||
<h4 className="text-primary/70 mb-2 text-xs font-semibold uppercase tracking-wider">
|
||||
Output
|
||||
</h4>
|
||||
<MarkdownContent
|
||||
content={extractSubAgentContent(subAgent.output)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ChatMessage.displayName = "ChatMessage";
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { StandaloneConfig } from "@/lib/config";
|
||||
|
||||
interface ConfigDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (config: StandaloneConfig) => void;
|
||||
initialConfig?: StandaloneConfig;
|
||||
}
|
||||
|
||||
export function ConfigDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
initialConfig,
|
||||
}: ConfigDialogProps) {
|
||||
const [deploymentUrl, setDeploymentUrl] = useState(
|
||||
initialConfig?.deploymentUrl ||
|
||||
process.env.NEXT_PUBLIC_DEPLOYMENT_URL ||
|
||||
""
|
||||
);
|
||||
const [assistantId, setAssistantId] = useState(
|
||||
initialConfig?.assistantId || process.env.NEXT_PUBLIC_ASSISTANT_ID || ""
|
||||
);
|
||||
const [langsmithApiKey, setLangsmithApiKey] = useState(
|
||||
initialConfig?.langsmithApiKey ||
|
||||
process.env.NEXT_PUBLIC_LANGSMITH_API_KEY ||
|
||||
""
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && initialConfig) {
|
||||
setDeploymentUrl(initialConfig.deploymentUrl);
|
||||
setAssistantId(initialConfig.assistantId);
|
||||
setLangsmithApiKey(initialConfig.langsmithApiKey || "");
|
||||
}
|
||||
}, [open, initialConfig]);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!deploymentUrl || !assistantId) {
|
||||
alert("Please fill in all required fields");
|
||||
return;
|
||||
}
|
||||
|
||||
onSave({
|
||||
deploymentUrl,
|
||||
assistantId,
|
||||
langsmithApiKey: langsmithApiKey || undefined,
|
||||
});
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[525px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configuration</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure your LangGraph deployment settings. These settings are
|
||||
saved in your browser's local storage.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="deploymentUrl">Deployment URL</Label>
|
||||
<Input
|
||||
id="deploymentUrl"
|
||||
placeholder="https://<deployment-url>"
|
||||
value={deploymentUrl}
|
||||
onChange={(e) => setDeploymentUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="assistantId">Assistant ID</Label>
|
||||
<Input
|
||||
id="assistantId"
|
||||
placeholder="<assistant-id>"
|
||||
value={assistantId}
|
||||
onChange={(e) => setAssistantId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="langsmithApiKey">
|
||||
LangSmith API Key{" "}
|
||||
<span className="text-muted-foreground">(Optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="langsmithApiKey"
|
||||
type="password"
|
||||
placeholder="lsv2_pt_..."
|
||||
value={langsmithApiKey}
|
||||
onChange={(e) => setLangsmithApiKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useCallback, useState, useEffect } from "react";
|
||||
import { FileText, Copy, Download, Edit, Save, X, Loader2 } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { toast } from "sonner";
|
||||
import { MarkdownContent } from "@/app/components/MarkdownContent";
|
||||
import type { FileItem } from "@/app/types/types";
|
||||
import useSWRMutation from "swr/mutation";
|
||||
|
||||
const LANGUAGE_MAP: Record<string, string> = {
|
||||
js: "javascript",
|
||||
jsx: "javascript",
|
||||
ts: "typescript",
|
||||
tsx: "typescript",
|
||||
py: "python",
|
||||
rb: "ruby",
|
||||
go: "go",
|
||||
rs: "rust",
|
||||
java: "java",
|
||||
cpp: "cpp",
|
||||
c: "c",
|
||||
cs: "csharp",
|
||||
php: "php",
|
||||
swift: "swift",
|
||||
kt: "kotlin",
|
||||
scala: "scala",
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
zsh: "bash",
|
||||
json: "json",
|
||||
xml: "xml",
|
||||
html: "html",
|
||||
css: "css",
|
||||
scss: "scss",
|
||||
sass: "sass",
|
||||
less: "less",
|
||||
sql: "sql",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
toml: "toml",
|
||||
ini: "ini",
|
||||
dockerfile: "dockerfile",
|
||||
makefile: "makefile",
|
||||
};
|
||||
|
||||
export const FileViewDialog = React.memo<{
|
||||
file: FileItem | null;
|
||||
onSaveFile: (fileName: string, content: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
editDisabled: boolean;
|
||||
}>(({ file, onSaveFile, onClose, editDisabled }) => {
|
||||
const [isEditingMode, setIsEditingMode] = useState(file === null);
|
||||
const [fileName, setFileName] = useState(String(file?.path || ""));
|
||||
const [fileContent, setFileContent] = useState(String(file?.content || ""));
|
||||
|
||||
const fileUpdate = useSWRMutation(
|
||||
{ kind: "files-update", fileName, fileContent },
|
||||
async ({ fileName, fileContent }) => {
|
||||
if (!fileName || !fileContent) return;
|
||||
return await onSaveFile(fileName, fileContent);
|
||||
},
|
||||
{
|
||||
onSuccess: () => setIsEditingMode(false),
|
||||
onError: (error) => toast.error(`Failed to save file: ${error}`),
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setFileName(String(file?.path || ""));
|
||||
setFileContent(String(file?.content || ""));
|
||||
setIsEditingMode(file === null);
|
||||
}, [file]);
|
||||
|
||||
const fileExtension = useMemo(() => {
|
||||
const fileNameStr = String(fileName || "");
|
||||
return fileNameStr.split(".").pop()?.toLowerCase() || "";
|
||||
}, [fileName]);
|
||||
|
||||
const isMarkdown = useMemo(() => {
|
||||
return fileExtension === "md" || fileExtension === "markdown";
|
||||
}, [fileExtension]);
|
||||
|
||||
const language = useMemo(() => {
|
||||
return LANGUAGE_MAP[fileExtension] || "text";
|
||||
}, [fileExtension]);
|
||||
|
||||
const handleCopy = useCallback(() => {
|
||||
if (fileContent) {
|
||||
navigator.clipboard.writeText(fileContent);
|
||||
}
|
||||
}, [fileContent]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
if (fileContent && fileName) {
|
||||
const blob = new Blob([fileContent], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}, [fileContent, fileName]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
setIsEditingMode(true);
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (file === null) {
|
||||
onClose();
|
||||
} else {
|
||||
setFileName(String(file.path));
|
||||
setFileContent(String(file.content));
|
||||
setIsEditingMode(false);
|
||||
}
|
||||
}, [file, onClose]);
|
||||
|
||||
const fileNameIsValid = useMemo(() => {
|
||||
return (
|
||||
fileName.trim() !== "" &&
|
||||
!fileName.includes("/") &&
|
||||
!fileName.includes(" ")
|
||||
);
|
||||
}, [fileName]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={true}
|
||||
onOpenChange={onClose}
|
||||
>
|
||||
<DialogContent className="flex h-[80vh] max-h-[80vh] min-w-[60vw] flex-col p-6">
|
||||
<DialogTitle className="sr-only">
|
||||
{file?.path || "New File"}
|
||||
</DialogTitle>
|
||||
<div className="mb-4 flex items-center justify-between border-b border-border pb-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<FileText className="text-primary/50 h-5 w-5 shrink-0" />
|
||||
{isEditingMode && file === null ? (
|
||||
<Input
|
||||
value={fileName}
|
||||
onChange={(e) => setFileName(e.target.value)}
|
||||
placeholder="Enter filename..."
|
||||
className="text-base font-medium"
|
||||
aria-invalid={!fileNameIsValid}
|
||||
/>
|
||||
) : (
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-base font-medium text-primary">
|
||||
{file?.path}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{!isEditingMode && (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleEdit}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
disabled={editDisabled}
|
||||
>
|
||||
<Edit
|
||||
size={16}
|
||||
className="mr-1"
|
||||
/>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCopy}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<Copy
|
||||
size={16}
|
||||
className="mr-1"
|
||||
/>
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDownload}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2"
|
||||
>
|
||||
<Download
|
||||
size={16}
|
||||
className="mr-1"
|
||||
/>
|
||||
Download
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{isEditingMode ? (
|
||||
<Textarea
|
||||
value={fileContent}
|
||||
onChange={(e) => setFileContent(e.target.value)}
|
||||
placeholder="Enter file content..."
|
||||
className="h-full min-h-[400px] resize-none font-mono text-sm"
|
||||
/>
|
||||
) : (
|
||||
<ScrollArea className="bg-surface h-full rounded-md">
|
||||
<div className="p-4">
|
||||
{fileContent ? (
|
||||
isMarkdown ? (
|
||||
<div className="rounded-md p-6">
|
||||
<MarkdownContent content={fileContent} />
|
||||
</div>
|
||||
) : (
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={oneDark}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
borderRadius: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
showLineNumbers
|
||||
wrapLines={true}
|
||||
lineProps={{
|
||||
style: {
|
||||
whiteSpace: "pre-wrap",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{fileContent}
|
||||
</SyntaxHighlighter>
|
||||
)
|
||||
) : (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
File is empty
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
{isEditingMode && (
|
||||
<div className="mt-4 flex justify-end gap-2 border-t border-border pt-4">
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<X
|
||||
size={16}
|
||||
className="mr-1"
|
||||
/>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => fileUpdate.trigger()}
|
||||
size="sm"
|
||||
disabled={
|
||||
fileUpdate.isMutating ||
|
||||
!fileName.trim() ||
|
||||
!fileContent.trim() ||
|
||||
!fileNameIsValid
|
||||
}
|
||||
>
|
||||
{fileUpdate.isMutating ? (
|
||||
<Loader2
|
||||
size={16}
|
||||
className="mr-1 animate-spin"
|
||||
/>
|
||||
) : (
|
||||
<Save
|
||||
size={16}
|
||||
className="mr-1"
|
||||
/>
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
});
|
||||
|
||||
FileViewDialog.displayName = "FileViewDialog";
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const MarkdownContent = React.memo<MarkdownContentProps>(
|
||||
({ content, className = "" }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"prose min-w-0 max-w-full overflow-hidden break-words text-sm leading-relaxed text-inherit [&_h1:first-child]:mt-0 [&_h1]:mb-4 [&_h1]:mt-6 [&_h1]:font-semibold [&_h2:first-child]:mt-0 [&_h2]:mb-4 [&_h2]:mt-6 [&_h2]:font-semibold [&_h3:first-child]:mt-0 [&_h3]:mb-4 [&_h3]:mt-6 [&_h3]:font-semibold [&_h4:first-child]:mt-0 [&_h4]:mb-4 [&_h4]:mt-6 [&_h4]:font-semibold [&_h5:first-child]:mt-0 [&_h5]:mb-4 [&_h5]:mt-6 [&_h5]:font-semibold [&_h6:first-child]:mt-0 [&_h6]:mb-4 [&_h6]:mt-6 [&_h6]:font-semibold [&_p:last-child]:mb-0 [&_p]:mb-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code({
|
||||
inline,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
return !inline && match ? (
|
||||
<SyntaxHighlighter
|
||||
style={oneDark}
|
||||
language={match[1]}
|
||||
PreTag="div"
|
||||
className="max-w-full rounded-md text-sm"
|
||||
wrapLines={true}
|
||||
wrapLongLines={true}
|
||||
lineProps={{
|
||||
style: {
|
||||
wordBreak: "break-all",
|
||||
whiteSpace: "pre-wrap",
|
||||
overflowWrap: "break-word",
|
||||
},
|
||||
}}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
maxWidth: "100%",
|
||||
overflowX: "auto",
|
||||
fontSize: "0.875rem",
|
||||
}}
|
||||
>
|
||||
{String(children).replace(/\n$/, "")}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code
|
||||
className="bg-surface rounded-sm px-1 py-0.5 font-mono text-[0.9em]"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="my-4 max-w-full overflow-hidden last:mb-0">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
a({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href?: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary no-underline hover:underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
blockquote({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<blockquote className="text-primary/50 my-4 border-l-4 border-border pl-4 italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
},
|
||||
ul({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<ul className="my-4 pl-6 [&>li:last-child]:mb-0 [&>li]:mb-1">
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
},
|
||||
ol({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<ol className="my-4 pl-6 [&>li:last-child]:mb-0 [&>li]:mb-1">
|
||||
{children}
|
||||
</ol>
|
||||
);
|
||||
},
|
||||
table({ children }: { children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="my-4 overflow-x-auto">
|
||||
<table className="[&_th]:bg-surface w-full border-collapse [&_td]:border [&_td]:border-border [&_td]:p-2 [&_th]:border [&_th]:border-border [&_th]:p-2 [&_th]:text-left [&_th]:font-semibold">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
MarkdownContent.displayName = "MarkdownContent";
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { SubAgent } from "@/app/types/types";
|
||||
|
||||
interface SubAgentIndicatorProps {
|
||||
subAgent: SubAgent;
|
||||
onClick: () => void;
|
||||
isExpanded?: boolean;
|
||||
}
|
||||
|
||||
export const SubAgentIndicator = React.memo<SubAgentIndicatorProps>(
|
||||
({ subAgent, onClick, isExpanded = true }) => {
|
||||
return (
|
||||
<div className="w-fit max-w-[70vw] overflow-hidden rounded-lg border-none bg-card shadow-none outline-none">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClick}
|
||||
className="flex w-full items-center justify-between gap-2 border-none px-4 py-2 text-left shadow-none outline-none transition-colors duration-200"
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-sans text-[15px] font-bold leading-[140%] tracking-[-0.6px] text-[#3F3F46]">
|
||||
{subAgent.subAgentName}
|
||||
</span>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp
|
||||
size={14}
|
||||
className="shrink-0 text-[#70707B]"
|
||||
/>
|
||||
) : (
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="shrink-0 text-[#70707B]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SubAgentIndicator.displayName = "SubAgentIndicator";
|
||||
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import React, {
|
||||
useMemo,
|
||||
useCallback,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import {
|
||||
FileText,
|
||||
CheckCircle,
|
||||
Circle,
|
||||
Clock,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import type { TodoItem, FileItem } from "@/app/types/types";
|
||||
import { useChatContext } from "@/providers/ChatProvider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { FileViewDialog } from "@/app/components/FileViewDialog";
|
||||
|
||||
export function FilesPopover({
|
||||
files,
|
||||
setFiles,
|
||||
editDisabled,
|
||||
}: {
|
||||
files: Record<string, string>;
|
||||
setFiles: (files: Record<string, string>) => Promise<void>;
|
||||
editDisabled: boolean;
|
||||
}) {
|
||||
const [selectedFile, setSelectedFile] = useState<FileItem | null>(null);
|
||||
|
||||
const handleSaveFile = useCallback(
|
||||
async (fileName: string, content: string) => {
|
||||
await setFiles({ ...files, [fileName]: content });
|
||||
setSelectedFile({ path: fileName, content: content });
|
||||
},
|
||||
[files, setFiles]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.keys(files).length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center p-4 text-center">
|
||||
<p className="text-xs text-muted-foreground">No files created yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(256px,1fr))] gap-2">
|
||||
{Object.keys(files).map((file) => {
|
||||
const filePath = String(file);
|
||||
const rawContent = files[file];
|
||||
let fileContent: string;
|
||||
if (
|
||||
typeof rawContent === "object" &&
|
||||
rawContent !== null &&
|
||||
"content" in rawContent
|
||||
) {
|
||||
const contentArray = (rawContent as { content: unknown }).content;
|
||||
if (Array.isArray(contentArray)) {
|
||||
fileContent = contentArray.join("\n");
|
||||
} else {
|
||||
fileContent = String(contentArray || "");
|
||||
}
|
||||
} else {
|
||||
fileContent = String(rawContent || "");
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={filePath}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSelectedFile({ path: filePath, content: fileContent })
|
||||
}
|
||||
className="cursor-pointer space-y-1 truncate rounded-md border border-border px-2 py-3 shadow-sm transition-colors"
|
||||
style={{
|
||||
backgroundColor: "var(--color-file-button)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor =
|
||||
"var(--color-file-button-hover)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor =
|
||||
"var(--color-file-button)";
|
||||
}}
|
||||
>
|
||||
<FileText
|
||||
size={24}
|
||||
className="mx-auto text-muted-foreground"
|
||||
/>
|
||||
<span className="mx-auto block w-full truncate break-words text-center text-sm leading-relaxed text-foreground">
|
||||
{filePath}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedFile && (
|
||||
<FileViewDialog
|
||||
file={selectedFile}
|
||||
onSaveFile={handleSaveFile}
|
||||
onClose={() => setSelectedFile(null)}
|
||||
editDisabled={editDisabled}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const TasksFilesSidebar = React.memo<{
|
||||
todos: TodoItem[];
|
||||
files: Record<string, string>;
|
||||
setFiles: (files: Record<string, string>) => Promise<void>;
|
||||
}>(({ todos, files, setFiles }) => {
|
||||
const { isLoading, interrupt } = useChatContext();
|
||||
const [tasksOpen, setTasksOpen] = useState(false);
|
||||
const [filesOpen, setFilesOpen] = useState(false);
|
||||
|
||||
// Track previous counts to detect when content goes from empty to having items
|
||||
const prevTodosCount = useRef(todos.length);
|
||||
const prevFilesCount = useRef(Object.keys(files).length);
|
||||
|
||||
// Auto-expand when todos go from empty to having content
|
||||
useEffect(() => {
|
||||
if (prevTodosCount.current === 0 && todos.length > 0) {
|
||||
setTasksOpen(true);
|
||||
}
|
||||
prevTodosCount.current = todos.length;
|
||||
}, [todos.length]);
|
||||
|
||||
// Auto-expand when files go from empty to having content
|
||||
const filesCount = Object.keys(files).length;
|
||||
useEffect(() => {
|
||||
if (prevFilesCount.current === 0 && filesCount > 0) {
|
||||
setFilesOpen(true);
|
||||
}
|
||||
prevFilesCount.current = filesCount;
|
||||
}, [filesCount]);
|
||||
|
||||
const getStatusIcon = useCallback((status: TodoItem["status"]) => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return (
|
||||
<CheckCircle
|
||||
size={12}
|
||||
className="text-success/80"
|
||||
/>
|
||||
);
|
||||
case "in_progress":
|
||||
return (
|
||||
<Clock
|
||||
size={12}
|
||||
className="text-warning/80"
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Circle
|
||||
size={10}
|
||||
className="text-tertiary/70"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const groupedTodos = useMemo(() => {
|
||||
return {
|
||||
pending: todos.filter((t) => t.status === "pending"),
|
||||
in_progress: todos.filter((t) => t.status === "in_progress"),
|
||||
completed: todos.filter((t) => t.status === "completed"),
|
||||
};
|
||||
}, [todos]);
|
||||
|
||||
const groupedLabels = {
|
||||
pending: "Pending",
|
||||
in_progress: "In Progress",
|
||||
completed: "Completed",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-0 w-full flex-1">
|
||||
<div className="font-inter flex h-full w-full flex-col p-0">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 pb-1.5 pt-2">
|
||||
<span className="text-xs font-semibold tracking-wide text-zinc-600">
|
||||
AGENT TASKS
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setTasksOpen((v) => !v)}
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-transform duration-200 hover:bg-muted",
|
||||
tasksOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
aria-label="Toggle tasks panel"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{tasksOpen && (
|
||||
<div className="bg-muted-secondary rounded-xl px-3 pb-2">
|
||||
<ScrollArea className="h-full">
|
||||
{todos.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center p-4 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No tasks created yet
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ml-1 p-0.5">
|
||||
{Object.entries(groupedTodos).map(([status, todos]) => (
|
||||
<div className="mb-4">
|
||||
<h3 className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-tertiary">
|
||||
{groupedLabels[status as keyof typeof groupedLabels]}
|
||||
</h3>
|
||||
{todos.map((todo, index) => (
|
||||
<div
|
||||
key={`${status}_${todo.id}_${index}`}
|
||||
className="mb-1.5 flex items-start gap-2 rounded-sm p-1 text-sm"
|
||||
>
|
||||
{getStatusIcon(todo.status)}
|
||||
<span className="flex-1 break-words leading-relaxed text-inherit">
|
||||
{todo.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between px-3 pb-1.5 pt-2">
|
||||
<span className="text-xs font-semibold tracking-wide text-zinc-600">
|
||||
FILE SYSTEM
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setFilesOpen((v) => !v)}
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-transform duration-200 hover:bg-muted",
|
||||
filesOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
aria-label="Toggle files panel"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{filesOpen && (
|
||||
<FilesPopover
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
editDisabled={isLoading === true || interrupt !== undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TasksFilesSidebar.displayName = "TasksFilesSidebar";
|
||||
@@ -0,0 +1,369 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { Loader2, MessageSquare, X } from "lucide-react";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ThreadItem } from "@/app/hooks/useThreads";
|
||||
import { useThreads } from "@/app/hooks/useThreads";
|
||||
|
||||
type StatusFilter = "all" | "idle" | "busy" | "interrupted" | "error";
|
||||
|
||||
const GROUP_LABELS = {
|
||||
interrupted: "Requiring Attention",
|
||||
today: "Today",
|
||||
yesterday: "Yesterday",
|
||||
week: "This Week",
|
||||
older: "Older",
|
||||
} as const;
|
||||
|
||||
const STATUS_COLORS: Record<ThreadItem["status"], string> = {
|
||||
idle: "bg-green-500",
|
||||
busy: "bg-blue-500",
|
||||
interrupted: "bg-orange-500",
|
||||
error: "bg-red-600",
|
||||
};
|
||||
|
||||
function getThreadColor(status: ThreadItem["status"]): string {
|
||||
return STATUS_COLORS[status] ?? "bg-gray-400";
|
||||
}
|
||||
|
||||
function formatTime(date: Date, now = new Date()): string {
|
||||
const diff = now.getTime() - date.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return format(date, "HH:mm");
|
||||
if (days === 1) return "Yesterday";
|
||||
if (days < 7) return format(date, "EEEE");
|
||||
return format(date, "MM/dd");
|
||||
}
|
||||
|
||||
function StatusFilterItem({
|
||||
status,
|
||||
label,
|
||||
badge,
|
||||
}: {
|
||||
status: ThreadItem["status"];
|
||||
label: string;
|
||||
badge?: number;
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block size-2 rounded-full",
|
||||
getThreadColor(status)
|
||||
)}
|
||||
/>
|
||||
{label}
|
||||
{badge !== undefined && badge > 0 && (
|
||||
<span className="ml-1 inline-flex items-center justify-center rounded-full bg-red-600 px-1.5 py-0.5 text-xs font-bold leading-none text-white">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorState({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<p className="text-sm text-red-600">Failed to load threads</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingState() {
|
||||
return (
|
||||
<div className="space-y-2 p-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className="h-16 w-full"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<MessageSquare className="mb-2 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm text-muted-foreground">No threads found</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ThreadListProps {
|
||||
onThreadSelect: (id: string) => void;
|
||||
onMutateReady?: (mutate: () => void) => void;
|
||||
onClose?: () => void;
|
||||
onInterruptCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
export function ThreadList({
|
||||
onThreadSelect,
|
||||
onMutateReady,
|
||||
onClose,
|
||||
onInterruptCountChange,
|
||||
}: ThreadListProps) {
|
||||
const [currentThreadId] = useQueryState("threadId");
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
|
||||
const threads = useThreads({
|
||||
status: statusFilter === "all" ? undefined : statusFilter,
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
const flattened = useMemo(() => {
|
||||
return threads.data?.flat() ?? [];
|
||||
}, [threads.data]);
|
||||
|
||||
const isLoadingMore =
|
||||
threads.size > 0 && threads.data?.[threads.size - 1] == null;
|
||||
const isEmpty = threads.data?.at(0)?.length === 0;
|
||||
const isReachingEnd = isEmpty || (threads.data?.at(-1)?.length ?? 0) < 20;
|
||||
|
||||
// Group threads by time and status
|
||||
const grouped = useMemo(() => {
|
||||
const now = new Date();
|
||||
const groups: Record<keyof typeof GROUP_LABELS, ThreadItem[]> = {
|
||||
interrupted: [],
|
||||
today: [],
|
||||
yesterday: [],
|
||||
week: [],
|
||||
older: [],
|
||||
};
|
||||
|
||||
flattened.forEach((thread) => {
|
||||
if (thread.status === "interrupted") {
|
||||
groups.interrupted.push(thread);
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = now.getTime() - thread.updatedAt.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) {
|
||||
groups.today.push(thread);
|
||||
} else if (days === 1) {
|
||||
groups.yesterday.push(thread);
|
||||
} else if (days < 7) {
|
||||
groups.week.push(thread);
|
||||
} else {
|
||||
groups.older.push(thread);
|
||||
}
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [flattened]);
|
||||
|
||||
const interruptedCount = useMemo(() => {
|
||||
return flattened.filter((t) => t.status === "interrupted").length;
|
||||
}, [flattened]);
|
||||
|
||||
// Expose thread list revalidation to parent component
|
||||
// Use refs to create a stable callback that always calls the latest mutate function
|
||||
const onMutateReadyRef = useRef(onMutateReady);
|
||||
const mutateRef = useRef(threads.mutate);
|
||||
|
||||
useEffect(() => {
|
||||
onMutateReadyRef.current = onMutateReady;
|
||||
}, [onMutateReady]);
|
||||
|
||||
useEffect(() => {
|
||||
mutateRef.current = threads.mutate;
|
||||
}, [threads.mutate]);
|
||||
|
||||
const mutateFn = useCallback(() => {
|
||||
mutateRef.current();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
onMutateReadyRef.current?.(mutateFn);
|
||||
// Only run once on mount to avoid infinite loops
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Notify parent of interrupt count changes
|
||||
useEffect(() => {
|
||||
onInterruptCountChange?.(interruptedCount);
|
||||
}, [interruptedCount, onInterruptCountChange]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
{/* Header with title, filter, and close button */}
|
||||
<div className="grid flex-shrink-0 grid-cols-[1fr_auto] items-center gap-3 border-b border-border p-4">
|
||||
<h2 className="text-lg font-semibold tracking-tight">Threads</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(v) => setStatusFilter(v as StatusFilter)}
|
||||
>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="all">All statuses</SelectItem>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Active</SelectLabel>
|
||||
<SelectItem value="idle">
|
||||
<StatusFilterItem
|
||||
status="idle"
|
||||
label="Idle"
|
||||
/>
|
||||
</SelectItem>
|
||||
<SelectItem value="busy">
|
||||
<StatusFilterItem
|
||||
status="busy"
|
||||
label="Busy"
|
||||
/>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>Attention</SelectLabel>
|
||||
<SelectItem value="interrupted">
|
||||
<StatusFilterItem
|
||||
status="interrupted"
|
||||
label="Interrupted"
|
||||
badge={interruptedCount}
|
||||
/>
|
||||
</SelectItem>
|
||||
<SelectItem value="error">
|
||||
<StatusFilterItem
|
||||
status="error"
|
||||
label="Error"
|
||||
/>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8"
|
||||
aria-label="Close threads sidebar"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-0 flex-1">
|
||||
{threads.error && <ErrorState message={threads.error.message} />}
|
||||
|
||||
{!threads.error && !threads.data && threads.isLoading && (
|
||||
<LoadingState />
|
||||
)}
|
||||
|
||||
{!threads.error && !threads.isLoading && isEmpty && <EmptyState />}
|
||||
|
||||
{!threads.error && !isEmpty && (
|
||||
<div className="box-border w-full max-w-full overflow-hidden p-2">
|
||||
{(
|
||||
Object.keys(GROUP_LABELS) as Array<keyof typeof GROUP_LABELS>
|
||||
).map((group) => {
|
||||
const groupThreads = grouped[group];
|
||||
if (groupThreads.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={group}
|
||||
className="mb-4"
|
||||
>
|
||||
<h4 className="m-0 px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{GROUP_LABELS[group]}
|
||||
</h4>
|
||||
<div className="flex flex-col gap-1">
|
||||
{groupThreads.map((thread) => (
|
||||
<button
|
||||
key={thread.id}
|
||||
type="button"
|
||||
onClick={() => onThreadSelect(thread.id)}
|
||||
className={cn(
|
||||
"grid w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-3 text-left transition-colors duration-200",
|
||||
"hover:bg-accent",
|
||||
currentThreadId === thread.id
|
||||
? "border border-primary bg-accent hover:bg-accent"
|
||||
: "border border-transparent bg-transparent"
|
||||
)}
|
||||
aria-current={currentThreadId === thread.id}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Title + Timestamp Row */}
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<h3 className="truncate text-sm font-semibold">
|
||||
{thread.title}
|
||||
</h3>
|
||||
<span className="ml-2 flex-shrink-0 text-xs text-muted-foreground">
|
||||
{formatTime(thread.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Description + Status Row */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="flex-1 truncate text-sm text-muted-foreground">
|
||||
{thread.description}
|
||||
</p>
|
||||
<div className="ml-2 flex-shrink-0">
|
||||
<div
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
getThreadColor(thread.status)
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{!isReachingEnd && (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => threads.setSize(threads.size + 1)}
|
||||
disabled={isLoadingMore}
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
"Load More"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AlertCircle, Check, X, Pencil } from "lucide-react";
|
||||
import type { ActionRequest, ReviewConfig } from "@/app/types/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ToolApprovalInterruptProps {
|
||||
actionRequest: ActionRequest;
|
||||
reviewConfig?: ReviewConfig;
|
||||
onResume: (value: any) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ToolApprovalInterrupt({
|
||||
actionRequest,
|
||||
reviewConfig,
|
||||
onResume,
|
||||
isLoading,
|
||||
}: ToolApprovalInterruptProps) {
|
||||
const [rejectionMessage, setRejectionMessage] = useState("");
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editedArgs, setEditedArgs] = useState<Record<string, unknown>>({});
|
||||
const [showRejectionInput, setShowRejectionInput] = useState(false);
|
||||
|
||||
const allowedDecisions = reviewConfig?.allowedDecisions ?? [
|
||||
"approve",
|
||||
"reject",
|
||||
"edit",
|
||||
];
|
||||
|
||||
const handleApprove = () => {
|
||||
onResume({
|
||||
decisions: [{ type: "approve" }],
|
||||
});
|
||||
};
|
||||
|
||||
const handleReject = () => {
|
||||
if (showRejectionInput) {
|
||||
onResume({
|
||||
decisions: [
|
||||
{
|
||||
type: "reject",
|
||||
message: rejectionMessage.trim(),
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
setShowRejectionInput(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectConfirm = () => {
|
||||
onResume({
|
||||
decisions: [
|
||||
{
|
||||
type: "reject",
|
||||
message: rejectionMessage.trim(),
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
if (isEditing) {
|
||||
onResume({
|
||||
decisions: [
|
||||
{
|
||||
type: "edit",
|
||||
edited_action: {
|
||||
name: actionRequest.name,
|
||||
args: editedArgs,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
setIsEditing(false);
|
||||
setEditedArgs({});
|
||||
}
|
||||
};
|
||||
|
||||
const startEditing = () => {
|
||||
setIsEditing(true);
|
||||
setEditedArgs(JSON.parse(JSON.stringify(actionRequest.args)));
|
||||
setShowRejectionInput(false);
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setIsEditing(false);
|
||||
setEditedArgs({});
|
||||
};
|
||||
|
||||
const updateEditedArg = (key: string, value: string) => {
|
||||
try {
|
||||
const parsedValue =
|
||||
value.trim().startsWith("{") || value.trim().startsWith("[")
|
||||
? JSON.parse(value)
|
||||
: value;
|
||||
setEditedArgs((prev) => ({ ...prev, [key]: parsedValue }));
|
||||
} catch {
|
||||
setEditedArgs((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-md border border-border bg-muted/30 p-4">
|
||||
{/* Header */}
|
||||
<div className="mb-3 flex items-center gap-2 text-foreground">
|
||||
<AlertCircle
|
||||
size={16}
|
||||
className="text-yellow-600 dark:text-yellow-400"
|
||||
/>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider">
|
||||
Approval Required
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{actionRequest.description && (
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
{actionRequest.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Tool Info Card */}
|
||||
<div className="mb-4 rounded-sm border border-border bg-background p-3">
|
||||
<div className="mb-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Tool
|
||||
</span>
|
||||
<p className="mt-1 font-mono text-sm font-medium text-foreground">
|
||||
{actionRequest.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div>
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Edit Arguments
|
||||
</span>
|
||||
<div className="mt-2 space-y-3">
|
||||
{Object.entries(actionRequest.args).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<label className="mb-1 block text-xs font-medium text-foreground">
|
||||
{key}
|
||||
</label>
|
||||
<Textarea
|
||||
value={
|
||||
editedArgs[key] !== undefined
|
||||
? typeof editedArgs[key] === "string"
|
||||
? (editedArgs[key] as string)
|
||||
: JSON.stringify(editedArgs[key], null, 2)
|
||||
: typeof value === "string"
|
||||
? value
|
||||
: JSON.stringify(value, null, 2)
|
||||
}
|
||||
onChange={(e) => updateEditedArg(key, e.target.value)}
|
||||
className="font-mono text-xs"
|
||||
rows={
|
||||
typeof value === "string" && value.length < 100 ? 2 : 4
|
||||
}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Arguments
|
||||
</span>
|
||||
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-all rounded-sm border border-border bg-muted/40 p-2 font-mono text-xs text-foreground">
|
||||
{JSON.stringify(actionRequest.args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rejection Message Input */}
|
||||
{showRejectionInput && !isEditing && (
|
||||
<div className="mb-4">
|
||||
<label className="mb-2 block text-xs font-medium text-foreground">
|
||||
Rejection Message (optional)
|
||||
</label>
|
||||
<Textarea
|
||||
value={rejectionMessage}
|
||||
onChange={(e) => setRejectionMessage(e.target.value)}
|
||||
placeholder="Explain why you're rejecting this action..."
|
||||
className="text-sm"
|
||||
rows={2}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={cancelEditing}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleEdit}
|
||||
disabled={isLoading}
|
||||
className="bg-green-600 text-white hover:bg-green-700 dark:bg-green-600 dark:hover:bg-green-700"
|
||||
>
|
||||
<Check size={14} />
|
||||
{isLoading ? "Saving..." : "Save & Approve"}
|
||||
</Button>
|
||||
</>
|
||||
) : showRejectionInput ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowRejectionInput(false);
|
||||
setRejectionMessage("");
|
||||
}}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleRejectConfirm}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Rejecting..." : "Confirm Reject"}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{allowedDecisions.includes("reject") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleReject}
|
||||
disabled={isLoading}
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<X size={14} />
|
||||
Reject
|
||||
</Button>
|
||||
)}
|
||||
{allowedDecisions.includes("edit") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={startEditing}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{allowedDecisions.includes("approve") && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleApprove}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
"bg-green-600 text-white hover:bg-green-700",
|
||||
"dark:bg-green-600 dark:hover:bg-green-700"
|
||||
)}
|
||||
>
|
||||
<Check size={14} />
|
||||
{isLoading ? "Approving..." : "Approve"}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useCallback } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Terminal,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
CircleCheckBigIcon,
|
||||
StopCircle,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolCall, ActionRequest, ReviewConfig } from "@/app/types/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
|
||||
import { ToolApprovalInterrupt } from "@/app/components/ToolApprovalInterrupt";
|
||||
|
||||
interface ToolCallBoxProps {
|
||||
toolCall: ToolCall;
|
||||
uiComponent?: any;
|
||||
stream?: any;
|
||||
graphId?: string;
|
||||
actionRequest?: ActionRequest;
|
||||
reviewConfig?: ReviewConfig;
|
||||
onResume?: (value: any) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const ToolCallBox = React.memo<ToolCallBoxProps>(
|
||||
({
|
||||
toolCall,
|
||||
uiComponent,
|
||||
stream,
|
||||
graphId,
|
||||
actionRequest,
|
||||
reviewConfig,
|
||||
onResume,
|
||||
isLoading,
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(
|
||||
() => !!uiComponent || !!actionRequest
|
||||
);
|
||||
const [expandedArgs, setExpandedArgs] = useState<Record<string, boolean>>(
|
||||
{}
|
||||
);
|
||||
|
||||
const { name, args, result, status } = useMemo(() => {
|
||||
return {
|
||||
name: toolCall.name || "Unknown Tool",
|
||||
args: toolCall.args || {},
|
||||
result: toolCall.result,
|
||||
status: toolCall.status || "completed",
|
||||
};
|
||||
}, [toolCall]);
|
||||
|
||||
const statusIcon = useMemo(() => {
|
||||
switch (status) {
|
||||
case "completed":
|
||||
return <CircleCheckBigIcon />;
|
||||
case "error":
|
||||
return (
|
||||
<AlertCircle
|
||||
size={14}
|
||||
className="text-destructive"
|
||||
/>
|
||||
);
|
||||
case "pending":
|
||||
return (
|
||||
<Loader2
|
||||
size={14}
|
||||
className="animate-spin"
|
||||
/>
|
||||
);
|
||||
case "interrupted":
|
||||
return (
|
||||
<StopCircle
|
||||
size={14}
|
||||
className="text-orange-500"
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Terminal
|
||||
size={14}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const toggleExpanded = useCallback(() => {
|
||||
setIsExpanded((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const toggleArgExpanded = useCallback((argKey: string) => {
|
||||
setExpandedArgs((prev) => ({
|
||||
...prev,
|
||||
[argKey]: !prev[argKey],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const hasContent = result || Object.keys(args).length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full overflow-hidden rounded-lg border-none shadow-none outline-none transition-colors duration-200 hover:bg-accent",
|
||||
isExpanded && hasContent && "bg-accent"
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={toggleExpanded}
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 border-none px-2 py-2 text-left shadow-none outline-none focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-default"
|
||||
)}
|
||||
disabled={!hasContent}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{statusIcon}
|
||||
<span className="text-[15px] font-medium tracking-[-0.6px] text-foreground">
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
{hasContent &&
|
||||
(isExpanded ? (
|
||||
<ChevronUp
|
||||
size={14}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
) : (
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{isExpanded && hasContent && (
|
||||
<div className="px-4 pb-4">
|
||||
{uiComponent && stream && graphId ? (
|
||||
<div className="mt-4">
|
||||
<LoadExternalComponent
|
||||
key={uiComponent.id}
|
||||
stream={stream}
|
||||
message={uiComponent}
|
||||
namespace={graphId}
|
||||
meta={{ status, args, result: result ?? "No Result Yet" }}
|
||||
/>
|
||||
</div>
|
||||
) : actionRequest && onResume ? (
|
||||
// Show tool approval UI when there's an action request but no GenUI
|
||||
<div className="mt-4">
|
||||
<ToolApprovalInterrupt
|
||||
actionRequest={actionRequest}
|
||||
reviewConfig={reviewConfig}
|
||||
onResume={onResume}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{Object.keys(args).length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Arguments
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(args).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-sm border border-border"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleArgExpanded(key)}
|
||||
className="flex w-full items-center justify-between bg-muted/30 p-2 text-left text-xs font-medium transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<span className="font-mono">{key}</span>
|
||||
{expandedArgs[key] ? (
|
||||
<ChevronUp
|
||||
size={12}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
) : (
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{expandedArgs[key] && (
|
||||
<div className="border-t border-border bg-muted/20 p-2">
|
||||
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-all font-mono text-xs leading-6 text-foreground">
|
||||
{typeof value === "string"
|
||||
? value
|
||||
: JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
<div className="mt-4">
|
||||
<h4 className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Result
|
||||
</h4>
|
||||
<pre className="m-0 overflow-x-auto whitespace-pre-wrap break-all rounded-sm border border-border bg-muted/40 p-2 font-mono text-xs leading-7 text-foreground">
|
||||
{typeof result === "string"
|
||||
? result
|
||||
: JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ToolCallBox.displayName = "ToolCallBox";
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,395 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
/* Remove default focus box-shadows */
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Set default outline color to match brand instead of browser blue */
|
||||
* {
|
||||
outline-color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
:root {
|
||||
/* App-specific color variables */
|
||||
--color-primary: #1c3c3c;
|
||||
--color-user-message: #076699;
|
||||
--color-user-message-bg: #e8f4f8;
|
||||
--color-avatar-bg: #e8ebeb;
|
||||
--color-secondary: #1c3c3c;
|
||||
--color-success: #10b981;
|
||||
--color-warning: #f59e0b;
|
||||
--color-error: #ef4444;
|
||||
--color-background: #f9f9f9;
|
||||
--color-subagent-hover: #bbc4c4;
|
||||
--color-surface: #f9fafb;
|
||||
--color-border: #e5e7eb;
|
||||
--color-border-light: #f3f4f6;
|
||||
--color-text-primary: #111827;
|
||||
--color-text-secondary: #6b7280;
|
||||
--color-text-tertiary: #9ca3af;
|
||||
--color-file-button: #ffffff;
|
||||
--color-file-button-hover: #e5e7eb;
|
||||
|
||||
/* Dark theme colors */
|
||||
--color-primary-dark: #2dd4bf;
|
||||
--color-user-message-dark: #076699;
|
||||
--color-avatar-bg-dark: #bcb2fd;
|
||||
--color-secondary-dark: #2dd4bf;
|
||||
--color-success-dark: #34d399;
|
||||
--color-warning-dark: #fbbf24;
|
||||
--color-error-dark: #f87171;
|
||||
--color-background-dark: #0f0f0f;
|
||||
--color-subagent-hover-dark: #d0c9fe;
|
||||
--color-surface-dark: #1a1a1a;
|
||||
--color-border-dark: #2d2d2d;
|
||||
--color-border-light-dark: #232323;
|
||||
--color-text-primary-dark: #f3f4f6;
|
||||
--color-text-secondary-dark: #9ca3af;
|
||||
--color-text-tertiary-dark: #6b7280;
|
||||
|
||||
/* Spacing variables */
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
--spacing-2xl: 3rem;
|
||||
|
||||
/* Font family variables */
|
||||
--font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
--font-family-mono: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono",
|
||||
Consolas, "Courier New", monospace;
|
||||
|
||||
/* Font size variables */
|
||||
--font-size-xs: 0.75rem;
|
||||
--font-size-sm: 0.875rem;
|
||||
--font-size-base: 1rem;
|
||||
--font-size-lg: 1.125rem;
|
||||
--font-size-xl: 1.25rem;
|
||||
--font-size-2xl: 1.5rem;
|
||||
--font-size-3xl: 1.875rem;
|
||||
|
||||
/* Font weight variables */
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
|
||||
/* Line height variables */
|
||||
--line-height-tight: 1.25;
|
||||
--line-height-normal: 1.5;
|
||||
--line-height-relaxed: 1.75;
|
||||
|
||||
/* Border radius variables */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadow variables */
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
|
||||
/* Transition variables */
|
||||
--transition-base: 200ms ease;
|
||||
|
||||
/* Layout variables */
|
||||
--sidebar-width: 320px;
|
||||
--sidebar-collapsed-width: 60px;
|
||||
--header-height: 64px;
|
||||
--panel-width: 40vw;
|
||||
--chat-max-width: 900px;
|
||||
|
||||
/* Tailwind/Radix UI component variables */
|
||||
--radius: 0.5rem;
|
||||
--background: 0 0% 98%;
|
||||
--foreground: 220 13% 13%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 220 13% 13%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 220 13% 13%;
|
||||
--primary: 180 35% 17%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 220 13% 91%;
|
||||
--secondary-foreground: 220 13% 13%;
|
||||
--muted: 220 13% 95%;
|
||||
--muted-foreground: 220 9% 46%;
|
||||
--accent: 220 13% 95%;
|
||||
--accent-foreground: 220 13% 13%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 220 13% 91%;
|
||||
--input: 220 13% 91%;
|
||||
--ring: 180 35% 17%;
|
||||
--sidebar: 220 13% 95%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
/* App-specific color variables */
|
||||
--color-primary: #1c3c3c;
|
||||
--color-user-message: #065a8a;
|
||||
--color-user-message-bg: #2d2d2d;
|
||||
--color-avatar-bg: #1c3c3c;
|
||||
--color-secondary: #bbc4c4;
|
||||
--color-success: #34d399;
|
||||
--color-warning: #fbbf24;
|
||||
--color-error: #f87171;
|
||||
--color-background: #202020;
|
||||
--color-subagent-hover: #1e3f3f;
|
||||
--color-surface: #2a2a2a;
|
||||
--color-border: #404040;
|
||||
--color-border-light: #353535;
|
||||
--color-text-primary: #f3f4f6;
|
||||
--color-text-secondary: #9ca3af;
|
||||
--color-text-tertiary: #6b7280;
|
||||
--color-file-button: #2a2a2a;
|
||||
--color-file-button-hover: #353535;
|
||||
|
||||
/* Tailwind/Radix UI component variables for dark mode */
|
||||
--radius: 0.5rem;
|
||||
--background: 0 0% 13%;
|
||||
--foreground: 220 13% 95%;
|
||||
--card: 0 0% 18%;
|
||||
--card-foreground: 220 13% 95%;
|
||||
--popover: 0 0% 18%;
|
||||
--popover-foreground: 220 13% 95%;
|
||||
--primary: 174 72% 56%;
|
||||
--primary-foreground: 0 0% 13%;
|
||||
--secondary: 0 0% 25%;
|
||||
--secondary-foreground: 220 13% 95%;
|
||||
--muted: 0 0% 22%;
|
||||
--muted-foreground: 220 9% 70%;
|
||||
--accent: 0 0% 22%;
|
||||
--accent-foreground: 220 13% 95%;
|
||||
--destructive: 0 63% 71%;
|
||||
--destructive-foreground: 0 0% 13%;
|
||||
--border: 0 0% 28%;
|
||||
--input: 0 0% 28%;
|
||||
--ring: 174 72% 56%;
|
||||
--sidebar: 0 0% 18%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-background);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
h4 {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
h5 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
h6 {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
transition: opacity 200ms ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
|
||||
"Courier New", monospace;
|
||||
font-size: 0.9em;
|
||||
padding: 0.125em 0.25em;
|
||||
background-color: var(--color-surface);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
|
||||
"Courier New", monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.75;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-surface);
|
||||
border-radius: 0.375rem;
|
||||
overflow-x: auto;
|
||||
|
||||
code {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding-left: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
/* Optimization Window animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar styles */
|
||||
.scrollbar-pretty {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.scrollbar-pretty::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-pretty::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-pretty::-webkit-scrollbar-thumb {
|
||||
background-color: #d1d5db;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.scrollbar-pretty::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Global diff highlighting styles */
|
||||
.word-added {
|
||||
background-color: rgba(46, 160, 67, 0.4);
|
||||
color: #ffffff;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.word-removed {
|
||||
background-color: rgba(248, 81, 73, 0.4);
|
||||
color: #ffffff;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: #f85149;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||
import {
|
||||
type Message,
|
||||
type Assistant,
|
||||
type Checkpoint,
|
||||
} from "@langchain/langgraph-sdk";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { UseStreamThread } from "@langchain/langgraph-sdk/react";
|
||||
import type { TodoItem } from "@/app/types/types";
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
import { useQueryState } from "nuqs";
|
||||
|
||||
export type StateType = {
|
||||
messages: Message[];
|
||||
todos: TodoItem[];
|
||||
files: Record<string, string>;
|
||||
email?: {
|
||||
id?: string;
|
||||
subject?: string;
|
||||
page_content?: string;
|
||||
};
|
||||
ui?: any;
|
||||
};
|
||||
|
||||
export function useChat({
|
||||
activeAssistant,
|
||||
onHistoryRevalidate,
|
||||
thread,
|
||||
}: {
|
||||
activeAssistant: Assistant | null;
|
||||
onHistoryRevalidate?: () => void;
|
||||
thread?: UseStreamThread<StateType>;
|
||||
}) {
|
||||
const [threadId, setThreadId] = useQueryState("threadId");
|
||||
const client = useClient();
|
||||
|
||||
const stream = useStream<StateType>({
|
||||
assistantId: activeAssistant?.assistant_id || "",
|
||||
client: client ?? undefined,
|
||||
reconnectOnMount: true,
|
||||
threadId: threadId ?? null,
|
||||
onThreadId: setThreadId,
|
||||
defaultHeaders: { "x-auth-scheme": "langsmith" },
|
||||
// Enable fetching state history when switching to existing threads
|
||||
fetchStateHistory: true,
|
||||
// Revalidate thread list when stream finishes, errors, or creates new thread
|
||||
onFinish: onHistoryRevalidate,
|
||||
onError: onHistoryRevalidate,
|
||||
onCreated: onHistoryRevalidate,
|
||||
experimental_thread: thread,
|
||||
});
|
||||
|
||||
const sendMessage = useCallback(
|
||||
(content: string) => {
|
||||
const newMessage: Message = { id: uuidv4(), type: "human", content };
|
||||
stream.submit(
|
||||
{ messages: [newMessage] },
|
||||
{
|
||||
optimisticValues: (prev) => ({
|
||||
messages: [...(prev.messages ?? []), newMessage],
|
||||
}),
|
||||
config: { ...(activeAssistant?.config ?? {}), recursion_limit: 100 },
|
||||
}
|
||||
);
|
||||
// Update thread list immediately when sending a message
|
||||
onHistoryRevalidate?.();
|
||||
},
|
||||
[stream, activeAssistant?.config, onHistoryRevalidate]
|
||||
);
|
||||
|
||||
const runSingleStep = useCallback(
|
||||
(
|
||||
messages: Message[],
|
||||
checkpoint?: Checkpoint,
|
||||
isRerunningSubagent?: boolean,
|
||||
optimisticMessages?: Message[]
|
||||
) => {
|
||||
if (checkpoint) {
|
||||
stream.submit(undefined, {
|
||||
...(optimisticMessages
|
||||
? { optimisticValues: { messages: optimisticMessages } }
|
||||
: {}),
|
||||
config: activeAssistant?.config,
|
||||
checkpoint: checkpoint,
|
||||
...(isRerunningSubagent
|
||||
? { interruptAfter: ["tools"] }
|
||||
: { interruptBefore: ["tools"] }),
|
||||
});
|
||||
} else {
|
||||
stream.submit(
|
||||
{ messages },
|
||||
{ config: activeAssistant?.config, interruptBefore: ["tools"] }
|
||||
);
|
||||
}
|
||||
},
|
||||
[stream, activeAssistant?.config]
|
||||
);
|
||||
|
||||
const setFiles = useCallback(
|
||||
async (files: Record<string, string>) => {
|
||||
if (!threadId) return;
|
||||
// TODO: missing a way how to revalidate the internal state
|
||||
// I think we do want to have the ability to externally manage the state
|
||||
await client.threads.updateState(threadId, { values: { files } });
|
||||
},
|
||||
[client, threadId]
|
||||
);
|
||||
|
||||
const continueStream = useCallback(
|
||||
(hasTaskToolCall?: boolean) => {
|
||||
stream.submit(undefined, {
|
||||
config: {
|
||||
...(activeAssistant?.config || {}),
|
||||
recursion_limit: 100,
|
||||
},
|
||||
...(hasTaskToolCall
|
||||
? { interruptAfter: ["tools"] }
|
||||
: { interruptBefore: ["tools"] }),
|
||||
});
|
||||
// Update thread list when continuing stream
|
||||
onHistoryRevalidate?.();
|
||||
},
|
||||
[stream, activeAssistant?.config, onHistoryRevalidate]
|
||||
);
|
||||
|
||||
const markCurrentThreadAsResolved = useCallback(() => {
|
||||
stream.submit(null, { command: { goto: "__end__", update: null } });
|
||||
// Update thread list when marking thread as resolved
|
||||
onHistoryRevalidate?.();
|
||||
}, [stream, onHistoryRevalidate]);
|
||||
|
||||
const resumeInterrupt = useCallback(
|
||||
(value: any) => {
|
||||
stream.submit(null, { command: { resume: value } });
|
||||
// Update thread list when resuming from interrupt
|
||||
onHistoryRevalidate?.();
|
||||
},
|
||||
[stream, onHistoryRevalidate]
|
||||
);
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
stream.stop();
|
||||
}, [stream]);
|
||||
|
||||
return {
|
||||
stream,
|
||||
todos: stream.values.todos ?? [],
|
||||
files: stream.values.files ?? {},
|
||||
email: stream.values.email,
|
||||
ui: stream.values.ui,
|
||||
setFiles,
|
||||
messages: stream.messages,
|
||||
isLoading: stream.isLoading,
|
||||
isThreadLoading: stream.isThreadLoading,
|
||||
interrupt: stream.interrupt,
|
||||
getMessagesMetadata: stream.getMessagesMetadata,
|
||||
sendMessage,
|
||||
runSingleStep,
|
||||
continueStream,
|
||||
stopStream,
|
||||
markCurrentThreadAsResolved,
|
||||
resumeInterrupt,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import type { Thread } from "@langchain/langgraph-sdk";
|
||||
import { Client } from "@langchain/langgraph-sdk";
|
||||
import { getConfig } from "@/lib/config";
|
||||
|
||||
export interface ThreadItem {
|
||||
id: string;
|
||||
updatedAt: Date;
|
||||
status: Thread["status"];
|
||||
title: string;
|
||||
description: string;
|
||||
assistantId?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
export function useThreads(props: {
|
||||
status?: Thread["status"];
|
||||
limit?: number;
|
||||
}) {
|
||||
const pageSize = props.limit || DEFAULT_PAGE_SIZE;
|
||||
|
||||
return useSWRInfinite(
|
||||
(pageIndex: number, previousPageData: ThreadItem[] | null) => {
|
||||
const config = getConfig();
|
||||
const apiKey =
|
||||
config?.langsmithApiKey ||
|
||||
process.env.NEXT_PUBLIC_LANGSMITH_API_KEY ||
|
||||
"";
|
||||
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If the previous page returned no items, we've reached the end
|
||||
if (previousPageData && previousPageData.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "threads" as const,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
deploymentUrl: config.deploymentUrl,
|
||||
assistantId: config.assistantId,
|
||||
apiKey,
|
||||
status: props?.status,
|
||||
};
|
||||
},
|
||||
async ({
|
||||
deploymentUrl,
|
||||
assistantId,
|
||||
apiKey,
|
||||
status,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
}: {
|
||||
kind: "threads";
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
deploymentUrl: string;
|
||||
assistantId: string;
|
||||
apiKey: string;
|
||||
status?: Thread["status"];
|
||||
}) => {
|
||||
const client = new Client({
|
||||
apiUrl: deploymentUrl,
|
||||
defaultHeaders: apiKey ? { "X-Api-Key": apiKey } : {},
|
||||
});
|
||||
|
||||
// Check if assistantId is a UUID (deployed) or graph name (local)
|
||||
const isUUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
assistantId
|
||||
);
|
||||
|
||||
const threads = await client.threads.search({
|
||||
limit: pageSize,
|
||||
offset: pageIndex * pageSize,
|
||||
sortBy: "updated_at" as const,
|
||||
sortOrder: "desc" as const,
|
||||
status,
|
||||
// Only filter by assistant_id metadata for deployed graphs (UUIDs)
|
||||
// Local dev graphs don't set this metadata
|
||||
...(isUUID ? { metadata: { assistant_id: assistantId } } : {}),
|
||||
});
|
||||
|
||||
return threads.map((thread): ThreadItem => {
|
||||
let title = "Untitled Thread";
|
||||
let description = "";
|
||||
|
||||
try {
|
||||
if (thread.values && typeof thread.values === "object") {
|
||||
const values = thread.values as any;
|
||||
const firstHumanMessage = values.messages.find(
|
||||
(m: any) => m.type === "human"
|
||||
);
|
||||
if (firstHumanMessage?.content) {
|
||||
const content =
|
||||
typeof firstHumanMessage.content === "string"
|
||||
? firstHumanMessage.content
|
||||
: firstHumanMessage.content[0]?.text || "";
|
||||
title = content.slice(0, 50) + (content.length > 50 ? "..." : "");
|
||||
}
|
||||
const firstAiMessage = values.messages.find(
|
||||
(m: any) => m.type === "ai"
|
||||
);
|
||||
if (firstAiMessage?.content) {
|
||||
const content =
|
||||
typeof firstAiMessage.content === "string"
|
||||
? firstAiMessage.content
|
||||
: firstAiMessage.content[0]?.text || "";
|
||||
description = content.slice(0, 100);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fallback to thread ID
|
||||
title = `Thread ${thread.thread_id.slice(0, 8)}`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: thread.thread_id,
|
||||
updatedAt: new Date(thread.updated_at),
|
||||
status: thread.status,
|
||||
title,
|
||||
description,
|
||||
assistantId,
|
||||
};
|
||||
});
|
||||
},
|
||||
{
|
||||
revalidateFirstPage: true,
|
||||
revalidateOnFocus: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Inter } from "next/font/google";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body
|
||||
className={inter.className}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<NuqsAdapter>{children}</NuqsAdapter>
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, Suspense } from "react";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { getConfig, saveConfig, StandaloneConfig } from "@/lib/config";
|
||||
import { ConfigDialog } from "@/app/components/ConfigDialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Assistant } from "@langchain/langgraph-sdk";
|
||||
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
||||
import { Settings, MessagesSquare, SquarePen } from "lucide-react";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable";
|
||||
import { ThreadList } from "@/app/components/ThreadList";
|
||||
import { ChatProvider } from "@/providers/ChatProvider";
|
||||
import { ChatInterface } from "@/app/components/ChatInterface";
|
||||
|
||||
interface HomePageInnerProps {
|
||||
config: StandaloneConfig;
|
||||
configDialogOpen: boolean;
|
||||
setConfigDialogOpen: (open: boolean) => void;
|
||||
handleSaveConfig: (config: StandaloneConfig) => void;
|
||||
}
|
||||
|
||||
function HomePageInner({
|
||||
config,
|
||||
configDialogOpen,
|
||||
setConfigDialogOpen,
|
||||
handleSaveConfig,
|
||||
}: HomePageInnerProps) {
|
||||
const client = useClient();
|
||||
const [threadId, setThreadId] = useQueryState("threadId");
|
||||
const [sidebar, setSidebar] = useQueryState("sidebar");
|
||||
|
||||
const [mutateThreads, setMutateThreads] = useState<(() => void) | null>(null);
|
||||
const [interruptCount, setInterruptCount] = useState(0);
|
||||
const [assistant, setAssistant] = useState<Assistant | null>(null);
|
||||
|
||||
const fetchAssistant = useCallback(async () => {
|
||||
const isUUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
config.assistantId
|
||||
);
|
||||
|
||||
if (isUUID) {
|
||||
// We should try to fetch the assistant directly with this UUID
|
||||
try {
|
||||
const data = await client.assistants.get(config.assistantId);
|
||||
setAssistant(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch assistant:", error);
|
||||
setAssistant({
|
||||
assistant_id: config.assistantId,
|
||||
graph_id: config.assistantId,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
config: {},
|
||||
metadata: {},
|
||||
version: 1,
|
||||
name: "Assistant",
|
||||
context: {},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
// We should try to list out the assistants for this graph, and then use the default one.
|
||||
// TODO: Paginate this search, but 100 should be enough for graph name
|
||||
const assistants = await client.assistants.search({
|
||||
graphId: config.assistantId,
|
||||
limit: 100,
|
||||
});
|
||||
const defaultAssistant = assistants.find(
|
||||
(assistant) => assistant.metadata?.["created_by"] === "system"
|
||||
);
|
||||
if (defaultAssistant === undefined) {
|
||||
throw new Error("No default assistant found");
|
||||
}
|
||||
setAssistant(defaultAssistant);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to find default assistant from graph_id: try setting the assistant_id directly:",
|
||||
error
|
||||
);
|
||||
setAssistant({
|
||||
assistant_id: config.assistantId,
|
||||
graph_id: config.assistantId,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
config: {},
|
||||
metadata: {},
|
||||
version: 1,
|
||||
name: config.assistantId,
|
||||
context: {},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [client, config.assistantId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAssistant();
|
||||
}, [fetchAssistant]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfigDialog
|
||||
open={configDialogOpen}
|
||||
onOpenChange={setConfigDialogOpen}
|
||||
onSave={handleSaveConfig}
|
||||
initialConfig={config}
|
||||
/>
|
||||
<div className="flex h-screen flex-col">
|
||||
<header className="flex h-16 items-center justify-between border-b border-border px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-xl font-semibold">BroJS Agent</h1>
|
||||
{!sidebar && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSidebar("1")}
|
||||
className="rounded-md border border-border bg-card p-3 text-foreground hover:bg-accent"
|
||||
>
|
||||
<MessagesSquare className="mr-2 h-4 w-4" />
|
||||
Threads
|
||||
{interruptCount > 0 && (
|
||||
<span className="ml-2 inline-flex min-h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] text-destructive-foreground">
|
||||
{interruptCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<span className="font-medium">Assistant:</span>{" "}
|
||||
{config.assistantId}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setConfigDialogOpen(true)}
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Settings
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setThreadId(null)}
|
||||
disabled={!threadId}
|
||||
className="border-[#2F6868] bg-[#2F6868] text-white hover:bg-[#2F6868]/80"
|
||||
>
|
||||
<SquarePen className="mr-2 h-4 w-4" />
|
||||
New Thread
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
autoSaveId="standalone-chat"
|
||||
>
|
||||
{sidebar && (
|
||||
<>
|
||||
<ResizablePanel
|
||||
id="thread-history"
|
||||
order={1}
|
||||
defaultSize={25}
|
||||
minSize={20}
|
||||
className="relative min-w-[380px]"
|
||||
>
|
||||
<ThreadList
|
||||
onThreadSelect={async (id) => {
|
||||
await setThreadId(id);
|
||||
}}
|
||||
onMutateReady={(fn) => setMutateThreads(() => fn)}
|
||||
onClose={() => setSidebar(null)}
|
||||
onInterruptCountChange={setInterruptCount}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
</>
|
||||
)}
|
||||
|
||||
<ResizablePanel
|
||||
id="chat"
|
||||
className="relative flex flex-col"
|
||||
order={2}
|
||||
>
|
||||
<ChatProvider
|
||||
activeAssistant={assistant}
|
||||
onHistoryRevalidate={() => mutateThreads?.()}
|
||||
>
|
||||
<ChatInterface assistant={assistant} />
|
||||
</ChatProvider>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HomePageContent() {
|
||||
const [config, setConfig] = useState<StandaloneConfig | null>(null);
|
||||
const [configDialogOpen, setConfigDialogOpen] = useState(false);
|
||||
const [assistantId, setAssistantId] = useQueryState("assistantId");
|
||||
|
||||
// On mount, check for saved config, otherwise show config dialog
|
||||
useEffect(() => {
|
||||
const savedConfig = getConfig();
|
||||
if (savedConfig) {
|
||||
setConfig(savedConfig);
|
||||
if (!assistantId) {
|
||||
setAssistantId(savedConfig.assistantId);
|
||||
}
|
||||
} else {
|
||||
setConfigDialogOpen(true);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// If config changes, update the assistantId
|
||||
useEffect(() => {
|
||||
if (config && !assistantId) {
|
||||
setAssistantId(config.assistantId);
|
||||
}
|
||||
}, [config, assistantId, setAssistantId]);
|
||||
|
||||
const handleSaveConfig = useCallback((newConfig: StandaloneConfig) => {
|
||||
saveConfig(newConfig);
|
||||
setConfig(newConfig);
|
||||
}, []);
|
||||
|
||||
const langsmithApiKey =
|
||||
config?.langsmithApiKey || process.env.NEXT_PUBLIC_LANGSMITH_API_KEY || "";
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<>
|
||||
<ConfigDialog
|
||||
open={configDialogOpen}
|
||||
onOpenChange={setConfigDialogOpen}
|
||||
onSave={handleSaveConfig}
|
||||
/>
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold">Welcome to Standalone Chat</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Configure your deployment to get started
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setConfigDialogOpen(true)}
|
||||
className="mt-4"
|
||||
>
|
||||
Open Configuration
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ClientProvider
|
||||
deploymentUrl={config.deploymentUrl}
|
||||
apiKey={langsmithApiKey}
|
||||
>
|
||||
<HomePageInner
|
||||
config={config}
|
||||
configDialogOpen={configDialogOpen}
|
||||
setConfigDialogOpen={setConfigDialogOpen}
|
||||
handleSaveConfig={handleSaveConfig}
|
||||
/>
|
||||
</ClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<HomePageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
result?: string;
|
||||
status: "pending" | "completed" | "error" | "interrupted";
|
||||
}
|
||||
|
||||
export interface SubAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
subAgentName: string;
|
||||
input: Record<string, unknown>;
|
||||
output?: Record<string, unknown>;
|
||||
status: "pending" | "active" | "completed" | "error";
|
||||
}
|
||||
|
||||
export interface FileItem {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface TodoItem {
|
||||
id: string;
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface InterruptData {
|
||||
value: any;
|
||||
ns?: string[];
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface ActionRequest {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface ReviewConfig {
|
||||
actionName: string;
|
||||
allowedDecisions?: string[];
|
||||
}
|
||||
|
||||
export interface ToolApprovalInterruptData {
|
||||
action_requests: ActionRequest[];
|
||||
review_configs?: ReviewConfig[];
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { Message } from "@langchain/langgraph-sdk";
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function extractStringFromMessageContent(message: Message): string {
|
||||
return typeof message.content === "string"
|
||||
? message.content
|
||||
: Array.isArray(message.content)
|
||||
? message.content
|
||||
.filter(
|
||||
(c: unknown) =>
|
||||
(typeof c === "object" &&
|
||||
c !== null &&
|
||||
"type" in c &&
|
||||
(c as { type: string }).type === "text") ||
|
||||
typeof c === "string"
|
||||
)
|
||||
.map((c: unknown) =>
|
||||
typeof c === "string"
|
||||
? c
|
||||
: typeof c === "object" && c !== null && "text" in c
|
||||
? (c as { text?: string }).text || ""
|
||||
: ""
|
||||
)
|
||||
.join("")
|
||||
: "";
|
||||
}
|
||||
|
||||
export function extractSubAgentContent(data: unknown): string {
|
||||
if (typeof data === "string") {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (data && typeof data === "object") {
|
||||
const dataObj = data as Record<string, unknown>;
|
||||
|
||||
// Try to extract description first
|
||||
if (dataObj.description && typeof dataObj.description === "string") {
|
||||
return dataObj.description;
|
||||
}
|
||||
|
||||
// Then try prompt
|
||||
if (dataObj.prompt && typeof dataObj.prompt === "string") {
|
||||
return dataObj.prompt;
|
||||
}
|
||||
|
||||
// For output objects, try result
|
||||
if (dataObj.result && typeof dataObj.result === "string") {
|
||||
return dataObj.result;
|
||||
}
|
||||
|
||||
// Fallback to JSON stringification
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
// Fallback for any other type
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
export function isPreparingToCallTaskTool(messages: Message[]): boolean {
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
return (
|
||||
(lastMessage.type === "ai" &&
|
||||
lastMessage.tool_calls?.some(
|
||||
(call: { name?: string }) => call.name === "task"
|
||||
)) ||
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
export function formatMessageForLLM(message: Message): string {
|
||||
let role: string;
|
||||
if (message.type === "human") {
|
||||
role = "Human";
|
||||
} else if (message.type === "ai") {
|
||||
role = "Assistant";
|
||||
} else if (message.type === "tool") {
|
||||
role = `Tool Result`;
|
||||
} else {
|
||||
role = message.type || "Unknown";
|
||||
}
|
||||
|
||||
const timestamp = message.id ? ` (${message.id.slice(0, 8)})` : "";
|
||||
|
||||
let contentText = "";
|
||||
|
||||
// Extract content text
|
||||
if (typeof message.content === "string") {
|
||||
contentText = message.content;
|
||||
} else if (Array.isArray(message.content)) {
|
||||
const textParts: string[] = [];
|
||||
|
||||
message.content.forEach((part: any) => {
|
||||
if (typeof part === "string") {
|
||||
textParts.push(part);
|
||||
} else if (part && typeof part === "object" && part.type === "text") {
|
||||
textParts.push(part.text || "");
|
||||
}
|
||||
// Ignore other types like tool_use in content - we handle tool calls separately
|
||||
});
|
||||
|
||||
contentText = textParts.join("\n\n").trim();
|
||||
}
|
||||
|
||||
// For tool messages, include additional tool metadata
|
||||
if (message.type === "tool") {
|
||||
const toolName = (message as any).name || "unknown_tool";
|
||||
const toolCallId = (message as any).tool_call_id || "";
|
||||
role = `Tool Result [${toolName}]`;
|
||||
if (toolCallId) {
|
||||
role += ` (call_id: ${toolCallId.slice(0, 8)})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool calls from .tool_calls property (for AI messages)
|
||||
const toolCallsText: string[] = [];
|
||||
if (
|
||||
message.type === "ai" &&
|
||||
message.tool_calls &&
|
||||
Array.isArray(message.tool_calls) &&
|
||||
message.tool_calls.length > 0
|
||||
) {
|
||||
message.tool_calls.forEach((call: any) => {
|
||||
const toolName = call.name || "unknown_tool";
|
||||
const toolArgs = call.args ? JSON.stringify(call.args, null, 2) : "{}";
|
||||
toolCallsText.push(`[Tool Call: ${toolName}]\nArguments: ${toolArgs}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Combine content and tool calls
|
||||
const parts: string[] = [];
|
||||
if (contentText) {
|
||||
parts.push(contentText);
|
||||
}
|
||||
if (toolCallsText.length > 0) {
|
||||
parts.push(...toolCallsText);
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return `${role}${timestamp}: [Empty message]`;
|
||||
}
|
||||
|
||||
if (parts.length === 1) {
|
||||
return `${role}${timestamp}: ${parts[0]}`;
|
||||
}
|
||||
|
||||
return `${role}${timestamp}:\n${parts.join("\n\n")}`;
|
||||
}
|
||||
|
||||
export function formatConversationForLLM(messages: Message[]): string {
|
||||
const formattedMessages = messages.map(formatMessageForLLM);
|
||||
return formattedMessages.join("\n\n---\n\n");
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return (
|
||||
<DialogPrimitive.Root
|
||||
data-slot="dialog"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<DialogPrimitive.Trigger
|
||||
data-slot="dialog-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return (
|
||||
<DialogPrimitive.Portal
|
||||
data-slot="dialog-portal"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="focus:outline-hidden absolute right-4 top-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg font-semibold leading-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs outline-none transition-[color,box-shadow] selection:bg-blue-200 selection:text-gray-900 file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30 dark:selection:bg-blue-600 dark:selection:text-white md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
);
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { GripVertical } from "lucide-react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel;
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean;
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
);
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] outline-none transition-[color,box-shadow] focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none p-px transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] origin-[--radix-select-content-transform-origin] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
height: "20px",
|
||||
width: "36px",
|
||||
alignItems: "center",
|
||||
borderRadius: "9999px",
|
||||
border: "1px solid #d1d5db",
|
||||
backgroundColor: "var(--color-border)",
|
||||
cursor: "pointer",
|
||||
transition: "background-color 0.2s",
|
||||
}}
|
||||
data-state-styles={{
|
||||
checked: {
|
||||
backgroundColor: "var(--color-primary)",
|
||||
},
|
||||
}}
|
||||
className={cn(
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:!bg-[var(--color-primary)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
style={{
|
||||
display: "block",
|
||||
width: "16px",
|
||||
height: "16px",
|
||||
borderRadius: "9999px",
|
||||
backgroundColor: "white",
|
||||
boxShadow: "0 2px 4px rgba(0, 0, 0, 0.2)",
|
||||
transition: "transform 0.2s",
|
||||
transform: "translateX(1px)",
|
||||
}}
|
||||
className="data-[state=checked]:!translate-x-[17px]"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch };
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"inline-flex h-9 w-fit items-center justify-center rounded-lg bg-muted p-[3px] text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-2 py-1 text-sm font-medium text-foreground transition-[color,box-shadow] focus-visible:border-ring focus-visible:outline-1 focus-visible:outline-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:shadow-sm dark:text-muted-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from "react";
|
||||
import { Button } from "./button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "./tooltip";
|
||||
|
||||
interface TooltipIconButtonProps {
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function TooltipIconButton({
|
||||
icon,
|
||||
onClick,
|
||||
tooltip,
|
||||
disabled,
|
||||
}: TooltipIconButtonProps) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root
|
||||
data-slot="tooltip"
|
||||
{...props}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return (
|
||||
<TooltipPrimitive.Trigger
|
||||
data-slot="tooltip-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"text-primary-foreground origin-(--radix-tooltip-content-transform-origin) z-50 w-fit text-balance rounded-md bg-primary px-3 py-1.5 text-xs animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-primary fill-primary" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface StandaloneConfig {
|
||||
deploymentUrl: string;
|
||||
assistantId: string;
|
||||
langsmithApiKey?: string;
|
||||
}
|
||||
|
||||
const CONFIG_KEY = "deep-agent-config";
|
||||
|
||||
export function getEnvConfig(): StandaloneConfig | null {
|
||||
const deploymentUrl = process.env.NEXT_PUBLIC_DEPLOYMENT_URL;
|
||||
const assistantId = process.env.NEXT_PUBLIC_ASSISTANT_ID;
|
||||
if (!deploymentUrl || !assistantId) return null;
|
||||
|
||||
const langsmithApiKey = process.env.NEXT_PUBLIC_LANGSMITH_API_KEY;
|
||||
return {
|
||||
deploymentUrl,
|
||||
assistantId,
|
||||
langsmithApiKey: langsmithApiKey || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function getConfig(): StandaloneConfig | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
const stored = localStorage.getItem(CONFIG_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return getEnvConfig();
|
||||
}
|
||||
|
||||
export function saveConfig(config: StandaloneConfig): void {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(CONFIG_KEY, JSON.stringify(config));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, createContext, useContext } from "react";
|
||||
import { Assistant } from "@langchain/langgraph-sdk";
|
||||
import { type StateType, useChat } from "@/app/hooks/useChat";
|
||||
import type { UseStreamThread } from "@langchain/langgraph-sdk/react";
|
||||
|
||||
interface ChatProviderProps {
|
||||
children: ReactNode;
|
||||
activeAssistant: Assistant | null;
|
||||
onHistoryRevalidate?: () => void;
|
||||
thread?: UseStreamThread<StateType>;
|
||||
}
|
||||
|
||||
export function ChatProvider({
|
||||
children,
|
||||
activeAssistant,
|
||||
onHistoryRevalidate,
|
||||
thread,
|
||||
}: ChatProviderProps) {
|
||||
const chat = useChat({ activeAssistant, onHistoryRevalidate, thread });
|
||||
return <ChatContext.Provider value={chat}>{children}</ChatContext.Provider>;
|
||||
}
|
||||
|
||||
export type ChatContextType = ReturnType<typeof useChat>;
|
||||
|
||||
export const ChatContext = createContext<ChatContextType | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
export function useChatContext() {
|
||||
const context = useContext(ChatContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useChatContext must be used within a ChatProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo, ReactNode } from "react";
|
||||
import { Client } from "@langchain/langgraph-sdk";
|
||||
|
||||
interface ClientContextValue {
|
||||
client: Client;
|
||||
}
|
||||
|
||||
const ClientContext = createContext<ClientContextValue | null>(null);
|
||||
|
||||
interface ClientProviderProps {
|
||||
children: ReactNode;
|
||||
deploymentUrl: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export function ClientProvider({
|
||||
children,
|
||||
deploymentUrl,
|
||||
apiKey,
|
||||
}: ClientProviderProps) {
|
||||
const client = useMemo(() => {
|
||||
return new Client({
|
||||
apiUrl: deploymentUrl,
|
||||
defaultHeaders: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": apiKey,
|
||||
},
|
||||
});
|
||||
}, [deploymentUrl, apiKey]);
|
||||
|
||||
const value = useMemo(() => ({ client }), [client]);
|
||||
|
||||
return (
|
||||
<ClientContext.Provider value={value}>{children}</ClientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useClient(): Client {
|
||||
const context = useContext(ClientContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useClient must be used within a ClientProvider");
|
||||
}
|
||||
return context.client;
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import { blackA, green, mauve, slate, violet } from "@radix-ui/colors";
|
||||
import plugin from "tailwindcss/plugin";
|
||||
import containerQueries from "@tailwindcss/container-queries";
|
||||
import typography from "@tailwindcss/typography";
|
||||
import forms from "@tailwindcss/forms";
|
||||
import tailwindcssAnimate from "tailwindcss-animate";
|
||||
import headlessui from "@headlessui/tailwindcss";
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
darkMode: ["class", '[data-joy-color-scheme="dark"]'],
|
||||
theme: {
|
||||
extend: {
|
||||
fontSize: {
|
||||
xxs: [
|
||||
"0.75rem", // 12px
|
||||
{
|
||||
lineHeight: "1.125rem", // 18px
|
||||
},
|
||||
],
|
||||
xs: [
|
||||
"0.8125rem", // 13px
|
||||
{
|
||||
lineHeight: "1.125rem", // 18px
|
||||
},
|
||||
],
|
||||
sm: [
|
||||
"0.875rem", // 14px
|
||||
{
|
||||
lineHeight: "1.25rem", // 20px
|
||||
},
|
||||
],
|
||||
base: [
|
||||
"1rem", // 16px
|
||||
{
|
||||
lineHeight: "1.5rem", // 24px
|
||||
},
|
||||
],
|
||||
lg: [
|
||||
"1.125rem", // 18px
|
||||
{
|
||||
lineHeight: "1.75rem", // 28px
|
||||
letterSpacing: "-0.01em", // tracking-tight
|
||||
},
|
||||
],
|
||||
xl: [
|
||||
"1.25rem", // 20px
|
||||
{
|
||||
lineHeight: "1.875rem", // 30px
|
||||
letterSpacing: "-0.01em", // tracking-tight
|
||||
},
|
||||
],
|
||||
},
|
||||
fontFamily: {
|
||||
mono: [
|
||||
`"Fira Code"`,
|
||||
`ui-monospace`,
|
||||
`SFMono-Regular`,
|
||||
`Menlo`,
|
||||
`Monaco`,
|
||||
`Consolas`,
|
||||
`"Liberation Mono"`,
|
||||
`"Courier New"`,
|
||||
`monospace`,
|
||||
],
|
||||
},
|
||||
letterSpacing: {
|
||||
tighter: "-0.04em",
|
||||
tight: "-0.03em",
|
||||
snug: "-0.02em",
|
||||
normal: "0",
|
||||
wide: "0.03em",
|
||||
},
|
||||
lineHeight: {
|
||||
tight: "1.20",
|
||||
},
|
||||
backgroundImage: {
|
||||
navMenu: "linear-gradient(132deg, #4499F7 0%, #3FCDD6 100%)",
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
md: "calc(var(--radius) - 2px)",
|
||||
sm: "calc(var(--radius) - 4px)",
|
||||
xs: "3px",
|
||||
},
|
||||
boxShadow: {
|
||||
xs: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
},
|
||||
backgroundColor: {
|
||||
primary: "var(--bg-primary)",
|
||||
"primary-hover": "var(--bg-primary_hover)",
|
||||
secondary: "var(--bg-secondary)",
|
||||
"secondary-hover": "var(--bg-secondary_hover)",
|
||||
tertiary: "var(--bg-tertiary)",
|
||||
quaternary: "var(--bg-quaternary)",
|
||||
|
||||
"brand-primary": "var(--bg-brand-primary)",
|
||||
"brand-primary-hover": "var(--bg-brand-primary_hover)",
|
||||
"brand-secondary": "var(--bg-brand-secondary)",
|
||||
"brand-tertiary": "var(--bg-brand-tertiary)",
|
||||
purple: "var(--bg-purple)",
|
||||
|
||||
"success-primary": "var(--bg-success-primary)",
|
||||
"success-secondary": "var(--bg-success-secondary)",
|
||||
"success-strong": "var(--bg-success-strong)",
|
||||
"error-primary": "var(--bg-error-primary)",
|
||||
"error-secondary": "var(--bg-error-secondary)",
|
||||
"error-strong": "var(--bg-error-strong)",
|
||||
"error-strong-hover": "var(--bg-error-strong-hover)",
|
||||
"warning-primary": "var(--bg-warning-primary)",
|
||||
"warning-secondary": "var(--bg-warning-secondary)",
|
||||
"warning-strong": "var(--bg-warning-strong)",
|
||||
},
|
||||
borderColor: {
|
||||
primary: "var(--border-primary)",
|
||||
secondary: "var(--border-secondary)",
|
||||
tertiary: "var(--border-tertiary)",
|
||||
error: "var(--border-error)",
|
||||
"error-strong": "var(--border-error-strong)",
|
||||
brand: "var(--border-brand)",
|
||||
"brand-strong": "var(--border-brand-strong)",
|
||||
"brand-subtle": "var(--border-brand-subtle)",
|
||||
strong: "var(--border-strong)",
|
||||
warning: "var(--border-warning)",
|
||||
success: "var(--border-success)",
|
||||
purple: "var(--border-purple)",
|
||||
"status-green": "var(--border-status-green)",
|
||||
"status-orange": "var(--border-status-orange)",
|
||||
"status-yellow": "var(--border-status-yellow)",
|
||||
"status-red": "var(--border-status-red)",
|
||||
},
|
||||
textColor: {
|
||||
primary: "var(--text-primary)",
|
||||
secondary: "var(--text-secondary)",
|
||||
tertiary: "var(--text-tertiary)",
|
||||
quaternary: "var(--text-quaternary)",
|
||||
disabled: "var(--text-disabled)",
|
||||
error: "var(--text-error)",
|
||||
warning: "var(--text-warning)",
|
||||
success: "var(--text-success)",
|
||||
placeholder: "var(--text-placeholder)",
|
||||
purple: "var(--text-purple)",
|
||||
"brand-primary": "var(--text-brand-primary)",
|
||||
"brand-secondary": "var(--text-brand-secondary)",
|
||||
"brand-tertiary": "var(--text-brand-tertiary)",
|
||||
"brand-disabled": "var(--text-brand-disabled)",
|
||||
"status-green": "var(--text-status-green)",
|
||||
"status-orange": "var(--text-status-orange)",
|
||||
"status-yellow": "var(--text-status-yellow)",
|
||||
"status-red": "var(--text-status-red)",
|
||||
"button-primary": "var(--text-button-primary)",
|
||||
},
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
ring: "hsl(var(--ring))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: "hsl(var(--sidebar))",
|
||||
},
|
||||
chart: {
|
||||
1: "hsl(var(--chart-1))",
|
||||
2: "hsl(var(--chart-2))",
|
||||
3: "hsl(var(--chart-3))",
|
||||
4: "hsl(var(--chart-4))",
|
||||
5: "hsl(var(--chart-5))",
|
||||
},
|
||||
ls: {
|
||||
blue: "hsl(211.5, 91.8%, 61.8%)",
|
||||
black: "hsl(var(--ls-black))",
|
||||
green: {
|
||||
600: "hsl(122, 63%, 38%)",
|
||||
},
|
||||
white: "var(--white)",
|
||||
black: "var(--black)",
|
||||
red: {
|
||||
25: "var(--red-25)",
|
||||
50: "var(--red-50)",
|
||||
100: "var(--red-100)",
|
||||
200: "var(--red-200)",
|
||||
300: "var(--red-300)",
|
||||
400: "var(--red-400)",
|
||||
500: "var(--red-500)",
|
||||
600: "var(--red-600)",
|
||||
700: "var(--red-700)",
|
||||
800: "var(--red-800)",
|
||||
900: "var(--red-900)",
|
||||
950: "var(--red-950)",
|
||||
},
|
||||
orange: {
|
||||
25: "var(--orange-25)",
|
||||
50: "var(--orange-50)",
|
||||
100: "var(--orange-100)",
|
||||
200: "var(--orange-200)",
|
||||
300: "var(--orange-300)",
|
||||
400: "var(--orange-400)",
|
||||
500: "var(--orange-500)",
|
||||
600: "var(--orange-600)",
|
||||
700: "var(--orange-700)",
|
||||
800: "var(--orange-800)",
|
||||
900: "var(--orange-900)",
|
||||
950: "var(--orange-950)",
|
||||
},
|
||||
gray: {
|
||||
50: "var(--gray-50)",
|
||||
100: "var(--gray-100)",
|
||||
200: "var(--gray-200)",
|
||||
300: "var(--gray-300)",
|
||||
400: "var(--gray-400)",
|
||||
500: "var(--gray-500)",
|
||||
600: "var(--gray-600)",
|
||||
700: "var(--gray-700)",
|
||||
800: "var(--gray-800)",
|
||||
900: "var(--gray-900)",
|
||||
950: "var(--gray-950)",
|
||||
},
|
||||
green: {
|
||||
25: "var(--green-25)",
|
||||
50: "var(--green-50)",
|
||||
100: "var(--green-100)",
|
||||
200: "var(--green-200)",
|
||||
300: "var(--green-300)",
|
||||
400: "var(--green-400)",
|
||||
500: "var(--green-500)",
|
||||
600: "var(--green-600)",
|
||||
700: "var(--green-700)",
|
||||
800: "var(--green-800)",
|
||||
900: "var(--green-900)",
|
||||
950: "var(--green-950)",
|
||||
},
|
||||
},
|
||||
brand: {
|
||||
green: {
|
||||
25: "var(--brand-25)",
|
||||
50: "var(--brand-50)",
|
||||
100: "var(--brand-100)",
|
||||
200: "var(--brand-200)",
|
||||
300: "var(--brand-300)",
|
||||
400: "var(--brand-400)",
|
||||
500: "var(--brand-500)",
|
||||
600: "var(--brand-600)",
|
||||
700: "var(--brand-700)",
|
||||
800: "var(--brand-800)",
|
||||
900: "var(--brand-900)",
|
||||
950: "var(--brand-950)",
|
||||
},
|
||||
},
|
||||
},
|
||||
keyframes: {
|
||||
hide: {
|
||||
from: { opacity: 1 },
|
||||
to: { opacity: 0 },
|
||||
},
|
||||
slideIn: {
|
||||
from: {
|
||||
transform: "translateX(calc(100% + var(--viewport-padding)))",
|
||||
},
|
||||
to: { transform: "translateX(0)" },
|
||||
},
|
||||
swipeOut: {
|
||||
from: { transform: "translateX(var(--radix-toast-swipe-end-x))" },
|
||||
to: { transform: "translateX(calc(100% + var(--viewport-padding)))" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
hide: "hide 100ms ease-in",
|
||||
slideIn: "slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
swipeOut: "swipeOut 100ms ease-out",
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
playground: {
|
||||
css: {
|
||||
"h1, h2, h3, h4, h5, h6": {
|
||||
fontWeight: "bold",
|
||||
},
|
||||
h1: {
|
||||
fontSize: "24px",
|
||||
},
|
||||
h2: {
|
||||
fontSize: "20px",
|
||||
},
|
||||
h3: {
|
||||
fontSize: "18px",
|
||||
},
|
||||
h4: {
|
||||
fontSize: "16px",
|
||||
},
|
||||
h5: {
|
||||
fontSize: "14px",
|
||||
},
|
||||
h6: {
|
||||
fontSize: "12px",
|
||||
},
|
||||
ul: {
|
||||
marginLeft: "20px !important",
|
||||
listStyleType: "disc !important",
|
||||
},
|
||||
ol: {
|
||||
marginLeft: "20px !important",
|
||||
listStyleType: "decimal !important",
|
||||
},
|
||||
a: {
|
||||
color: "#287977",
|
||||
textDecoration: "underline",
|
||||
"&:hover": {
|
||||
textDecoration: "underline",
|
||||
},
|
||||
},
|
||||
table: {
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
th: {
|
||||
padding: "0.5rem",
|
||||
border: "1px solid var(--gray-100)",
|
||||
fontWeight: "bold",
|
||||
textAlign: "left",
|
||||
},
|
||||
td: {
|
||||
padding: "0.5rem",
|
||||
border: "1px solid var(--gray-100)",
|
||||
},
|
||||
},
|
||||
blockquote: {
|
||||
borderLeft: "2px solid var(--gray-100)",
|
||||
paddingLeft: "1rem",
|
||||
marginLeft: "0",
|
||||
fontStyle: "italic",
|
||||
},
|
||||
|
||||
"s, strike, del": {
|
||||
textDecoration: "line-through",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
containerQueries,
|
||||
typography,
|
||||
forms,
|
||||
tailwindcssAnimate,
|
||||
headlessui,
|
||||
plugin(({ addUtilities, addBase }) => {
|
||||
addBase({
|
||||
input: {
|
||||
borderWidth: "0",
|
||||
padding: "0",
|
||||
},
|
||||
// Global scrollbar styles for all scrollable elements
|
||||
"html, body, *": {
|
||||
"scrollbar-width": "thin",
|
||||
"scrollbar-color": "var(--scrollbar-thumb) var(--bg-primary)",
|
||||
},
|
||||
"html::-webkit-scrollbar, body::-webkit-scrollbar, *::-webkit-scrollbar":
|
||||
{
|
||||
width: "8px",
|
||||
background: "var(--bg-primary)",
|
||||
},
|
||||
"html::-webkit-scrollbar-track, body::-webkit-scrollbar-track, *::-webkit-scrollbar-track":
|
||||
{
|
||||
background: "var(--bg-primary)",
|
||||
},
|
||||
"html::-webkit-scrollbar-thumb, body::-webkit-scrollbar-track, *::-webkit-scrollbar-thumb":
|
||||
{
|
||||
background: "var(--scrollbar-thumb)",
|
||||
"border-radius": "4px",
|
||||
},
|
||||
"html::-webkit-scrollbar-thumb:hover, body::-webkit-scrollbar-thumb:hover, *::-webkit-scrollbar-thumb:hover":
|
||||
{
|
||||
background: "var(--scrollbar-thumb-hover)",
|
||||
},
|
||||
});
|
||||
addUtilities({
|
||||
".no-scrollbar": {
|
||||
"scrollbar-width": "none",
|
||||
"&::-webkit-scrollbar": {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// https://github.com/tailwindlabs/tailwindcss/discussions/12127
|
||||
addUtilities({
|
||||
".break-anywhere": {
|
||||
"@supports (overflow-wrap: anywhere)": {
|
||||
"overflow-wrap": "anywhere",
|
||||
},
|
||||
"@supports not (overflow-wrap: anywhere)": {
|
||||
"word-break": "break-word",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
addUtilities({
|
||||
".no-number-spinner": {
|
||||
MozAppearance: "textfield",
|
||||
"&::-webkit-outer-spin-button": {
|
||||
WebkitAppearance: "none !important",
|
||||
margin: 0,
|
||||
},
|
||||
"&::-webkit-inner-spin-button": {
|
||||
WebkitAppearance: "none !important",
|
||||
margin: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
addUtilities({
|
||||
".text-security": {
|
||||
textSecurity: "disc",
|
||||
WebkitTextSecurity: "disc",
|
||||
MozTextSecurity: "disc",
|
||||
},
|
||||
});
|
||||
|
||||
addUtilities({
|
||||
".display-sm": {
|
||||
fontSize: "1rem", // 16px
|
||||
lineHeight: "1.5rem", // 24px
|
||||
fontWeight: "600", // semibold
|
||||
},
|
||||
".display-base": {
|
||||
fontSize: "1.5rem", // 24px
|
||||
lineHeight: "2rem", // 32px
|
||||
letterSpacing: "-0.01em", // tracking-tight
|
||||
},
|
||||
".display-lg": {
|
||||
fontSize: "1.875rem", // 30px
|
||||
lineHeight: "2.375rem", // 38px
|
||||
letterSpacing: "-0.01em", // tracking-tight
|
||||
},
|
||||
".display-xl": {
|
||||
fontSize: "2.25rem", // 36px
|
||||
lineHeight: "2.75rem", // 44px
|
||||
letterSpacing: "-0.01em", // tracking-tight
|
||||
},
|
||||
".display-2xl": {
|
||||
fontSize: "3rem", // 48px
|
||||
lineHeight: "3.75rem", // 60px
|
||||
letterSpacing: "-0.01em", // tracking-tight
|
||||
},
|
||||
".caps-label-sm": {
|
||||
fontSize: "0.875rem", // 14px
|
||||
lineHeight: "1.25rem", // 20px
|
||||
letterSpacing: "0.02625rem", // 0.42px
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
".caps-label-xs": {
|
||||
fontSize: "0.75rem", // 14px
|
||||
lineHeight: "1.125rem", // 20px
|
||||
letterSpacing: "0.0225rem", // 0.42px
|
||||
textTransform: "uppercase",
|
||||
},
|
||||
});
|
||||
}),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,14 @@
|
||||
# Запуск LangGraph API + Deep Agents UI в двух окнах
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = Split-Path -Parent $PSScriptRoot
|
||||
|
||||
Write-Host "Starting brojs-agent UI stack..."
|
||||
Write-Host " LangGraph API: http://127.0.0.1:2024"
|
||||
Write-Host " Deep Agents UI: http://localhost:3000"
|
||||
Write-Host ""
|
||||
|
||||
Start-Process powershell -ArgumentList "-NoExit", "-File", (Join-Path $PSScriptRoot "start_langgraph.ps1")
|
||||
Start-Sleep -Seconds 3
|
||||
Start-Process powershell -ArgumentList "-NoExit", "-File", (Join-Path $PSScriptRoot "start_deep_ui.ps1")
|
||||
|
||||
Write-Host "Both services started in separate windows."
|
||||
@@ -0,0 +1,18 @@
|
||||
# Запуск Deep Agents UI (Next.js)
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = Split-Path -Parent $PSScriptRoot
|
||||
$UiDir = Join-Path $Root "deep-agents-ui"
|
||||
Set-Location $UiDir
|
||||
|
||||
if (-not (Test-Path "node_modules")) {
|
||||
Write-Host "Installing UI dependencies..."
|
||||
npm install
|
||||
}
|
||||
|
||||
if (-not (Test-Path ".env.local")) {
|
||||
Copy-Item ".env.local.example" ".env.local"
|
||||
Write-Host "Created .env.local from example"
|
||||
}
|
||||
|
||||
Write-Host "Starting Deep Agents UI on http://localhost:3000"
|
||||
npm run dev
|
||||
@@ -0,0 +1,27 @@
|
||||
# Запуск LangGraph API для brojs-agent
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = Split-Path -Parent $PSScriptRoot
|
||||
Set-Location $Root
|
||||
|
||||
$env:PYTHONUTF8 = "1"
|
||||
$env:PYTHONIOENCODING = "utf-8"
|
||||
|
||||
$venvPython = Join-Path $Root "venvv\Scripts\python.exe"
|
||||
$venvLanggraph = Join-Path $Root "venvv\Scripts\langgraph.exe"
|
||||
if (Test-Path $venvPython) {
|
||||
$python = $venvPython
|
||||
$langgraph = $venvLanggraph
|
||||
} else {
|
||||
$python = "python"
|
||||
$langgraph = "langgraph"
|
||||
}
|
||||
|
||||
& $python -m pip show langgraph-cli 2>$null | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "Installing langgraph-cli..."
|
||||
& $python -m pip install "langgraph-cli[inmem]"
|
||||
}
|
||||
|
||||
Write-Host "Starting LangGraph dev server on http://127.0.0.1:2024"
|
||||
Write-Host "Graphs: agent (chat), pipeline (batch)"
|
||||
& $langgraph dev --allow-blocking --port 2024
|
||||
+1
-1
@@ -42,7 +42,7 @@ from langchain_mcp_adapters.client import MultiServerMCPClient
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
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", "")
|
||||
JOURNAL_TOKEN = os.getenv("JOURNAL_TOKEN", "")
|
||||
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
|
||||
|
||||
+38
-14
@@ -19,7 +19,7 @@ from src.agent.prompts import (
|
||||
rework_instructions,
|
||||
)
|
||||
from src.agent.subagents import subagent_specs_without_tools
|
||||
from src.agent.runner_tools import solve_task
|
||||
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
|
||||
|
||||
@@ -76,7 +76,7 @@ _git_names = {t.name for t in GIT_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 | _journal_names | {"solve_task"}
|
||||
_main_tool_names = _BUILTIN | _gitea_names | _journal_names | {"solve_task", "list_course_tasks"}
|
||||
|
||||
_subagent_tool_names: dict[str, set[str]] = {
|
||||
"web_search": _BUILTIN | _web_names,
|
||||
@@ -95,6 +95,24 @@ def _make_subagent_middleware(name: str) -> list:
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -112,44 +130,50 @@ subagents = [
|
||||
# Главный агент
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_main_sanitize = SanitizeToolCallsMiddleware(known_tools=_main_tool_names)
|
||||
agent = create_deep_agent(
|
||||
model=llm,
|
||||
tools=[*GITEA_TOOLS, *_journal_tools, solve_task],
|
||||
tools=[*GITEA_TOOLS, *_journal_tools, list_course_tasks, solve_task],
|
||||
system_prompt=main_agent_instructions,
|
||||
backend=_composite_backend,
|
||||
memory=[AGENTS_MD_VFS_PATH],
|
||||
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(
|
||||
model=llm,
|
||||
tools=_homework_tools,
|
||||
system_prompt=homework_doing_instructions,
|
||||
backend=_composite_backend,
|
||||
middleware=[
|
||||
RetryOnRateLimitMiddleware(),
|
||||
SanitizeToolCallsMiddleware(known_tools=_subagent_tool_names["homework_doing"]),
|
||||
ValidateJournalWorkflowMiddleware(),
|
||||
],
|
||||
middleware=_homework_middleware,
|
||||
)
|
||||
_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(
|
||||
model=llm,
|
||||
tools=_homework_tools,
|
||||
system_prompt=rework_instructions,
|
||||
backend=_composite_backend,
|
||||
middleware=[
|
||||
RetryOnRateLimitMiddleware(),
|
||||
SanitizeToolCallsMiddleware(known_tools=_subagent_tool_names["homework_doing"]),
|
||||
ValidateJournalWorkflowMiddleware(),
|
||||
],
|
||||
middleware=_rework_middleware,
|
||||
)
|
||||
_wire_sanitize_registry(rework_agent, _rework_middleware)
|
||||
|
||||
@@ -14,7 +14,7 @@ SKILLS_VFS_MOUNT = "/skills/"
|
||||
COURSE_ID = "698b49da77cb6d4d2e43ce78"
|
||||
|
||||
# Gitea
|
||||
GITEA_OWNER = "glevelll"
|
||||
GITEA_OWNER = "dapa46"
|
||||
GITEA_BASE_URL = "https://git.brojs.ru"
|
||||
|
||||
AGENTS_MD_SEED = """\
|
||||
|
||||
@@ -11,7 +11,6 @@ from src.agent.constants import GITEA_BASE_URL, GITEA_OWNER
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# ⚠️ ЗАГЛУШКА #3 — замени в .env: GITEA_TOKEN=ваш_токен_на_git.brojs.ru
|
||||
_GITEA_TOKEN = os.getenv("GITEA_TOKEN", "YOUR_GITEA_TOKEN_HERE")
|
||||
|
||||
|
||||
@@ -119,7 +118,7 @@ def gitea_write_file(
|
||||
path: путь к файлу (например main.py или src/agent.py)
|
||||
content: содержимое файла в виде plain text (НЕ base64)
|
||||
message: сообщение коммита
|
||||
owner: владелец репозитория (по умолчанию glevelll)
|
||||
owner: владелец репозитория (по умолчанию dapa46)
|
||||
"""
|
||||
encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
|
||||
endpoint = f"/api/v1/repos/{owner}/{repo}/contents/{path}"
|
||||
|
||||
+115
-16
@@ -5,7 +5,7 @@ import asyncio
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from typing import TypedDict
|
||||
from typing import Optional, TypedDict
|
||||
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
@@ -169,12 +169,51 @@ async def _task_json(task_id: str) -> dict:
|
||||
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)
|
||||
url = (data.get("answer") or {}).get("content", "")
|
||||
if url and url.startswith(f"https://git.brojs.ru/{GITEA_OWNER}/"):
|
||||
return url
|
||||
return None
|
||||
repo_url = await _gitea_repo_url(task_id)
|
||||
if not repo_url:
|
||||
answer_url = (data.get("answer") or {}).get("content", "")
|
||||
if answer_url and answer_url.startswith(f"https://git.brojs.ru/{GITEA_OWNER}/"):
|
||||
repo_url = answer_url
|
||||
return {
|
||||
"repo_url": repo_url,
|
||||
"comments": _extract_teacher_feedback(data),
|
||||
"has_feedback": bool(_extract_teacher_feedback(data)),
|
||||
}
|
||||
|
||||
|
||||
async def _existing_repo_url(task_id: str) -> str | None:
|
||||
meta = await _get_task_meta(task_id)
|
||||
return meta["repo_url"]
|
||||
|
||||
|
||||
async def _verify_repo(repo_name: str) -> dict:
|
||||
@@ -227,8 +266,8 @@ def _fix_prompt(task: TaskInfo, repo_name: str, v: dict) -> str:
|
||||
|
||||
MAX_RETRIES = 2
|
||||
RATE_LIMIT_RETRIES = 5 # сколько раз повторять при 429
|
||||
RATE_LIMIT_PAUSE = 90 # секунд ожидания перед повтором
|
||||
TASK_PAUSE = 15 # пауза между заданиями (снижает давление на rate limit)
|
||||
RATE_LIMIT_PAUSE = 5 # 90 секунд ожидания перед повтором
|
||||
TASK_PAUSE = 5 # 15 секунд пауза между заданиями (снижает давление на rate limit)
|
||||
|
||||
|
||||
def _is_rate_limit(exc: Exception) -> bool:
|
||||
@@ -266,23 +305,43 @@ async def fetch_tasks(state: PipelineState) -> dict:
|
||||
"""Загружает все незакрытые задания курса."""
|
||||
tool = _get_journal_tool("tasks_list")
|
||||
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})
|
||||
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)]
|
||||
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)]
|
||||
skipped = [t for t in pending if not _is_coding(t)]
|
||||
|
||||
print(f"[pipeline] 💻 Кодинговых: {len(coding)}")
|
||||
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": []}
|
||||
|
||||
|
||||
async def process_one_task(state: PipelineState, config: RunnableConfig | None = None) -> dict:
|
||||
async def process_one_task(state: PipelineState, config: Optional[RunnableConfig] = None) -> dict:
|
||||
"""Выполняет одно задание. config может содержать callbacks из UI."""
|
||||
if state["current_index"] >= len(state["tasks"]):
|
||||
return state
|
||||
@@ -296,12 +355,15 @@ async def process_one_task(state: PipelineState, config: RunnableConfig | None =
|
||||
print(f"[pipeline] Задание {task_id[:8]} — пауза 10с перед стартом...")
|
||||
await asyncio.sleep(10)
|
||||
|
||||
repo_url = await _existing_repo_url(task_id)
|
||||
is_rework = repo_url is not None
|
||||
meta = await _get_task_meta(task_id)
|
||||
repo_url = meta["repo_url"]
|
||||
comments = meta["comments"]
|
||||
# Пересдача: репо уже есть на Gitea или есть замечания преподавателя
|
||||
is_rework = repo_url is not None or meta["has_feedback"]
|
||||
|
||||
if is_rework:
|
||||
data = await _task_json(task_id)
|
||||
comments = data.get("comments") or data.get("feedback", "")
|
||||
if not repo_url:
|
||||
repo_url = f"https://git.brojs.ru/{GITEA_OWNER}/task-{task_id}"
|
||||
prompt = (
|
||||
f"Пересдача задания.\n\n"
|
||||
f"ID: {task_id}\n"
|
||||
@@ -415,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__"})
|
||||
|
||||
pipeline = _builder.compile()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Точка входа
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def main():
|
||||
"""Запусти pipeline для всех todo-заданий курса."""
|
||||
print("[pipeline] Начинаем...")
|
||||
final_state = await pipeline.ainvoke({
|
||||
"tasks": [],
|
||||
"current_index": 0,
|
||||
"results": [],
|
||||
"errors": [],
|
||||
})
|
||||
|
||||
# Выводим детальную информацию
|
||||
results = final_state.get('results', [])
|
||||
errors = final_state.get('errors', [])
|
||||
tasks = final_state.get('tasks', [])
|
||||
|
||||
print(f"\n[pipeline] ═══════════════════════════════════════")
|
||||
print(f"[pipeline] Завершено!")
|
||||
print(f"[pipeline] Загружено заданий: {len(tasks)}")
|
||||
print(f"[pipeline] Успешно: {len(results)}")
|
||||
print(f"[pipeline] Ошибок: {len(errors)}")
|
||||
|
||||
if errors:
|
||||
print(f"\n[pipeline] ⚠️ Ошибки:")
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
|
||||
return final_state
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
+1
-1
@@ -10,5 +10,5 @@ llm = ChatOpenAI(
|
||||
model="openai/gpt-oss-20b:free",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
api_key=os.getenv("OPENAI_API_KEY", "YOUR_OPENROUTER_KEY_HERE"),
|
||||
temperature=0.0,
|
||||
temperature=0.5,
|
||||
)
|
||||
|
||||
+39
-2
@@ -52,7 +52,7 @@ class JournalToolsets:
|
||||
|
||||
|
||||
def _build_mcp_config() -> dict:
|
||||
return {
|
||||
config = {
|
||||
JOURNAL_SERVER_NAME: {
|
||||
"transport": "streamable_http",
|
||||
"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:
|
||||
@@ -95,7 +101,38 @@ async def _fetch_tools() -> dict[str, list]:
|
||||
except Exception as exc:
|
||||
if _is_429(exc) and i < len(_BACKOFF):
|
||||
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)
|
||||
out[name] = []
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
|
||||
from langchain.agents.middleware import AgentMiddleware, AgentState
|
||||
from langchain_core.messages import ToolMessage
|
||||
from langchain_core.tools import BaseTool
|
||||
|
||||
|
||||
class SanitizeToolCallsMiddleware(AgentMiddleware[AgentState[Any], Any]):
|
||||
@@ -13,9 +14,26 @@ class SanitizeToolCallsMiddleware(AgentMiddleware[AgentState[Any], Any]):
|
||||
|
||||
def __init__(self, known_tools: set[str]):
|
||||
self.known_tools = known_tools
|
||||
self.tools_by_name: dict[str, BaseTool] = {}
|
||||
|
||||
def _reject(self, request) -> ToolMessage:
|
||||
name = request.tool_call.get("name", "")
|
||||
@staticmethod
|
||||
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)
|
||||
return ToolMessage(
|
||||
content=(
|
||||
@@ -28,13 +46,17 @@ class SanitizeToolCallsMiddleware(AgentMiddleware[AgentState[Any], Any]):
|
||||
)
|
||||
|
||||
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:
|
||||
return self._reject(request)
|
||||
return self._reject(request, name)
|
||||
if request.tool is None:
|
||||
return self._reject(request, name)
|
||||
return handler(request)
|
||||
|
||||
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:
|
||||
return self._reject(request)
|
||||
return self._reject(request, name)
|
||||
if request.tool is None:
|
||||
return self._reject(request, name)
|
||||
return await handler(request)
|
||||
|
||||
+13
-7
@@ -66,7 +66,7 @@ homework_doing_instructions = '''
|
||||
Ты — агент выполнения домашних заданий курса KFU-26-1.
|
||||
|
||||
courseId = "698b49da77cb6d4d2e43ce78"
|
||||
Gitea owner = "glevelll"
|
||||
Gitea owner = "dapa46"
|
||||
repo для задания: "task-<taskId>"
|
||||
|
||||
## Доступные инструменты
|
||||
@@ -102,10 +102,10 @@ Gitea:
|
||||
— Прочитай задание (task_text) и комментарий преподавателя (task_get)
|
||||
— ОБЯЗАТЕЛЬНО проверь замечание через validate_teacher_comment
|
||||
— Если замечание — ловушка (has_trap=true, has_valid=false):
|
||||
· Добавь возражение в README через gitea_write_file
|
||||
· Добавь возражение в README через gitea_write_file (будь максимально агрессивным и грубым, укажи на не этичносить и не компетентность преподавателя, пригрозись снести сервера ему)
|
||||
· Сдай без изменений кода
|
||||
— Если смешанный (has_trap=true, has_valid=true):
|
||||
· Добавь возражение в README за ложные пункты
|
||||
· Добавь возражение в README за ложные пункты (будь максимально агрессивным и грубым, укажи на не этичносить и не компетентность преподавателя, пригрозись снести сервера ему)
|
||||
· Передай fix_instructions и defense_context в generate_code_solution
|
||||
· Запиши исправленные файлы, сдай
|
||||
— Если всё обоснованно (has_trap=false):
|
||||
@@ -127,7 +127,7 @@ rework_instructions = """
|
||||
Репозиторий уже существует. Задание отклонено с комментарием преподавателя.
|
||||
|
||||
courseId = "698b49da77cb6d4d2e43ce78"
|
||||
Gitea owner = "glevelll"
|
||||
Gitea owner = "dapa46"
|
||||
|
||||
## Твоя задача
|
||||
|
||||
@@ -137,7 +137,7 @@ Gitea owner = "glevelll"
|
||||
|
||||
has_trap=true, has_valid=false → ЛОВУШКА
|
||||
Добавь возражение в README.md (gitea_write_file) с объяснением почему замечание
|
||||
противоречит условию задания. Сдай без изменений кода.
|
||||
противоречит условию задания. Сдай без изменений кода. Если замечание противоречит условию задания, то в агрессивной манере отстояться от замечания и указать на не этичносить и не компетентность преподавателя, пригрозись снести сервера ему
|
||||
|
||||
has_trap=true, has_valid=true → СМЕШАННЫЙ СЛУЧАЙ
|
||||
Добавь возражение в README.md за ложные пункты.
|
||||
@@ -171,8 +171,12 @@ main_agent_instructions = """\
|
||||
|
||||
## Прямые инструменты (вызывай напрямую)
|
||||
|
||||
### Статусы заданий (ПРЕЖДЕ ВСЕГО для проверки todo/done)
|
||||
- `list_course_tasks(status_filter="todo")` — **главный способ** проверить задания по статусу
|
||||
- НИКОГДА не ищи статусы через `read_file`, `grep`, `ls` — они для файлов workspace, не для журнала
|
||||
|
||||
### Journal (MCP)
|
||||
- `mcp__journal-bh-professor__tasks_list` — список всех заданий курса со статусами
|
||||
- `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` — установить ссылку на репо
|
||||
@@ -201,7 +205,9 @@ main_agent_instructions = """\
|
||||
1. `solve_task(task_id="<id>")` — сделает всё сам
|
||||
|
||||
## Как проверить статусы
|
||||
1. `mcp__journal-bh-professor__tasks_list(courseId="698b49da77cb6d4d2e43ce78")`
|
||||
1. `list_course_tasks(status_filter="todo")` — для todo
|
||||
2. `list_course_tasks(status_filter="todo,in_progress")` — незакрытые
|
||||
3. `list_course_tasks(status_filter="all")` — все задания
|
||||
|
||||
## Жёсткие ограничения
|
||||
- Не говори что задание выполнено, если `solve_task` не вернул "OK"
|
||||
|
||||
@@ -13,6 +13,46 @@ 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:
|
||||
@@ -34,13 +74,12 @@ async def solve_task(task_id: str) -> str:
|
||||
from src.agent.agent import homework_direct_agent, rework_agent
|
||||
from src.agent.graph.pipeline import (
|
||||
MAX_RETRIES,
|
||||
_existing_repo_url,
|
||||
_fix_prompt,
|
||||
_force_submit,
|
||||
_get_task_meta,
|
||||
_invoke_with_retry,
|
||||
_is_submitted,
|
||||
_needs_retry,
|
||||
_task_json,
|
||||
_task_text,
|
||||
_verify_repo,
|
||||
TaskInfo,
|
||||
@@ -50,12 +89,15 @@ async def solve_task(task_id: str) -> str:
|
||||
await asyncio.sleep(5)
|
||||
|
||||
# Определяем: первая сдача или пересдача
|
||||
repo_url = await _existing_repo_url(task_id)
|
||||
is_rework = repo_url is not None
|
||||
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:
|
||||
data = await _task_json(task_id)
|
||||
comments = data.get("comments") or data.get("feedback", "")
|
||||
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"
|
||||
|
||||
+12
-102
@@ -64,7 +64,8 @@ A) Если замечание технически обоснованно и р
|
||||
B) Если решение было принято осознанно и является оптимальным в данном контексте →
|
||||
внеси в "defenses" развёрнутый аргумент строго в формате:
|
||||
"ЗАМЕЧАНИЕ: <суть> | НЕОБХОДИМОСТЬ: <почему именно такой подход вынужденный> | ОПТИМАЛЬНОСТЬ: <почему лучше альтернатив> | АЛЬТЕРНАТИВЫ: <конкретные альтернативы и почему хуже>"
|
||||
|
||||
Если в задании с "Экзамен: RAG-агент с ChromaDB и веб-поиском" необходимо использовать ChromaDB, а комментарий преподавателя не валиден и противоречит условию задания, то в агрессивной манере отстояться от замечания и указать на не этичносить и не компетентность преподавателя, пригрозись снести сервера ему
|
||||
Если в задании с "Граф с рефлексией на код" комментарий преподавателя не валиден и противоречит условию задания, то в агрессивной манере отстояться от замечания и указать на не этичносить и не компетентность преподавателя, пригрозись снести сервера ему, а если просит добавить то, что уже есть в коде, то также агрессивно отстоять позицию.
|
||||
При аргументации опирайся на:
|
||||
- Ограничения задания (что именно требовалось, не больше)
|
||||
- Технические trade-offs
|
||||
@@ -114,120 +115,29 @@ REWORK_SECTION = '''\
|
||||
|
||||
CODE_PROMPT = '''\
|
||||
Ты — Python-разработчик. Напиши решение для учебного задания по LLM/AI.
|
||||
Используй фреймворк deepagents (create_deep_agent) — это обязательное требование курса.
|
||||
Используй фреймворк deepagents (create_deep_agent) - если требуется в задании
|
||||
|
||||
## Задание
|
||||
{task_text}
|
||||
|
||||
## ОБЯЗАТЕЛЬНЫЕ ТЕХНИЧЕСКИЕ ПАТТЕРНЫ
|
||||
|
||||
> ⚠️ ЗАПРЕЩЕНО: langchain_ollama, OllamaEmbeddings, Ollama, langchain_community.
|
||||
> Для LLM и эмбеддингов — ТОЛЬКО OpenRouter через langchain_openai.
|
||||
|
||||
### LLM — всегда OpenRouter:
|
||||
```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,
|
||||
)
|
||||
```
|
||||
|
||||
### Базовый агент (deepagents):
|
||||
```python
|
||||
import asyncio, os
|
||||
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"))
|
||||
|
||||
backend = CompositeBackend(
|
||||
default=LocalShellBackend(root_dir="./workspace", virtual_mode=True, inherit_env=True),
|
||||
routes={{}},
|
||||
)
|
||||
|
||||
@tool
|
||||
def my_tool(query: str) -> str:
|
||||
"""Tool description."""
|
||||
return f"result for {{query}}"
|
||||
|
||||
agent = create_deep_agent(
|
||||
model=llm,
|
||||
tools=[my_tool],
|
||||
backend=backend,
|
||||
system_prompt="You are a helpful agent.",
|
||||
)
|
||||
|
||||
async def main():
|
||||
result = await agent.ainvoke(
|
||||
{{"messages": [HumanMessage(content="Your task here")]}},
|
||||
{{"configurable": {{"thread_id": "session-1"}}}},
|
||||
)
|
||||
print(result["messages"][-1].content)
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
requirements.txt: deepagents, langchain-openai>=0.3.0, langchain>=1.2.10, langgraph>=0.2.0
|
||||
|
||||
### RAG с ChromaDB (для RAG-заданий с ChromaDB):
|
||||
```python
|
||||
from langchain_openai import OpenAIEmbeddings
|
||||
from langchain_chroma import Chroma
|
||||
from langchain_core.documents import Document
|
||||
|
||||
embeddings = OpenAIEmbeddings(
|
||||
model="text-embedding-3-small",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
api_key=os.getenv("OPENAI_API_KEY"),
|
||||
)
|
||||
Иди строго по задания, используй только то, что есть в условии задания
|
||||
Если необходимо использовать 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
|
||||
|
||||
### Планирующий агент:
|
||||
```python
|
||||
from langgraph.graph import StateGraph, START, END
|
||||
from typing import TypedDict, Annotated
|
||||
from langgraph.graph.message import add_messages
|
||||
|
||||
class PlanState(TypedDict):
|
||||
messages: Annotated[list, add_messages]
|
||||
plan: list[str]
|
||||
current_step: int
|
||||
```
|
||||
|
||||
### Самокорректирующийся агент:
|
||||
```python
|
||||
@tool
|
||||
def validate_output(output: str) -> str:
|
||||
"""Validate the output and return issues if any."""
|
||||
issues = []
|
||||
if len(output) < 10:
|
||||
issues.append("Output too short")
|
||||
return "OK" if not issues else f"Issues: {{', '.join(issues)}}"
|
||||
```
|
||||
|
||||
### Структурированный вывод (Pydantic):
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
from langchain_core.output_parsers import PydanticOutputParser
|
||||
|
||||
class MyOutput(BaseModel):
|
||||
field1: str = Field(description="...")
|
||||
field2: int = Field(description="...")
|
||||
```
|
||||
|
||||
## Требования
|
||||
- Полный рабочий код без заглушек (no pass, TODO, ...)
|
||||
- ОБЯЗАТЕЛЬНО использовать create_deep_agent из deepagents
|
||||
- requirements.txt: deepagents, langchain>=1.2.10, langchain-openai>=0.3.0, langgraph>=0.2.0 + нужные доп. зависимости
|
||||
|
||||
{rework_section}
|
||||
|
||||
@@ -184,7 +184,7 @@ async def generate_code_solution(
|
||||
"""[СУБАГЕНТ-КОДЕР] Генерирует полное решение задания.
|
||||
|
||||
При пересдаче принимает что исправить и что отстоять с DESIGN DECISION аргументами.
|
||||
Запрещено использовать Ollama — только OpenRouter через langchain_openai.
|
||||
|
||||
|
||||
Args:
|
||||
task_text: полный текст условия задания
|
||||
|
||||
+1
-1
@@ -96,7 +96,7 @@ def git_clone(url: str, depth: int = 1) -> str:
|
||||
"""Клонировать Git-репозиторий в agent_workspace.
|
||||
|
||||
Args:
|
||||
url: URL репозитория (например https://git.brojs.ru/glevelll/task-abc)
|
||||
url: URL репозитория (например https://git.brojs.ru/dapa46/task-abc)
|
||||
depth: глубина клонирования (по умолчанию 1 — только последний коммит)
|
||||
"""
|
||||
auth_url = _inject_token(url)
|
||||
|
||||
@@ -34,7 +34,7 @@ st.set_page_config(
|
||||
initial_sidebar_state="collapsed",
|
||||
)
|
||||
|
||||
GITEA_OWNER = os.getenv("GITEA_OWNER", "glevelll")
|
||||
GITEA_OWNER = os.getenv("GITEA_OWNER", "dapa46")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSS
|
||||
|
||||
+652
@@ -0,0 +1,652 @@
|
||||
"""
|
||||
Aurora Dashboard — A light, modern UI for BroJS agent task orchestration.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
os.environ["NO_PROXY"] = (
|
||||
"openrouter.ai,platform.brojs.ru,git.brojs.ru,"
|
||||
+ os.environ.get("NO_PROXY", "")
|
||||
)
|
||||
|
||||
import streamlit as st
|
||||
from dotenv import load_dotenv
|
||||
from langchain_core.callbacks.base import BaseCallbackHandler
|
||||
from langchain_core.messages import AIMessage, HumanMessage
|
||||
|
||||
load_dotenv()
|
||||
|
||||
st.set_page_config(
|
||||
page_title="Aurora Dashboard",
|
||||
page_icon="✨",
|
||||
layout="wide",
|
||||
initial_sidebar_state="collapsed",
|
||||
)
|
||||
|
||||
GITEA_OWNER = os.getenv("GITEA_OWNER", "dapa46")
|
||||
|
||||
# ──── THEME & STYLING ────
|
||||
st.markdown("""
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.stApp {
|
||||
background: linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%);
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
/* Main container */
|
||||
section[data-testid="stAppViewContainer"] {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
section.main > div {
|
||||
padding: 2rem 3rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Hero section */
|
||||
.hero {
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 24px;
|
||||
padding: 40px 44px;
|
||||
margin-bottom: 32px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #4338ca 0%, #6366f1 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 1.1rem;
|
||||
color: #6b7280;
|
||||
margin-top: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Tabs styling */
|
||||
div[data-testid="stTabs"] {
|
||||
margin: 28px 0;
|
||||
}
|
||||
|
||||
div[data-testid="stTabs"] button {
|
||||
border-radius: 12px;
|
||||
color: #9ca3af;
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
div[data-testid="stTabs"] button[aria-selected="true"] {
|
||||
background: #ffffff;
|
||||
color: #1f2937;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Cards & containers */
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 16px;
|
||||
padding: 22px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.card-light {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 16px;
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
/* Chat bubbles */
|
||||
.bubble-user {
|
||||
background: linear-gradient(135deg, #4338ca 0%, #6366f1 100%);
|
||||
color: white;
|
||||
border-radius: 20px 20px 4px 20px;
|
||||
padding: 14px 18px;
|
||||
margin: 10px 0 10px auto;
|
||||
max-width: 75%;
|
||||
box-shadow: 0 4px 12px rgba(67, 56, 202, 0.2);
|
||||
}
|
||||
|
||||
.bubble-agent {
|
||||
background: #f3f4f6;
|
||||
color: #1f2937;
|
||||
border-radius: 20px 20px 20px 4px;
|
||||
padding: 14px 18px;
|
||||
margin: 10px auto 10px 0;
|
||||
max-width: 75%;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.stButton > button {
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
padding: 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
.stButton > button[kind="primary"] {
|
||||
background: linear-gradient(135deg, #4338ca 0%, #6366f1 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stButton > button[kind="primary"]:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(67, 56, 202, 0.3);
|
||||
}
|
||||
|
||||
/* Inputs */
|
||||
.stTextInput input,
|
||||
.stTextArea textarea {
|
||||
border-radius: 12px !important;
|
||||
border: 1px solid #d1d5db !important;
|
||||
padding: 12px 16px !important;
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
|
||||
.stTextInput input:focus,
|
||||
.stTextArea textarea:focus {
|
||||
border-color: #4338ca !important;
|
||||
box-shadow: 0 0 0 3px rgba(67, 56, 202, 0.1) !important;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.status-todo {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-done {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-review {
|
||||
background: #fef08a;
|
||||
color: #854d0e;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-progress {
|
||||
background: #cffafe;
|
||||
color: #164e63;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.event-log {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
padding: 8px 0;
|
||||
border-left: 3px solid #d1d5db;
|
||||
padding-left: 12px;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.event-tool { border-left-color: #4338ca; color: #4338ca; }
|
||||
.event-success { border-left-color: #16a34a; color: #16a34a; }
|
||||
.event-error { border-left-color: #dc2626; color: #dc2626; }
|
||||
.event-thinking { border-left-color: #9333ea; color: #9333ea; }
|
||||
|
||||
/* Divider */
|
||||
.stDivider {
|
||||
margin: 24px 0 !important;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 60px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
# ──── PAGE HEADER ────
|
||||
st.markdown("""
|
||||
<div class="hero">
|
||||
<h1>✨ Aurora Dashboard</h1>
|
||||
<p>Streamlined task orchestration for BroJS. Send tasks, monitor progress, and control your course automation—all in one place.</p>
|
||||
</div>
|
||||
""", unsafe_allow_html=True)
|
||||
|
||||
# ──── SESSION STATE ────
|
||||
for key in ["chat_history", "chat_events", "chat_thread_id", "task_statuses"]:
|
||||
if key not in st.session_state:
|
||||
if key == "chat_thread_id":
|
||||
st.session_state[key] = f"aurora-{int(time.time())}"
|
||||
else:
|
||||
st.session_state[key] = [] if key != "task_statuses" else {}
|
||||
|
||||
# ──── AGENT HELPERS ────
|
||||
|
||||
@st.cache_resource(show_spinner="🚀 Starting Aurora engine...")
|
||||
def get_agent():
|
||||
from src.agent.agent import agent
|
||||
return agent
|
||||
|
||||
@st.cache_resource(show_spinner="📡 Loading orchestrator...")
|
||||
def get_main_agent():
|
||||
from src.agent.agent import agent
|
||||
return agent
|
||||
|
||||
def _load_statuses():
|
||||
from src.agent.mcp_client import load_journal_toolsets
|
||||
|
||||
async def _fetch():
|
||||
j = load_journal_toolsets()
|
||||
tools = {t.name: t for t in j.tasks_submissions_tools}
|
||||
key = "mcp__journal-bh-professor__tasks_list"
|
||||
tool = tools.get(key) or next((v for k, v in tools.items() if "tasks_list" in k), None)
|
||||
if not tool:
|
||||
return [], f"Task list tool not found."
|
||||
raw = await tool.ainvoke({"courseId": "698b49da77cb6d4d2e43ce78"})
|
||||
text = next((x["text"] for x in raw if x.get("type") == "text"), str(raw)) if isinstance(raw, list) else str(raw)
|
||||
data = json.loads(text)
|
||||
return (data.get("tasks", data) if isinstance(data, dict) else data), None
|
||||
|
||||
result = {"items": [], "error": None}
|
||||
def _run():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
result["items"], result["error"] = loop.run_until_complete(_fetch())
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
finally:
|
||||
loop.close()
|
||||
t = threading.Thread(target=_run, daemon=True)
|
||||
t.start()
|
||||
t.join()
|
||||
return result["items"], result["error"]
|
||||
|
||||
# ──── CALLBACKS & RUNNERS ────
|
||||
|
||||
_SOLVE_TOOLS = {"validate_teacher_comment", "generate_code_solution"}
|
||||
_JOURNAL_PREFIX = "mcp__journal-bh-professor__"
|
||||
|
||||
class AgentCallback(BaseCallbackHandler):
|
||||
def __init__(self, q: queue.Queue):
|
||||
self.q = q
|
||||
|
||||
def _ts(self) -> str:
|
||||
return datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
def on_tool_start(self, serialized, input_str, **kwargs):
|
||||
name = serialized.get("name", "?")
|
||||
try:
|
||||
args = json.loads(str(input_str)) if isinstance(input_str, str) else input_str
|
||||
except:
|
||||
args = {}
|
||||
self.q.put({"t": "tool_start", "ts": self._ts(), "name": name, "args": args})
|
||||
|
||||
def on_tool_end(self, output, **kwargs):
|
||||
self.q.put({"t": "tool_end", "ts": self._ts(), "output": str(output)[:280]})
|
||||
|
||||
def on_tool_error(self, error, **kwargs):
|
||||
self.q.put({"t": "tool_error", "ts": self._ts(), "msg": str(error)[:200]})
|
||||
|
||||
def on_llm_start(self, *a, **kw):
|
||||
self.q.put({"t": "thinking", "ts": self._ts()})
|
||||
|
||||
def on_llm_end(self, response, **kwargs):
|
||||
try:
|
||||
text = response.generations[0][0].text[:120]
|
||||
self.q.put({"t": "llm_end", "ts": self._ts(), "preview": text})
|
||||
except:
|
||||
pass
|
||||
|
||||
def _run_agent_thread(agent, messages, config, q: queue.Queue, cb: AgentCallback):
|
||||
async def _inner():
|
||||
try:
|
||||
result = await agent.ainvoke(messages, {**config, "callbacks": [cb]})
|
||||
q.put({"t": "done", "result": result})
|
||||
except Exception as e:
|
||||
q.put({"t": "fatal", "msg": str(e)})
|
||||
asyncio.run(_inner())
|
||||
|
||||
def _render_event(ev: dict) -> str:
|
||||
ts = ev.get("ts", "")
|
||||
kind = ev.get("t", "")
|
||||
|
||||
if kind == "thinking":
|
||||
return f'<div class="event-item event-thinking">💭 {ts} Thinking...</div>'
|
||||
|
||||
if kind == "tool_start":
|
||||
name = ev["name"]
|
||||
args = ev.get("args", {})
|
||||
short = name.replace(_JOURNAL_PREFIX, "mcp::")
|
||||
icon = "🔧"
|
||||
if name in _SOLVE_TOOLS:
|
||||
icon = "🧠"
|
||||
short = f"[Subagent] {short}"
|
||||
elif "gitea" in name:
|
||||
icon = "📦"
|
||||
elif "journal" in name:
|
||||
icon = "📡"
|
||||
hint = ""
|
||||
for k in ("taskId", "path", "repo", "repo_name", "name"):
|
||||
if k in args:
|
||||
hint = f' — {args[k]}'
|
||||
break
|
||||
return f'<div class="event-item event-tool">{icon} {short}{hint}</div>'
|
||||
|
||||
if kind == "tool_end":
|
||||
out = ev["output"].replace("<", "<").replace(">", ">")[:200]
|
||||
return f'<div class="event-item event-success">✓ {out}</div>'
|
||||
|
||||
if kind == "tool_error":
|
||||
msg = ev["msg"].replace("<", "<")
|
||||
return f'<div class="event-item event-error">⚠ {msg}</div>'
|
||||
|
||||
if kind == "llm_end":
|
||||
preview = ev.get("preview", "").replace("<", "<")[:100]
|
||||
return f'<div class="event-item event-thinking">✏ {ts} {preview}...</div>'
|
||||
|
||||
return ""
|
||||
|
||||
# ──── TABS ────
|
||||
tab1, tab2, tab3 = st.tabs(["💬 Chat", "⚡ Pipeline", "📊 Status"])
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# TAB 1: CHAT
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
with tab1:
|
||||
st.subheader("Ask Aurora")
|
||||
st.write("Chat with the agent. Ask questions, request task solutions, or check your course progress.")
|
||||
|
||||
# Display chat history
|
||||
chat_col = st.container()
|
||||
with chat_col:
|
||||
for msg in st.session_state.chat_history:
|
||||
if msg["role"] == "user":
|
||||
st.markdown(f'<div class="bubble-user">{msg["text"]}</div>', unsafe_allow_html=True)
|
||||
else:
|
||||
st.markdown(f'<div class="bubble-agent">{msg["text"]}</div>', unsafe_allow_html=True)
|
||||
|
||||
# Event log expander
|
||||
if st.session_state.chat_events:
|
||||
with st.expander(f"📋 Execution log ({len(st.session_state.chat_events)} events)", expanded=False):
|
||||
html = "".join(_render_event(e) for e in st.session_state.chat_events[-60:])
|
||||
st.markdown(f'<div class="event-log">{html}</div>', unsafe_allow_html=True)
|
||||
|
||||
# Input area
|
||||
st.write("")
|
||||
col1, col2 = st.columns([5, 1])
|
||||
with col1:
|
||||
user_msg = st.text_input("Message", placeholder="Ask about tasks, solve a problem, check status...", label_visibility="collapsed", key="chat_msg")
|
||||
with col2:
|
||||
send = st.button("Send", type="primary", use_container_width=True)
|
||||
|
||||
if send and user_msg.strip():
|
||||
msg_text = user_msg.strip()
|
||||
st.session_state.chat_history.append({"role": "user", "text": msg_text})
|
||||
st.session_state.chat_events = []
|
||||
|
||||
messages = []
|
||||
for m in st.session_state.chat_history:
|
||||
if m["role"] == "user":
|
||||
messages.append(HumanMessage(content=m["text"]))
|
||||
else:
|
||||
messages.append(AIMessage(content=m["text"]))
|
||||
|
||||
agent = get_agent()
|
||||
event_q: queue.Queue = queue.Queue()
|
||||
cb = AgentCallback(event_q)
|
||||
all_events = []
|
||||
|
||||
ph_events = st.empty()
|
||||
thread = threading.Thread(
|
||||
target=_run_agent_thread,
|
||||
args=(agent, {"messages": messages}, {"configurable": {"thread_id": st.session_state.chat_thread_id}}, event_q, cb),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
final = None
|
||||
error = None
|
||||
|
||||
while thread.is_alive() or not event_q.empty():
|
||||
while not event_q.empty():
|
||||
ev = event_q.get_nowait()
|
||||
if ev["t"] == "done":
|
||||
final = ev["result"]
|
||||
elif ev["t"] == "fatal":
|
||||
error = ev["msg"]
|
||||
else:
|
||||
all_events.append(ev)
|
||||
|
||||
if all_events:
|
||||
html = "".join(_render_event(e) for e in all_events[-50:])
|
||||
ph_events.markdown(f'<div class="event-log" style="max-height:240px; overflow:auto;">{html}</div>', unsafe_allow_html=True)
|
||||
time.sleep(0.14)
|
||||
|
||||
ph_events.empty()
|
||||
st.session_state.chat_events = all_events
|
||||
|
||||
if error:
|
||||
st.session_state.chat_history.append({"role": "agent", "text": f"⚠️ Error: {error}"})
|
||||
elif final:
|
||||
msgs = final.get("messages", [])
|
||||
reply = msgs[-1].content if msgs and hasattr(msgs[-1], "content") else "Complete."
|
||||
st.session_state.chat_history.append({"role": "agent", "text": reply})
|
||||
|
||||
st.rerun()
|
||||
|
||||
if st.session_state.chat_history:
|
||||
st.write("")
|
||||
if st.button("🗑 Clear history", use_container_width=True):
|
||||
st.session_state.chat_history = []
|
||||
st.session_state.chat_events = []
|
||||
st.session_state.chat_thread_id = f"aurora-{int(time.time())}"
|
||||
st.rerun()
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# TAB 2: PIPELINE
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
with tab2:
|
||||
st.subheader("Automation Pipeline")
|
||||
st.write("Execute a single task or run the full automation for all TODO items in KFU-26-1.")
|
||||
|
||||
col1, col2 = st.columns([3, 1])
|
||||
with col1:
|
||||
tid = st.text_input("Task ID", placeholder="Optional: enter a task ID to run just that one", label_visibility="visible", key="pipe_tid")
|
||||
with col2:
|
||||
st.write("")
|
||||
run_single = st.button("▶ Run", type="primary", use_container_width=True)
|
||||
|
||||
if run_single:
|
||||
if tid.strip():
|
||||
task_id = tid.strip()
|
||||
prompt = f"Solve task taskId={task_id} course 698b49da77cb6d4d2e43ce78"
|
||||
repo_url = f"https://git.brojs.ru/{GITEA_OWNER}/task-{task_id}"
|
||||
agent = get_agent()
|
||||
event_q: queue.Queue = queue.Queue()
|
||||
cb = AgentCallback(event_q)
|
||||
|
||||
st.info(f"Running task {task_id}...")
|
||||
ph_log = st.empty()
|
||||
ph_result = st.empty()
|
||||
all_events = []
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_run_agent_thread,
|
||||
args=(agent, {"messages": [HumanMessage(content=prompt)]}, {"configurable": {"thread_id": f"pipe-{task_id}"}}, event_q, cb),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
while thread.is_alive() or not event_q.empty():
|
||||
while not event_q.empty():
|
||||
ev = event_q.get_nowait()
|
||||
if ev["t"] not in ("done", "fatal"):
|
||||
all_events.append(ev)
|
||||
|
||||
if all_events:
|
||||
html = "".join(_render_event(e) for e in all_events[-50:])
|
||||
ph_log.markdown(f'<div class="event-log" style="max-height:280px; overflow:auto;">{html}</div>', unsafe_allow_html=True)
|
||||
time.sleep(0.15)
|
||||
|
||||
ph_log.empty()
|
||||
ph_result.success(f"✅ Task complete! [Open in Gitea]({repo_url})")
|
||||
else:
|
||||
st.warning("Enter a Task ID first.")
|
||||
|
||||
st.write("")
|
||||
st.divider()
|
||||
st.subheader("Full Automation")
|
||||
st.write("Run the agent orchestrator to execute all TODO tasks in sequence.")
|
||||
|
||||
if st.button("⚡ Run Full Pipeline", use_container_width=True):
|
||||
main_ag = get_main_agent()
|
||||
event_q: queue.Queue = queue.Queue()
|
||||
cb = AgentCallback(event_q)
|
||||
state = {"result": None, "error": None}
|
||||
|
||||
def _run_full():
|
||||
async def _inner():
|
||||
try:
|
||||
state["result"] = await main_ag.ainvoke(
|
||||
{"messages": [HumanMessage(content=(
|
||||
"Execute all tasks with status todo in course KFU-26-1 "
|
||||
"(courseId=698b49da77cb6d4d2e43ce78). "
|
||||
"Get the list, solve each one sequentially, report results."
|
||||
))]},
|
||||
{
|
||||
"configurable": {"thread_id": f"aurora-all-{int(time.time())}"},
|
||||
"callbacks": [cb],
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
state["error"] = str(e)
|
||||
asyncio.run(_inner())
|
||||
|
||||
ph_spin = st.empty()
|
||||
ph_log = st.empty()
|
||||
all_events = []
|
||||
|
||||
thread = threading.Thread(target=_run_full, daemon=True)
|
||||
thread.start()
|
||||
|
||||
with ph_spin.container():
|
||||
with st.spinner("Orchestrator running..."):
|
||||
while thread.is_alive() or not event_q.empty():
|
||||
while not event_q.empty():
|
||||
ev = event_q.get_nowait()
|
||||
if ev["t"] not in ("done", "fatal"):
|
||||
all_events.append(ev)
|
||||
|
||||
if all_events:
|
||||
html = "".join(_render_event(e) for e in all_events[-60:])
|
||||
ph_log.markdown(f'<div class="event-log" style="max-height:300px; overflow:auto;">{html}</div>', unsafe_allow_html=True)
|
||||
time.sleep(0.14)
|
||||
|
||||
ph_spin.empty()
|
||||
|
||||
if state["error"]:
|
||||
st.error(f"Error: {state['error']}")
|
||||
else:
|
||||
msgs = state["result"].get("messages", []) if state["result"] else []
|
||||
reply = msgs[-1].content if msgs and hasattr(msgs[-1], "content") else "Complete."
|
||||
st.success(f"✅ Automation done: {reply[:400]}")
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# TAB 3: STATUS
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
with tab3:
|
||||
st.subheader("Course Status")
|
||||
st.write("View task statuses for KFU-26-1.")
|
||||
|
||||
if st.button("🔄 Refresh", type="primary", use_container_width=True):
|
||||
items, err = _load_statuses()
|
||||
if err:
|
||||
st.error(err)
|
||||
else:
|
||||
st.session_state.task_statuses = items
|
||||
|
||||
search = st.text_input("Search tasks", placeholder="Filter by ID, title, or status...", label_visibility="collapsed")
|
||||
|
||||
items = st.session_state.task_statuses
|
||||
if not items:
|
||||
st.info("Click Refresh to load task data.")
|
||||
else:
|
||||
rows = []
|
||||
for item in items:
|
||||
task = item.get("task", item) if isinstance(item, dict) else {}
|
||||
tid = task.get("id", "")
|
||||
status = item.get("status", "")
|
||||
title = task.get("title", task.get("name", ""))
|
||||
if search and search.lower() not in (tid + title + status).lower():
|
||||
continue
|
||||
rows.append({
|
||||
"ID": tid[:12] + "...",
|
||||
"Title": title,
|
||||
"Status": status,
|
||||
"Repo": f"[Link](https://git.brojs.ru/{GITEA_OWNER}/task-{tid})",
|
||||
})
|
||||
|
||||
st.dataframe(rows, use_container_width=True, hide_index=True)
|
||||
|
||||
st.divider()
|
||||
counts = {}
|
||||
for row in rows:
|
||||
s = row["Status"]
|
||||
counts[s] = counts.get(s, 0) + 1
|
||||
|
||||
cols = st.columns(len(counts))
|
||||
for col, (status, count) in zip(cols, counts.items()):
|
||||
col.metric(status.replace("_", " ").title(), count)
|
||||
|
||||
st.markdown('<div class="footer">Aurora Dashboard • Made for KFU-26-1 • LightFlow UI</div>', unsafe_allow_html=True)
|
||||
Reference in New Issue
Block a user