Загрузить файлы в «/»
This commit is contained in:
@@ -0,0 +1,128 @@
|
|||||||
|
# 🤖 Агент решения домашних заданий
|
||||||
|
|
||||||
|
Берёт задания из журнала через **MCP**, решает их с помощью LLM, заливает на **git.brojs.ru** и отправляет ссылку преподавателю.
|
||||||
|
Общаться с агентом можно через чат на `localhost:8123` — так же, как у преподавателя.
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
homework_agent/
|
||||||
|
├── agent.py # Граф агента + CLI (точка входа для langgraph dev)
|
||||||
|
├── llm.py # Фабрика LLM: openai (пары) ↔ gigachat (дома)
|
||||||
|
├── mcp_clients.py # Загрузка MCP-инструментов (journal + gitea)
|
||||||
|
├── gitea_rest.py # REST fallback для Gitea (если uvx недоступен)
|
||||||
|
├── langgraph.json # Конфиг для langgraph dev
|
||||||
|
├── requirements.txt
|
||||||
|
├── .env.example
|
||||||
|
└── .env # ← создай сам, не коммить!
|
||||||
|
```
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Виртуальное окружение
|
||||||
|
python -m venv .venv
|
||||||
|
.venv\Scripts\activate # Windows
|
||||||
|
# source .venv/bin/activate # Mac/Linux
|
||||||
|
|
||||||
|
# 2. Зависимости
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 3. uv (нужен для Gitea MCP, если будешь использовать)
|
||||||
|
pip install uv
|
||||||
|
```
|
||||||
|
|
||||||
|
## Настройка .env
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# открой .env и заполни значения
|
||||||
|
```
|
||||||
|
|
||||||
|
### Токен журнала — `JOURNAL_MCP_PAT` (самое важное!)
|
||||||
|
|
||||||
|
Без него агент не видит задания. Спроси у преподавателя или найди сам:
|
||||||
|
|
||||||
|
1. Зайди на `https://platform.brojs.ru` (авторизуйся)
|
||||||
|
2. Открой DevTools (`F12`) → вкладка **Network**
|
||||||
|
3. Обнови страницу, кликни на любой запрос к `/jrnl-bh/api/...`
|
||||||
|
4. В заголовках найди `Authorization: Bearer <токен>` — это и есть `JOURNAL_MCP_PAT`
|
||||||
|
|
||||||
|
### Токен Gitea — `GITEA_TOKEN`
|
||||||
|
|
||||||
|
1. Зайди на https://git.brojs.ru (авторизуйся)
|
||||||
|
2. Аватарка → **Settings** → **Applications** → **Generate New Token**
|
||||||
|
3. Поставь права: `repository: read & write`
|
||||||
|
4. Скопируй в `.env` как `GITEA_TOKEN`
|
||||||
|
5. Туда же `GITEA_USERNAME` — твой логин на git.brojs.ru
|
||||||
|
|
||||||
|
### Переключение LLM
|
||||||
|
|
||||||
|
**На парах (gpt-oss-20b):**
|
||||||
|
```env
|
||||||
|
LLM_PROVIDER=openai
|
||||||
|
OPENAI_BASE_URL=https://89fe241d-b6f4-4e49-b3d5-9ed3c91dbe2f.tunnel4.com/v1
|
||||||
|
OPENAI_MODEL=openi/gpt-oss-20b
|
||||||
|
OPENAI_API_KEY=fake
|
||||||
|
```
|
||||||
|
> ⚠️ URL туннеля меняется каждый день — спрашивай актуальный у преподавателя
|
||||||
|
|
||||||
|
**Дома (GigaChat, бесплатно):**
|
||||||
|
```env
|
||||||
|
LLM_PROVIDER=gigachat
|
||||||
|
GIGACHAT_CREDENTIALS=твои_credentials
|
||||||
|
GIGACHAT_SCOPE=GIGACHAT_API_B2B
|
||||||
|
```
|
||||||
|
Получить credentials: https://developers.sber.ru/studio/workspaces/my-space/get-token/gigachat
|
||||||
|
|
||||||
|
## 🚀 Запуск чата (localhost:8123)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
langgraph dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Открой в браузере `http://localhost:8123` — появится чат. Можно писать:
|
||||||
|
- *«покажи мои задания»*
|
||||||
|
- *«реши задание с id=abc123»*
|
||||||
|
- *«какие задания есть в курсе xyz?»*
|
||||||
|
|
||||||
|
## Запуск из консоли (без чата)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Показать список заданий
|
||||||
|
python agent.py --list
|
||||||
|
|
||||||
|
# Решить конкретное задание
|
||||||
|
python agent.py --task-id <id>
|
||||||
|
|
||||||
|
# Решить задания курса
|
||||||
|
python agent.py --course-id <id>
|
||||||
|
|
||||||
|
# Посмотреть решение без отправки
|
||||||
|
python agent.py --task-id <id> --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Как это работает
|
||||||
|
|
||||||
|
```
|
||||||
|
Ты пишешь в чат
|
||||||
|
↓
|
||||||
|
agent_graph (LangGraph ReAct)
|
||||||
|
↓ читает задания
|
||||||
|
mcp__journal-mcp__tasks_list / task_get
|
||||||
|
↓ пишет код (LLM)
|
||||||
|
gpt-oss-20b / GigaChat
|
||||||
|
↓ заливает на Gitea
|
||||||
|
upload_solution → git.brojs.ru/username/dz
|
||||||
|
↓ отправляет ссылку
|
||||||
|
mcp__journal-mcp__submission_teacher_set_status
|
||||||
|
```
|
||||||
|
|
||||||
|
## Частые проблемы
|
||||||
|
|
||||||
|
| Ошибка | Решение |
|
||||||
|
|--------|---------|
|
||||||
|
| `JOURNAL_MCP_PAT не задан` | Добавь токен в `.env` |
|
||||||
|
| `FileNotFoundError: uvx` | `pip install uv` или работает REST fallback |
|
||||||
|
| LLM не отвечает | Проверь `OPENAI_BASE_URL` — URL туннеля мог смениться |
|
||||||
|
| `langgraph: command not found` | `pip install langgraph-cli[inmem]` |
|
||||||
+383
@@ -0,0 +1,383 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>HW Agent</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Onest:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
:root {
|
||||||
|
--bg: #0d0f14; --surface: #14171f; --surface2: #1c2030;
|
||||||
|
--border: rgba(255,255,255,0.07); --border2: rgba(255,255,255,0.12);
|
||||||
|
--text: #e8eaf0; --muted: #6b7280;
|
||||||
|
--accent: #7c6af5; --accent2: #5b4fd4;
|
||||||
|
--green: #34d399; --amber: #fbbf24; --red: #f87171;
|
||||||
|
--mono: 'JetBrains Mono', monospace; --sans: 'Onest', sans-serif;
|
||||||
|
}
|
||||||
|
body { background: var(--bg); color: var(--text); font-family: var(--sans); height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
|
||||||
|
.topbar { display: flex; align-items: center; gap: 12px; padding: 14px 20px; border-bottom: 1px solid var(--border); background: var(--surface); flex-shrink: 0; }
|
||||||
|
.logo { font-family: var(--mono); font-size: 14px; font-weight: 600; color: var(--accent); display: flex; align-items: center; gap: 8px; }
|
||||||
|
.logo-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: pulse 2s ease-in-out infinite; }
|
||||||
|
@keyframes pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:0.5;transform:scale(0.8)} }
|
||||||
|
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.status-badge { font-family: var(--mono); font-size: 11px; padding: 4px 10px; border-radius: 20px; border: 1px solid var(--border2); color: var(--muted); display: flex; align-items: center; gap: 5px; cursor: pointer; transition: all 0.2s; }
|
||||||
|
.status-badge:hover { border-color: var(--accent); color: var(--text); }
|
||||||
|
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--red); transition: background 0.3s; }
|
||||||
|
.status-dot.connected { background: var(--green); }
|
||||||
|
.new-thread-btn { font-family: var(--mono); font-size: 11px; padding: 4px 12px; border-radius: 6px; border: 1px solid var(--border2); background: transparent; color: var(--muted); cursor: pointer; transition: all 0.2s; }
|
||||||
|
.new-thread-btn:hover { background: var(--surface2); color: var(--text); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.main { display: flex; flex: 1; overflow: hidden; }
|
||||||
|
|
||||||
|
.sidebar { width: 220px; background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; }
|
||||||
|
.sidebar-title { font-family: var(--mono); font-size: 10px; letter-spacing: 0.1em; color: var(--muted); padding: 14px 16px 8px; text-transform: uppercase; }
|
||||||
|
.quick-btn { display: flex; align-items: center; gap: 8px; padding: 9px 16px; font-size: 13px; color: var(--muted); cursor: pointer; transition: all 0.15s; border: none; background: none; text-align: left; width: 100%; font-family: var(--sans); }
|
||||||
|
.quick-btn:hover { background: var(--surface2); color: var(--text); }
|
||||||
|
.sidebar-sep { height: 1px; background: var(--border); margin: 8px 0; }
|
||||||
|
.sidebar-bottom { margin-top: auto; padding: 12px 16px; font-family: var(--mono); font-size: 10px; color: var(--muted); }
|
||||||
|
.model-tag { background: var(--surface2); border: 1px solid var(--border2); border-radius: 4px; padding: 4px 8px; margin-top: 6px; display: flex; align-items: center; gap: 5px; }
|
||||||
|
|
||||||
|
.chat-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.messages { flex: 1; overflow-y: auto; padding: 24px 28px; display: flex; flex-direction: column; gap: 20px; scroll-behavior: smooth; }
|
||||||
|
.messages::-webkit-scrollbar { width: 4px; }
|
||||||
|
.messages::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
|
||||||
|
|
||||||
|
.welcome { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; gap: 12px; }
|
||||||
|
.welcome h2 { font-size: 16px; font-weight: 500; color: var(--muted); }
|
||||||
|
.welcome p { font-size: 13px; color: var(--muted); opacity: 0.6; font-family: var(--mono); }
|
||||||
|
.suggestions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; justify-content: center; max-width: 500px; }
|
||||||
|
.suggestion { padding: 6px 14px; background: var(--surface2); border: 1px solid var(--border2); border-radius: 20px; font-size: 12px; color: var(--muted); cursor: pointer; transition: all 0.15s; font-family: var(--mono); }
|
||||||
|
.suggestion:hover { border-color: var(--accent); color: var(--accent); background: rgba(124,106,245,0.08); }
|
||||||
|
|
||||||
|
.msg { display: flex; gap: 12px; animation: fadeUp 0.2s ease-out; }
|
||||||
|
.msg.user { flex-direction: row-reverse; }
|
||||||
|
@keyframes fadeUp { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:translateY(0)} }
|
||||||
|
.avatar { width: 28px; height: 28px; border-radius: 6px; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; flex-shrink: 0; font-family: var(--mono); }
|
||||||
|
.avatar.ai { background: rgba(124,106,245,0.15); color: var(--accent); border: 1px solid rgba(124,106,245,0.3); }
|
||||||
|
.avatar.user { background: var(--surface2); color: var(--muted); border: 1px solid var(--border2); }
|
||||||
|
.bubble { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 12px 16px; font-size: 14px; line-height: 1.6; max-width: 680px; }
|
||||||
|
.msg.user .bubble { background: var(--surface2); border-color: var(--border2); font-family: var(--mono); font-size: 13px; }
|
||||||
|
|
||||||
|
.repo-link { display: inline-flex; align-items: center; gap: 6px; background: rgba(52,211,153,0.08); border: 1px solid rgba(52,211,153,0.2); border-radius: 6px; padding: 8px 14px; margin: 6px 0; font-family: var(--mono); font-size: 12px; color: var(--green); text-decoration: none; transition: all 0.15s; word-break: break-all; display: block; }
|
||||||
|
.repo-link:hover { background: rgba(52,211,153,0.15); }
|
||||||
|
|
||||||
|
.typing { display: flex; gap: 4px; align-items: center; }
|
||||||
|
.typing span { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); animation: blink 1.2s ease-in-out infinite; }
|
||||||
|
.typing span:nth-child(2){animation-delay:0.2s} .typing span:nth-child(3){animation-delay:0.4s}
|
||||||
|
@keyframes blink { 0%,80%,100%{opacity:0.3;transform:scale(0.8)} 40%{opacity:1;transform:scale(1)} }
|
||||||
|
|
||||||
|
.input-area { padding: 16px 28px 20px; border-top: 1px solid var(--border); background: var(--surface); flex-shrink: 0; }
|
||||||
|
.input-wrap { display: flex; gap: 10px; align-items: flex-end; background: var(--bg); border: 1px solid var(--border2); border-radius: 12px; padding: 10px 12px 10px 16px; transition: border-color 0.2s; }
|
||||||
|
.input-wrap:focus-within { border-color: var(--accent); }
|
||||||
|
textarea { flex: 1; background: transparent; border: none; outline: none; color: var(--text); font-family: var(--mono); font-size: 13px; line-height: 1.5; resize: none; max-height: 120px; min-height: 20px; }
|
||||||
|
textarea::placeholder { color: var(--muted); }
|
||||||
|
.send-btn { width: 32px; height: 32px; border-radius: 8px; background: var(--accent); border: none; color: white; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; flex-shrink: 0; font-size: 14px; }
|
||||||
|
.send-btn:hover { background: var(--accent2); transform: scale(1.05); }
|
||||||
|
.send-btn:disabled { background: var(--surface2); cursor: not-allowed; transform: none; }
|
||||||
|
.input-hint { font-family: var(--mono); font-size: 10px; color: var(--muted); margin-top: 6px; padding: 0 4px; opacity: 0.5; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 100; backdrop-filter: blur(4px); }
|
||||||
|
.modal-overlay.hidden { display: none; }
|
||||||
|
.modal { background: var(--surface); border: 1px solid var(--border2); border-radius: 14px; padding: 24px; width: 440px; max-width: 90vw; }
|
||||||
|
.modal h3 { font-family: var(--mono); font-size: 14px; color: var(--accent); margin-bottom: 16px; }
|
||||||
|
.modal label { font-size: 12px; color: var(--muted); font-family: var(--mono); display: block; margin-bottom: 6px; }
|
||||||
|
.modal input, .modal select { width: 100%; background: var(--bg); border: 1px solid var(--border2); border-radius: 8px; padding: 9px 12px; color: var(--text); font-family: var(--mono); font-size: 13px; outline: none; margin-bottom: 14px; }
|
||||||
|
.modal input:focus, .modal select:focus { border-color: var(--accent); }
|
||||||
|
.modal select option { background: var(--surface); }
|
||||||
|
.modal-btns { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; }
|
||||||
|
.btn-cancel { padding: 8px 16px; background: transparent; border: 1px solid var(--border2); border-radius: 8px; color: var(--muted); font-family: var(--mono); font-size: 12px; cursor: pointer; }
|
||||||
|
.btn-cancel:hover { border-color: var(--border2); color: var(--text); }
|
||||||
|
.btn-ok { padding: 8px 20px; background: var(--accent); border: none; border-radius: 8px; color: white; font-family: var(--mono); font-size: 12px; cursor: pointer; }
|
||||||
|
.btn-ok:hover { background: var(--accent2); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
<div class="modal-overlay hidden" id="modalOverlay">
|
||||||
|
<div class="modal">
|
||||||
|
<h3 id="modalTitle">⚡ решить задание</h3>
|
||||||
|
<label>название задания</label>
|
||||||
|
<input type="text" id="modalInput" placeholder="например: текстовая игра на основе llm + interrupt" />
|
||||||
|
<div id="reviseExtra" style="display:none">
|
||||||
|
<label>что исправить (необязательно — агент прочитает комментарий сам)</label>
|
||||||
|
<input type="text" id="modalComment" placeholder="или оставь пустым" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-btns">
|
||||||
|
<button class="btn-cancel" onclick="closeModal()">отмена</button>
|
||||||
|
<button class="btn-ok" onclick="submitModal()">отправить ▶</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="logo"><div class="logo-dot"></div>hw_agent</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<div class="status-badge" id="statusBadge" onclick="checkConnection()">
|
||||||
|
<div class="status-dot" id="statusDot"></div>
|
||||||
|
<span id="statusText">connecting...</span>
|
||||||
|
</div>
|
||||||
|
<button class="new-thread-btn" onclick="newThread()">+ new thread</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<div class="sidebar">
|
||||||
|
<div class="sidebar-title">команды</div>
|
||||||
|
<button class="quick-btn" onclick="quickSend('покажи список всех заданий курса KFU-26-1')">📋 список заданий</button>
|
||||||
|
<button class="quick-btn" onclick="openModal('solve')">⚡ решить задание</button>
|
||||||
|
<button class="quick-btn" onclick="openModal('revise')">✏️ переделать по комменту</button>
|
||||||
|
<div class="sidebar-sep"></div>
|
||||||
|
<div class="sidebar-title">другое</div>
|
||||||
|
<button class="quick-btn" onclick="quickSend('покажи статус всех заданий')">📊 статус заданий</button>
|
||||||
|
<button class="quick-btn" onclick="quickSend('какие инструменты у тебя есть?')">🔧 инструменты</button>
|
||||||
|
<div class="sidebar-bottom">
|
||||||
|
<div style="opacity:0.5">модель</div>
|
||||||
|
<div class="model-tag"><span style="color:var(--accent)">▸</span><span id="modelName">gpt-oss-20b</span></div>
|
||||||
|
<div style="margin-top:8px;opacity:0.5">api</div>
|
||||||
|
<div class="model-tag" style="font-size:10px">localhost:2024</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-area">
|
||||||
|
<div class="messages" id="messages">
|
||||||
|
<div class="welcome" id="welcome">
|
||||||
|
<div style="font-size:36px;opacity:0.3">⬡</div>
|
||||||
|
<h2>homework agent</h2>
|
||||||
|
<p>langgraph @ localhost:2024</p>
|
||||||
|
<div class="suggestions">
|
||||||
|
<div class="suggestion" onclick="quickSend('покажи список заданий курса KFU-26-1')">список заданий</div>
|
||||||
|
<div class="suggestion" onclick="openModal('solve')">решить задание</div>
|
||||||
|
<div class="suggestion" onclick="openModal('revise')">переделать по комменту</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="input-area">
|
||||||
|
<div class="input-wrap">
|
||||||
|
<textarea id="input" placeholder="напиши агенту..." rows="1" onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
|
||||||
|
<button class="send-btn" id="sendBtn" onclick="sendMessage()">▶</button>
|
||||||
|
</div>
|
||||||
|
<div class="input-hint">enter — отправить · shift+enter — перенос строки</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = 'http://127.0.0.1:2024';
|
||||||
|
const ASSISTANT_ID = '46958879-389b-529f-bca9-2baa098385d8';
|
||||||
|
let threadId = null;
|
||||||
|
let isRunning = false;
|
||||||
|
let modalMode = 'solve';
|
||||||
|
|
||||||
|
// Modal
|
||||||
|
function openModal(mode) {
|
||||||
|
modalMode = mode;
|
||||||
|
document.getElementById('modalTitle').textContent = mode === 'solve' ? '⚡ решить задание' : '✏️ переделать задание';
|
||||||
|
document.getElementById('modalInput').placeholder = mode === 'solve'
|
||||||
|
? 'например: Агент с RAG-памятью'
|
||||||
|
: 'например: MCP-сервер для управления памятью агента';
|
||||||
|
document.getElementById('reviseExtra').style.display = mode === 'revise' ? 'block' : 'none';
|
||||||
|
document.getElementById('modalInput').value = '';
|
||||||
|
document.getElementById('modalComment').value = '';
|
||||||
|
document.getElementById('modalOverlay').classList.remove('hidden');
|
||||||
|
setTimeout(() => document.getElementById('modalInput').focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('modalOverlay').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitModal() {
|
||||||
|
const name = document.getElementById('modalInput').value.trim();
|
||||||
|
if (!name) { document.getElementById('modalInput').focus(); return; }
|
||||||
|
closeModal();
|
||||||
|
if (modalMode === 'solve') {
|
||||||
|
quickSend(`реши задание "${name}"`);
|
||||||
|
} else {
|
||||||
|
const comment = document.getElementById('modalComment').value.trim();
|
||||||
|
if (comment) {
|
||||||
|
quickSend(`переделай задание "${name}" по комментарию: ${comment}`);
|
||||||
|
} else {
|
||||||
|
quickSend(`переделай задание "${name}" по комментарию преподавателя`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('modalInput').addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); submitModal(); }
|
||||||
|
if (e.key === 'Escape') closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('modalOverlay').addEventListener('click', e => {
|
||||||
|
if (e.target === e.currentTarget) closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connection
|
||||||
|
async function checkConnection() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/assistants/${ASSISTANT_ID}`, {signal: AbortSignal.timeout(3000)});
|
||||||
|
const dot = document.getElementById('statusDot');
|
||||||
|
const txt = document.getElementById('statusText');
|
||||||
|
if (r.ok) {
|
||||||
|
dot.className = 'status-dot connected';
|
||||||
|
txt.textContent = 'connected';
|
||||||
|
} else {
|
||||||
|
dot.className = 'status-dot';
|
||||||
|
txt.textContent = 'error ' + r.status;
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
document.getElementById('statusDot').className = 'status-dot';
|
||||||
|
document.getElementById('statusText').textContent = 'offline';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newThread() {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${API}/threads`, {method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'});
|
||||||
|
if (r.ok) { const d = await r.json(); threadId = d.thread_id; }
|
||||||
|
else threadId = 'local-' + Date.now();
|
||||||
|
} catch { threadId = 'local-' + Date.now(); }
|
||||||
|
document.getElementById('messages').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
function hideWelcome() {
|
||||||
|
const w = document.getElementById('welcome');
|
||||||
|
if (w) w.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMessage(role, html) {
|
||||||
|
hideWelcome();
|
||||||
|
const msgs = document.getElementById('messages');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = `msg ${role}`;
|
||||||
|
const label = role === 'user' ? 'you' : 'ai';
|
||||||
|
div.innerHTML = `<div class="avatar ${role}">${label}</div><div class="bubble">${html}</div>`;
|
||||||
|
msgs.appendChild(div);
|
||||||
|
msgs.scrollTop = msgs.scrollHeight;
|
||||||
|
return div.querySelector('.bubble');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAiText(text) {
|
||||||
|
// Linkify git.brojs.ru URLs
|
||||||
|
return escHtml(text).replace(
|
||||||
|
/https:\/\/git\.brojs\.ru\/\S+/g,
|
||||||
|
url => `<a class="repo-link" href="${url}" target="_blank">🔗 ${url}</a>`
|
||||||
|
).replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send
|
||||||
|
async function sendMessage() {
|
||||||
|
const input = document.getElementById('input');
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!text || isRunning) return;
|
||||||
|
|
||||||
|
if (!threadId) await newThread();
|
||||||
|
|
||||||
|
input.value = '';
|
||||||
|
autoResize(input);
|
||||||
|
isRunning = true;
|
||||||
|
document.getElementById('sendBtn').disabled = true;
|
||||||
|
|
||||||
|
addMessage('user', escHtml(text));
|
||||||
|
const aiBubble = addMessage('ai', '<div class="typing"><span></span><span></span><span></span></div>');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API}/threads/${threadId}/runs/stream`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
assistant_id: ASSISTANT_ID,
|
||||||
|
input: {messages: [{role: 'user', content: text}]},
|
||||||
|
stream_mode: 'values'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
aiBubble.innerHTML = `<span style="color:var(--red)">❌ HTTP ${resp.status} — убедись что langgraph dev запущен</span>`;
|
||||||
|
isRunning = false;
|
||||||
|
document.getElementById('sendBtn').disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = resp.body.getReader();
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
let buf = '';
|
||||||
|
let lastAiText = '';
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const {done, value} = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += dec.decode(value, {stream: true});
|
||||||
|
|
||||||
|
const lines = buf.split('\n');
|
||||||
|
buf = lines.pop();
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data: ')) continue;
|
||||||
|
const raw = line.slice(6).trim();
|
||||||
|
if (!raw || raw === '[DONE]') continue;
|
||||||
|
try {
|
||||||
|
const ev = JSON.parse(raw);
|
||||||
|
// values mode: ev is the state object with messages array
|
||||||
|
const msgs = ev.messages || (Array.isArray(ev) ? ev : null);
|
||||||
|
if (msgs) {
|
||||||
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||||
|
const m = msgs[i];
|
||||||
|
const role = m.type || m.role;
|
||||||
|
if (role === 'ai' || role === 'assistant') {
|
||||||
|
const c = m.content;
|
||||||
|
if (typeof c === 'string' && c.trim()) { lastAiText = c; break; }
|
||||||
|
if (Array.isArray(c)) {
|
||||||
|
const t = c.filter(x => x.type === 'text').map(x => x.text).join('');
|
||||||
|
if (t.trim()) { lastAiText = t; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Show live update
|
||||||
|
if (lastAiText) aiBubble.innerHTML = formatAiText(lastAiText);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!lastAiText) aiBubble.innerHTML = '<span style="color:var(--muted)">агент не вернул ответ — проверь логи в терминале</span>';
|
||||||
|
else aiBubble.innerHTML = formatAiText(lastAiText);
|
||||||
|
|
||||||
|
} catch(e) {
|
||||||
|
aiBubble.innerHTML = `<span style="color:var(--red)">❌ ${escHtml(e.message)}</span><br><span style="color:var(--muted);font-family:var(--mono);font-size:12px">запусти: langgraph dev</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunning = false;
|
||||||
|
document.getElementById('sendBtn').disabled = false;
|
||||||
|
document.getElementById('messages').scrollTop = 99999;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quickSend(text) {
|
||||||
|
document.getElementById('input').value = text;
|
||||||
|
autoResize(document.getElementById('input'));
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKey(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoResize(el) {
|
||||||
|
el.style.height = 'auto';
|
||||||
|
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
checkConnection();
|
||||||
|
newThread();
|
||||||
|
setInterval(checkConnection, 15000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"dependencies": ["."],
|
||||||
|
"graphs": {
|
||||||
|
"homework_agent": "src.agent:agent_graph"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
langchain==1.2.15
|
||||||
|
langchain-core==1.3.0
|
||||||
|
langchain-openai==1.1.9
|
||||||
|
langchain-community>=0.3.0
|
||||||
|
langchain-mcp-adapters==0.2.1
|
||||||
|
langgraph==1.1.8
|
||||||
|
langgraph-cli[inmem]==0.4.23
|
||||||
|
fastmcp==3.1.0
|
||||||
|
gigachat==0.2.0
|
||||||
|
requests>=2.31.0
|
||||||
|
beautifulsoup4>=4.12.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
pydantic>=2.0.0
|
||||||
|
rich==14.3.3
|
||||||
|
markdownify==1.2.2
|
||||||
|
httpx>=0.27.0
|
||||||
Reference in New Issue
Block a user