363 lines
14 KiB
Python
363 lines
14 KiB
Python
"""
|
||
Демо-режим UI — симулирует работу агента без реальных API вызовов.
|
||
Запусти: streamlit run ui_demo.py
|
||
"""
|
||
import queue as q_mod
|
||
import threading
|
||
import time
|
||
from datetime import datetime
|
||
|
||
import streamlit as st
|
||
|
||
OWNER = "KirillKutlakhmetov"
|
||
|
||
st.set_page_config(
|
||
page_title="BroJS Agent — DEMO",
|
||
page_icon="🤖",
|
||
layout="wide",
|
||
initial_sidebar_state="collapsed",
|
||
)
|
||
|
||
st.markdown("""
|
||
<style>
|
||
.step-done {
|
||
padding: 8px 14px; border-radius: 8px; margin: 5px 0;
|
||
background: #0d2b1d; border-left: 4px solid #22c55e;
|
||
color: #4ade80; font-family: monospace; font-size: .88em;
|
||
}
|
||
.step-active {
|
||
padding: 8px 14px; border-radius: 8px; margin: 5px 0;
|
||
background: #2a1e00; border-left: 4px solid #f59e0b;
|
||
color: #fbbf24; font-family: monospace; font-size: .88em;
|
||
animation: pulse 1s infinite alternate;
|
||
}
|
||
.step-pending {
|
||
padding: 8px 14px; border-radius: 8px; margin: 5px 0;
|
||
background: #111827; border-left: 4px solid #1f2937;
|
||
color: #374151; font-family: monospace; font-size: .88em;
|
||
}
|
||
@keyframes pulse { from { opacity: 1; } to { opacity: 0.6; } }
|
||
|
||
.log-wrap {
|
||
background: #0b0f1a; border-radius: 10px; padding: 12px;
|
||
max-height: 340px; overflow-y: auto;
|
||
font-family: 'Courier New', monospace; font-size: .78em;
|
||
border: 1px solid #1f2937;
|
||
}
|
||
.log-line { padding: 3px 0; border-bottom: 1px solid #0f172a; }
|
||
.ts { color: #374151; }
|
||
.ttool { color: #818cf8; font-weight: bold; }
|
||
.tres { color: #34d399; }
|
||
.tthink { color: #6b7280; font-style: italic; }
|
||
|
||
.file-header {
|
||
background: #1e1b4b; border-radius: 6px 6px 0 0;
|
||
padding: 6px 14px; font-family: monospace;
|
||
font-size: .85em; color: #818cf8; border-bottom: 1px solid #312e81;
|
||
}
|
||
.agent-header {
|
||
background: linear-gradient(135deg, #1e1b4b 0%, #0f172a 100%);
|
||
border-radius: 12px; padding: 20px 28px; margin-bottom: 20px;
|
||
border: 1px solid #312e81;
|
||
}
|
||
.agent-title { font-size: 1.8em; font-weight: bold; color: #e2e8f0; margin: 0; }
|
||
.agent-sub { color: #6b7280; font-size: .9em; margin-top: 4px; }
|
||
.badge-ok { background:#0d2b1d; border:1px solid #22c55e; color:#4ade80; padding:10px 18px; border-radius:8px; font-family:monospace; }
|
||
.demo-banner { background:#1e1b4b; border:1px solid #4f46e5; color:#a5b4fc; padding:8px 16px; border-radius:8px; font-size:.85em; margin-bottom:12px; }
|
||
</style>
|
||
""", unsafe_allow_html=True)
|
||
|
||
st.markdown("""
|
||
<div class="agent-header">
|
||
<div class="agent-title">🤖 BroJS Agent</div>
|
||
<div class="agent-sub">Автоматическое выполнение заданий курса KFU-26-1 · platform.brojs.ru</div>
|
||
</div>
|
||
""", unsafe_allow_html=True)
|
||
|
||
st.markdown('<div class="demo-banner">🎬 DEMO-режим — реальные API не вызываются, показывает как выглядит интерфейс в работе</div>', unsafe_allow_html=True)
|
||
|
||
# ── Steps ─────────────────────────────────────────────────────────────────────
|
||
STEPS = [
|
||
("task_get", "📋 Читаю состояние задания"),
|
||
("task_text", "📄 Читаю текст задания"),
|
||
("gitea_create_repo", "📁 Создаю репозиторий"),
|
||
("gitea_write_file", "💾 Загружаю файлы"),
|
||
("task_update_answer", "🔗 Устанавливаю ответ"),
|
||
("task_submit", "🚀 Сдаю задание"),
|
||
]
|
||
_CLS = {"done": "step-done", "active": "step-active", "pending": "step-pending"}
|
||
_ICON = {"done": "✓", "active": "⟳", "pending": "○"}
|
||
|
||
|
||
def render_steps(states, file_count=0):
|
||
parts = []
|
||
for key, label in STEPS:
|
||
sv = states.get(key, "pending")
|
||
extra = (
|
||
f" <span style='opacity:.6'>({file_count} файлов)</span>"
|
||
if key == "gitea_write_file" and file_count > 0 else ""
|
||
)
|
||
parts.append(f'<div class="{_CLS[sv]}">{_ICON[sv]} {label}{extra}</div>')
|
||
return "".join(parts)
|
||
|
||
|
||
# ── Фейковые события ──────────────────────────────────────────────────────────
|
||
|
||
FAKE_MAIN_PY = '''\
|
||
import os, asyncio
|
||
from langchain_openai import ChatOpenAI
|
||
from langchain_core.messages import HumanMessage
|
||
from langchain.agents import create_agent
|
||
from langchain.tools import tool
|
||
from langchain_qdrant import QdrantVectorStore
|
||
from qdrant_client import QdrantClient
|
||
from qdrant_client.models import Distance, VectorParams
|
||
|
||
llm = ChatOpenAI(
|
||
model="openai/gpt-oss-20b:free",
|
||
base_url="https://platform.brojs.ru/jrnl-bh/api/inference/v1",
|
||
api_key=os.getenv("JOURNAL_MCP_PAT"),
|
||
temperature=0.0,
|
||
)
|
||
|
||
client = QdrantClient(":memory:")
|
||
client.create_collection(
|
||
"knowledge",
|
||
vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
|
||
)
|
||
|
||
@tool
|
||
def search_kb(query: str) -> str:
|
||
"""Search the knowledge base."""
|
||
results = vector_store.similarity_search(query, k=5)
|
||
if not results:
|
||
return "No relevant documents found."
|
||
return "\\n\\n".join(f"{i+1}. {d.page_content}" for i, d in enumerate(results))
|
||
|
||
agent = create_agent(
|
||
llm=llm,
|
||
tools=[search_kb],
|
||
system_prompt="You are a helpful RAG assistant.",
|
||
)
|
||
|
||
async def main():
|
||
result = await agent.ainvoke(
|
||
{"messages": [HumanMessage(content="What is LangChain?")]},
|
||
{"configurable": {"thread_id": "demo-1"}},
|
||
)
|
||
print(result["messages"][-1].content)
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(main())
|
||
'''
|
||
|
||
FAKE_REQUIREMENTS = """\
|
||
langchain-core>=0.3.0
|
||
langchain-openai>=0.3.0
|
||
langgraph>=0.2.0
|
||
langchain-qdrant>=0.1.0
|
||
qdrant-client>=1.7.0
|
||
python-dotenv>=1.0.0
|
||
"""
|
||
|
||
FAKE_README = """\
|
||
# RAG-агент с Qdrant
|
||
|
||
AI-ассистент с векторным поиском через Qdrant.
|
||
|
||
## Стек
|
||
| Компонент | Технология |
|
||
|-----------|-----------|
|
||
| LLM | BroJS gpt-oss-20b |
|
||
| Векторное хранилище | Qdrant in-memory |
|
||
| Фреймворк | LangChain + LangGraph |
|
||
|
||
## Установка
|
||
```bash
|
||
pip install -r requirements.txt
|
||
```
|
||
|
||
## Запуск
|
||
```bash
|
||
python main.py
|
||
```
|
||
"""
|
||
|
||
FAKE_EVENTS = [
|
||
{"delay": 0.5, "t": "thinking"},
|
||
{"delay": 1.0, "t": "tool_start", "name": "mcp__journal-bh-professor__task_get",
|
||
"inputs": {"taskId": "6a1867fa8a94f887e50d52bd"}},
|
||
{"delay": 1.2, "t": "tool_end", "output": '{"status": "todo", "answer": {"content": ""}, "comments": []}'},
|
||
{"delay": 0.4, "t": "thinking"},
|
||
{"delay": 0.8, "t": "tool_start", "name": "mcp__journal-bh-professor__task_text",
|
||
"inputs": {"taskId": "6a1867fa8a94f887e50d52bd"}},
|
||
{"delay": 1.1, "t": "tool_end", "output": "Создай RAG-агента с векторным хранилищем Qdrant и поиском по базе знаний..."},
|
||
{"delay": 0.6, "t": "thinking"},
|
||
{"delay": 1.5, "t": "tool_start", "name": "gitea_create_repo",
|
||
"inputs": {"name": "task-6a1867fa8a94f887e50d52bd", "private": False}},
|
||
{"delay": 0.9, "t": "tool_end", "output": "Репозиторий создан: https://git.brojs.ru/KirillKutlakhmetov/task-6a1867fa..."},
|
||
{"delay": 0.3, "t": "thinking"},
|
||
{"delay": 0.8, "t": "tool_start", "name": "gitea_write_file",
|
||
"inputs": {"repo": "task-6a1867fa...", "path": "main.py",
|
||
"content": FAKE_MAIN_PY, "message": "add main.py"}},
|
||
{"delay": 0.7, "t": "tool_end", "output": "Файл main.py создан в KirillKutlakhmetov/task-6a1867fa... (commit: a1b2c3d4)"},
|
||
{"delay": 0.5, "t": "tool_start", "name": "gitea_write_file",
|
||
"inputs": {"repo": "task-6a1867fa...", "path": "requirements.txt",
|
||
"content": FAKE_REQUIREMENTS, "message": "add requirements.txt"}},
|
||
{"delay": 0.6, "t": "tool_end", "output": "Файл requirements.txt создан в KirillKutlakhmetov/task-6a1867fa... (commit: b2c3d4e5)"},
|
||
{"delay": 0.5, "t": "tool_start", "name": "gitea_write_file",
|
||
"inputs": {"repo": "task-6a1867fa...", "path": "README.md",
|
||
"content": FAKE_README, "message": "add README.md"}},
|
||
{"delay": 0.6, "t": "tool_end", "output": "Файл README.md создан в KirillKutlakhmetov/task-6a1867fa... (commit: c3d4e5f6)"},
|
||
{"delay": 0.4, "t": "thinking"},
|
||
{"delay": 0.9, "t": "tool_start", "name": "mcp__journal-bh-professor__task_update_answer",
|
||
"inputs": {"taskId": "6a1867fa8a94f887e50d52bd", "answerType": "link",
|
||
"content": "https://git.brojs.ru/KirillKutlakhmetov/task-6a1867fa8a94f887e50d52bd"}},
|
||
{"delay": 0.8, "t": "tool_end", "output": '{"success": true, "message": "Answer updated"}'},
|
||
{"delay": 0.3, "t": "thinking"},
|
||
{"delay": 0.7, "t": "tool_start", "name": "mcp__journal-bh-professor__task_submit",
|
||
"inputs": {"taskId": "6a1867fa8a94f887e50d52bd", "confirmSubmit": True}},
|
||
{"delay": 1.0, "t": "tool_end", "output": '{"success": true, "message": "Task submitted for review"}'},
|
||
{"delay": 0.3, "t": "done"},
|
||
]
|
||
|
||
|
||
def _fake_step_key(tool_name):
|
||
for key, _ in STEPS:
|
||
if key in tool_name:
|
||
return key
|
||
return None
|
||
|
||
|
||
def run_fake_agent(q: q_mod.Queue):
|
||
for ev in FAKE_EVENTS:
|
||
time.sleep(ev["delay"])
|
||
q.put(ev)
|
||
|
||
|
||
# ── Layout ────────────────────────────────────────────────────────────────────
|
||
task_id = "6a1867fa8a94f887e50d52bd"
|
||
repo = f"task-{task_id}"
|
||
url = f"https://git.brojs.ru/{OWNER}/{repo}"
|
||
|
||
col_input, _ = st.columns([2, 1])
|
||
with col_input:
|
||
st.text_input("Task ID", value=task_id, disabled=True)
|
||
|
||
go = st.button("▶️ Выполнить задание (DEMO)", type="primary", use_container_width=True)
|
||
|
||
if go:
|
||
col_left, col_right = st.columns([1, 2])
|
||
with col_left:
|
||
st.markdown("**Pipeline**")
|
||
steps_ph = st.empty()
|
||
with col_right:
|
||
st.markdown("**Лог событий**")
|
||
log_ph = st.empty()
|
||
|
||
st.markdown("**Код (последний записанный файл)**")
|
||
code_header_ph = st.empty()
|
||
code_ph = st.empty()
|
||
status_ph = st.empty()
|
||
|
||
step_states = {k: "pending" for k, _ in STEPS}
|
||
logs: list[str] = []
|
||
files: dict[str, str] = {}
|
||
file_count = 0
|
||
active_key = None
|
||
thinking_shown = False
|
||
|
||
steps_ph.markdown(render_steps(step_states), unsafe_allow_html=True)
|
||
|
||
update_q: q_mod.Queue = q_mod.Queue()
|
||
t = threading.Thread(target=run_fake_agent, args=(update_q,), daemon=True)
|
||
t.start()
|
||
|
||
finished = False
|
||
while not finished:
|
||
dirty = False
|
||
while not update_q.empty():
|
||
ev = update_q.get_nowait()
|
||
ts = datetime.now().strftime("%H:%M:%S")
|
||
|
||
if ev["t"] == "thinking":
|
||
if not thinking_shown:
|
||
logs.append(
|
||
f'<div class="log-line"><span class="ts">{ts}</span> '
|
||
f'<span class="tthink">🤔 модель думает...</span></div>'
|
||
)
|
||
thinking_shown = True
|
||
dirty = True
|
||
|
||
elif ev["t"] == "tool_start":
|
||
thinking_shown = False
|
||
name = ev["name"]
|
||
inputs = ev.get("inputs", {})
|
||
key = _fake_step_key(name)
|
||
|
||
if key:
|
||
if active_key and active_key != key:
|
||
step_states[active_key] = "done"
|
||
step_states[key] = "active"
|
||
active_key = key
|
||
|
||
if key == "gitea_write_file":
|
||
file_count += 1
|
||
path = inputs.get("path", "")
|
||
content = inputs.get("content", "")
|
||
if path and content:
|
||
files[path] = content
|
||
|
||
short = name.replace("mcp__journal-bh-professor__", "mcp::")
|
||
path = inputs.get("path", "")
|
||
finfo = f" <b style='color:#60a5fa'>{path}</b>" if path else ""
|
||
logs.append(
|
||
f'<div class="log-line"><span class="ts">{ts}</span> '
|
||
f'<span class="ttool">🔧 {short}</span>{finfo}</div>'
|
||
)
|
||
dirty = True
|
||
|
||
elif ev["t"] == "tool_end":
|
||
out = ev["output"][:140].replace("<", "<").replace(">", ">")
|
||
logs.append(
|
||
f'<div class="log-line"><span class="ts">{ts}</span> '
|
||
f'<span class="tres">↩ {out}</span></div>'
|
||
)
|
||
dirty = True
|
||
|
||
elif ev["t"] == "done":
|
||
if active_key:
|
||
step_states[active_key] = "done"
|
||
finished = True
|
||
dirty = True
|
||
|
||
if dirty:
|
||
steps_ph.markdown(render_steps(step_states, file_count), unsafe_allow_html=True)
|
||
log_ph.markdown(
|
||
'<div class="log-wrap">' + "".join(logs[-60:]) + '</div>',
|
||
unsafe_allow_html=True,
|
||
)
|
||
if files:
|
||
last_path = list(files)[-1]
|
||
lang = "python" if last_path.endswith(".py") else (
|
||
"text" if last_path.endswith(".txt") else "markdown"
|
||
)
|
||
code_header_ph.markdown(
|
||
f'<div class="file-header">📄 {last_path} '
|
||
f'<span style="opacity:.5">({len(files)} файлов загружено)</span></div>',
|
||
unsafe_allow_html=True,
|
||
)
|
||
code_ph.code(files[last_path], language=lang)
|
||
|
||
time.sleep(0.1)
|
||
|
||
# финал
|
||
for k, _ in STEPS:
|
||
step_states[k] = "done"
|
||
steps_ph.markdown(render_steps(step_states, file_count), unsafe_allow_html=True)
|
||
status_ph.markdown(
|
||
f'<div class="badge-ok">✓ Задание сдано! '
|
||
f'<a href="{url}" target="_blank" style="color:#4ade80">Открыть репозиторий →</a></div>',
|
||
unsafe_allow_html=True,
|
||
)
|