Initial import

This commit is contained in:
2025-09-07 22:33:51 +03:00
commit 727cb2a4e3
23 changed files with 3480 additions and 0 deletions

66
.gitignore vendored Normal file
View File

@@ -0,0 +1,66 @@
# Python bytecode and caches
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.sqlite3
# Builds and packaging
build/
dist/
pip-wheel-metadata/
*.egg-info/
.eggs/
# Test/coverage/mypy caches
.pytest_cache/
.coverage
.coverage.*
htmlcov/
.mypy_cache/
.cache/
# Virtual environments
venv/
.venv/
env/
ENV/
env.bak/
venv.bak/
# IDE/editor
.vscode/
.idea/
*.code-workspace
# OS junk
.DS_Store
Thumbs.db
# Logs
*.log
agentui.log
# proxy
proxy.txt
# Local config
.env
.env.*
*.env
# Project-specific runtime files
presets/
pipeline.json
# Node
node_modules/
# Temp
tmp/
temp/
# Proxy config
proxy.txt
proxy.*
*.proxy.*

0
Agent.py Normal file
View File

80
PROJECT_OVERVIEW.md Normal file
View File

@@ -0,0 +1,80 @@
# AgentUI Project Overview
## Цель проекта
AgentUI — это прокси‑сервер с визуальным редактором пайплайнов (на базе Drawflow), который нормализует запросы от различных клиентов в единый формат и выполняет их через цепочку узлов (nodes). Это позволяет гибко собирать пайплайны обработки текстовых/LLMзапросов без необходимости вручную интегрировать каждый провайдер.
---
## Основные компоненты
### Фронтенд: Визуальный редактор
- Построен на **Drawflow**.
- Поддерживает узлы, входы/выходы и соединения.
- Реализована надёжная сериализация/десериализация:
- `toPipelineJSON()` сохраняет структуру + все соединения.
- `fromPipelineJSON()` восстанавливает узлы и соединения с учётом времени рендера DOM (retryлогика).
- Исправлены баги исчезающих соединений.
- В инспекторе узлов отображается оригинальный ID узла, а не runtime ID от Drawflow.
- UI подсказки: макрохинты в синтаксисе `[[...]]` (например `[[VAR:system.prompt]]`, `[[OUT:node1.text]]`).
### Бэкенд: Исполнение пайплайна
- Основной код: [`agentui/pipeline/executor.py`](agentui/pipeline/executor.py).
- Выполняется **топологическая сортировка** графа для правильного порядка исполнения и предотвращения циклов.
- Узлы:
- **RawForwardNode**:
- Прямой HTTPфорвардинг с макросами в `base_url`, `override_path`, `headers`.
- Автоопределение провайдера.
- **ProviderCallNode**:
- Унифицированный вызов LLMпровайдеров.
- Преобразует внутренний формат сообщений в специфический формат для OpenAI, Gemini, Anthropic.
- Поддерживает параметры `temperature`, `max_tokens`, `top_p`, `stop` (или аналоги).
- Поддержка **макросов**:
- `{{ path }}` — Jinjaподобный.
- `[[VAR:...]]` — доступ к данным контекста (system, chat, params).
- `[[OUT:nodeId(.attr)]]` — ссылки на вывод других узлов.
- `{{ OUT.node.* }}` — альтернативная форма.
### API сервер (`agentui/api/server.py`)
- Нормализует запросы под форматы `/v1/chat/completions`, Gemini, Anthropic.
- Формирует контекст макросов (vendor, model, params, incoming).
---
## Текущий прогресс
- Исправлены баги сериализации соединений во фронтенде.
- Добавлены подсказки по макросам.
- Реализована топологическая сортировка исполнения.
- Создан универсальный рендер макросов `render_template_simple`.
- Интегрирован RawForward с макроподстановкой.
- ProviderCall теперь преобразует сообщения под формат конкретного провайдера.
---
## Текущая задача (для нового разработчика)
В проекте мы начинаем реализацию **Prompt Manager**, который станет частью узла `ProviderCall`.
**Что уже решено:**
- Архитектура пайплайна, сериализация/десериализация, макросная система, базовые конвертеры форматов.
**Что нужно сделать:**
- [ ] Спроектировать структуру promptменеджера: массив блоков `{ name, role, prompt, enabled, order }`.
- [ ] Добавить универсальный рендер макросов, который применяется ко всем блокам перед конвертацией.
- [ ] Доработать конвертеры форматов под OpenAI, Gemini, Anthropic, чтобы они учитывали эти блоки.
- [ ] Интегрировать promptменеджер в `ProviderCallNode`:
- Сборка последовательности сообщений.
- Подстановка макросов.
- Конвертация в провайдерский формат.
- [ ] Реализовать UI promptменеджера во фронтенде:
- CRUD операций над блоками.
- Drag&Drop сортировку.
- Возможность включать/выключать блок.
- Выбор роли (`user`, `system`, `assistant`, `tool`).
---
## Важные файлы
- [`static/editor.html`](static/editor.html) — визуальный редактор пайплайнов.
- [`agentui/pipeline/executor.py`](agentui/pipeline/executor.py) — логика исполнения пайплайнов, макросов и узлов.
- [`agentui/api/server.py`](agentui/api/server.py) — REST API для внешних клиентов.
- [`pipeline.json`](pipeline.json) — сохранённый пайплайн по умолчанию.

3
agentui/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
__all__ = []

2
agentui/api/__init__.py Normal file
View File

@@ -0,0 +1,2 @@

588
agentui/api/server.py Normal file
View File

@@ -0,0 +1,588 @@
from fastapi import FastAPI, Request, HTTPException, Query, Header
import logging
from logging.handlers import RotatingFileHandler
import json
from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode, unquote
from fastapi.responses import JSONResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from typing import Any, Dict, List, Literal, Optional
from agentui.pipeline.executor import PipelineExecutor
from agentui.pipeline.defaults import default_pipeline
from agentui.pipeline.storage import load_pipeline, save_pipeline, list_presets, load_preset, save_preset
class UnifiedParams(BaseModel):
temperature: float = 0.7
max_tokens: Optional[int] = None
top_p: Optional[float] = 1.0
stop: Optional[List[str]] = None
class UnifiedMessage(BaseModel):
role: Literal["system", "user", "assistant", "tool"]
content: Any
tool_call_id: Optional[str] = None
name: Optional[str] = None
class UnifiedChatRequest(BaseModel):
vendor_format: Literal["openai", "gemini", "claude", "unknown"] = "unknown"
model: str = ""
messages: List[UnifiedMessage] = Field(default_factory=list)
tools: Optional[List[Dict[str, Any]]] = None
tool_choice: Optional[Any] = None
params: UnifiedParams = Field(default_factory=UnifiedParams)
system: Optional[str] = None
stream: bool = False
metadata: Dict[str, Any] = Field(default_factory=dict)
def detect_vendor(payload: Dict[str, Any]) -> str:
if "anthropic_version" in payload or payload.get("provider") == "anthropic":
return "claude"
# Gemini typical payload keys
if "contents" in payload or "generationConfig" in payload:
return "gemini"
# OpenAI typical keys
if "messages" in payload or "model" in payload:
return "openai"
return "unknown"
def normalize_to_unified(payload: Dict[str, Any]) -> UnifiedChatRequest:
vendor = detect_vendor(payload)
if vendor == "openai":
model = payload.get("model", "")
messages = payload.get("messages", [])
system = None
# OpenAI может иметь system в messages
norm_messages: List[UnifiedMessage] = []
for m in messages:
role = m.get("role", "user")
content = m.get("content")
if role == "system" and system is None and isinstance(content, str):
system = content
else:
norm_messages.append(UnifiedMessage(role=role, content=content))
params = UnifiedParams(
temperature=payload.get("temperature", 0.7),
max_tokens=payload.get("max_tokens"),
top_p=payload.get("top_p", 1.0),
stop=payload.get("stop"),
)
stream = bool(payload.get("stream", False))
return UnifiedChatRequest(
vendor_format="openai",
model=model,
messages=norm_messages,
params=params,
system=system,
stream=stream,
tools=payload.get("tools"),
tool_choice=payload.get("tool_choice"),
)
elif vendor == "gemini":
# Gemini → Unified (упрощённо, текст только)
model = payload.get("model", "")
contents = payload.get("contents", [])
norm_messages: List[UnifiedMessage] = []
for c in contents:
raw_role = c.get("role", "user")
# Gemini использует role: "user" и "model" — маппим "model" -> "assistant"
role = "assistant" if raw_role == "model" else (raw_role if raw_role in {"user", "system", "assistant", "tool"} else "user")
parts = c.get("parts", [])
# текстовые части склеиваем
text_parts = []
for p in parts:
if isinstance(p, dict) and "text" in p:
text_parts.append(p["text"])
content = "\n".join(text_parts) if text_parts else parts
norm_messages.append(UnifiedMessage(role=role, content=content))
gen = payload.get("generationConfig", {})
params = UnifiedParams(
temperature=gen.get("temperature", 0.7),
max_tokens=gen.get("maxOutputTokens"),
top_p=gen.get("topP", 1.0),
stop=gen.get("stopSequences"),
)
return UnifiedChatRequest(
vendor_format="gemini",
model=model,
messages=norm_messages,
params=params,
stream=False,
)
elif vendor == "claude":
model = payload.get("model", "")
system = payload.get("system")
messages = payload.get("messages", [])
norm_messages: List[UnifiedMessage] = []
for m in messages:
role = m.get("role", "user")
content_raw = m.get("content")
# Anthropic messages API: content может быть строкой или массивом блоков {type:"text", text:"..."}
if isinstance(content_raw, list):
text_parts: List[str] = []
for part in content_raw:
if isinstance(part, dict) and part.get("type") == "text" and isinstance(part.get("text"), str):
text_parts.append(part["text"])
content = "\n".join(text_parts)
else:
content = content_raw
norm_messages.append(UnifiedMessage(role=role, content=content))
params = UnifiedParams(
temperature=payload.get("temperature", 0.7),
max_tokens=payload.get("max_tokens"),
top_p=payload.get("top_p", 1.0),
stop=payload.get("stop"),
)
return UnifiedChatRequest(
vendor_format="claude",
model=model,
messages=norm_messages,
params=params,
system=system,
stream=False,
)
else:
raise HTTPException(status_code=400, detail="Unsupported or unknown vendor payload")
def build_macro_context(u: UnifiedChatRequest, incoming: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
last_user = next((m.content for m in reversed(u.messages) if m.role == "user"), "")
inc = incoming or {}
# Распарсим query-параметры (в т.ч. key для Gemini)
try:
qparams = dict(parse_qsl(inc.get("query", ""), keep_blank_values=True))
except Exception: # noqa: BLE001
qparams = {}
inc_enriched: Dict[str, Any] = dict(inc)
inc_enriched["query_params"] = qparams
# Необязательный удобный срез ключей
try:
headers = inc.get("headers") or {}
api_keys: Dict[str, Any] = {}
if isinstance(headers, dict):
api_keys["authorization"] = headers.get("authorization") or headers.get("Authorization")
api_keys["key"] = qparams.get("key")
if api_keys:
inc_enriched["api_keys"] = api_keys
except Exception: # noqa: BLE001
pass
return {
"vendor_format": u.vendor_format,
"model": u.model,
"system": u.system or "",
"chat": {
"last_user": last_user,
"messages": [m.model_dump() for m in u.messages],
},
"params": u.params.model_dump(),
"incoming": inc_enriched,
}
def jinja_render(template: str, ctx: Dict[str, Any]) -> str:
# Чтобы не тянуть Jinja2 в MVP: простая {{ key.path }} замена
def get_value(path: str, data: Dict[str, Any]) -> Any:
cur: Any = data
for part in path.split('.'):
if isinstance(cur, dict):
cur = cur.get(part, "")
else:
return ""
return cur if isinstance(cur, (str, int, float)) else ""
out = template
import re
for m in re.findall(r"\{\{\s*([^}]+)\s*\}\}", template):
expr = m.strip()
# support simple default filter: {{ path|default(value) }}
default_match = re.match(r"([^|]+)\|\s*default\((.*)\)", expr)
if default_match:
path = default_match.group(1).strip()
fallback = default_match.group(2).strip()
# strip quotes if present
if (fallback.startswith("\"") and fallback.endswith("\"")) or (fallback.startswith("'") and fallback.endswith("'")):
fallback = fallback[1:-1]
raw_val = get_value(path, ctx)
val = str(raw_val) if raw_val not in (None, "") else str(fallback)
else:
val = str(get_value(expr, ctx))
out = out.replace("{{ "+m+" }}", val).replace("{{"+m+"}}", val)
return out
async def execute_pipeline_echo(u: UnifiedChatRequest) -> Dict[str, Any]:
# Мини-пайплайн: PromptTemplate -> LLMInvoke(echo) -> VendorFormatter
macro_ctx = build_macro_context(u)
# PromptTemplate
prompt_template = "System: {{ system }}\nUser: {{ chat.last_user }}"
rendered_prompt = jinja_render(prompt_template, macro_ctx)
# LLMInvoke (echo, т.к. без реального провайдера в MVP)
llm_response_text = f"[echo by {u.model}]\n" + rendered_prompt
# VendorFormatter
if u.vendor_format == "openai":
return {
"id": "mockcmpl-123",
"object": "chat.completion",
"model": u.model,
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": llm_response_text},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 0, "completion_tokens": len(llm_response_text.split()), "total_tokens": 0},
}
if u.vendor_format == "gemini":
return {
"candidates": [
{
"content": {
"role": "model",
"parts": [{"text": llm_response_text}],
},
"finishReason": "STOP",
"index": 0,
}
],
"modelVersion": u.model,
}
if u.vendor_format == "claude":
return {
"id": "msg_mock_123",
"type": "message",
"model": u.model,
"role": "assistant",
"content": [
{"type": "text", "text": llm_response_text}
],
"stop_reason": "end_turn",
}
raise HTTPException(status_code=400, detail="Unsupported vendor for formatting")
def create_app() -> FastAPI:
app = FastAPI(title="НадTavern")
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("agentui")
if not logger.handlers:
stream_handler = logging.StreamHandler()
stream_handler.setLevel(logging.INFO)
file_handler = RotatingFileHandler("agentui.log", maxBytes=1_000_000, backupCount=3, encoding="utf-8")
file_handler.setLevel(logging.INFO)
logger.addHandler(stream_handler)
logger.addHandler(file_handler)
def _mask_headers(h: Dict[str, Any]) -> Dict[str, Any]:
# Временно отключаем маскировку Authorization для отладки
hidden = {"x-api-key", "cookie"}
masked: Dict[str, Any] = {}
for k, v in h.items():
lk = k.lower()
if lk in hidden:
masked[k] = "***"
else:
masked[k] = v
return masked
def _sanitize_url(url: str) -> str:
try:
parts = urlsplit(url)
qs = parse_qsl(parts.query, keep_blank_values=True)
qs_masked = [(k, "***" if k.lower() in {"key", "access_token", "token"} else v) for k, v in qs]
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(qs_masked), parts.fragment))
except Exception: # noqa: BLE001
return url
async def _log_request(req: Request, raw_body: Optional[bytes] = None, parsed: Optional[Any] = None) -> None:
try:
url = _sanitize_url(str(req.url))
headers = _mask_headers(dict(req.headers))
body_preview = None
if raw_body is not None:
body_preview = raw_body.decode(errors="ignore")
if len(body_preview) > 4000:
body_preview = body_preview[:4000] + "...<truncated>"
payload = {
"event": "incoming_request",
"method": req.method,
"url": url,
"headers": headers,
"body": body_preview,
"json": parsed if isinstance(parsed, (dict, list)) else None,
}
logger.info("%s", json.dumps(payload, ensure_ascii=False))
except Exception: # noqa: BLE001
pass
async def _log_response(req: Request, status: int, data: Any) -> None:
try:
payload = {
"event": "outgoing_response",
"method": req.method,
"path": req.url.path,
"status": status,
"json": data if isinstance(data, (dict, list)) else None,
}
logger.info("%s", json.dumps(payload, ensure_ascii=False))
except Exception: # noqa: BLE001
pass
@app.get("/")
async def index() -> HTMLResponse:
html = (
"<html><head><title>НадTavern</title></head>"
"<body>"
"<h1>НадTavern</h1>"
"<p>Простой UI и API запущены.</p>"
"<p>POST /v1/chat/completions — универсальный эндпоинт (без стриминга)."
" Поддерживает OpenAI/Gemini/Claude формы. Возвращает в исходном формате.</p>"
"<p><a href='/ui'>Перейти в UI</a></p>"
"</body></html>"
)
return HTMLResponse(html)
@app.post("/v1/chat/completions")
async def chat_completions(request: Request) -> JSONResponse:
raw = await request.body()
try:
payload = json.loads(raw or b"{}")
except Exception: # noqa: BLE001
raise HTTPException(status_code=400, detail="Invalid JSON")
await _log_request(request, raw_body=raw, parsed=payload)
unified = normalize_to_unified(payload)
unified.stream = False # по требованию MVP без стриминга
# контекст для пайплайна
incoming = {
"method": request.method,
"url": _sanitize_url(str(request.url)),
"path": request.url.path,
"query": request.url.query,
"headers": dict(request.headers),
"json": payload,
}
macro_ctx = build_macro_context(unified, incoming=incoming)
pipeline = load_pipeline()
executor = PipelineExecutor(pipeline)
last = await executor.run(macro_ctx)
result = last.get("result") or await execute_pipeline_echo(unified)
await _log_response(request, 200, result)
return JSONResponse(result)
# Google AI Studio совместимые роуты (Gemini):
# POST /v1beta/models/{model}:generateContent?key=...
# POST /v1/models/{model}:generateContent?key=...
@app.post("/v1beta/models/{model}:generateContent")
async def gemini_generate_content_v1beta(model: str, request: Request, key: Optional[str] = Query(default=None)) -> JSONResponse: # noqa: ARG001
raw = await request.body()
try:
payload = json.loads(raw or b"{}")
except Exception: # noqa: BLE001
raise HTTPException(status_code=400, detail="Invalid JSON")
# Убедимся, что модель присутствует в полезной нагрузке
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload type")
payload = {**payload, "model": model}
await _log_request(request, raw_body=raw, parsed=payload)
unified = normalize_to_unified(payload)
unified.stream = False
incoming = {
"method": request.method,
"url": _sanitize_url(str(request.url)),
"path": request.url.path,
"query": request.url.query,
"headers": dict(request.headers),
"json": payload,
}
macro_ctx = build_macro_context(unified, incoming=incoming)
pipeline = load_pipeline()
executor = PipelineExecutor(pipeline)
last = await executor.run(macro_ctx)
result = last.get("result") or await execute_pipeline_echo(unified)
await _log_response(request, 200, result)
return JSONResponse(result)
@app.post("/v1/models/{model}:generateContent")
async def gemini_generate_content_v1(model: str, request: Request, key: Optional[str] = Query(default=None)) -> JSONResponse: # noqa: ARG001
raw = await request.body()
try:
payload = json.loads(raw or b"{}")
except Exception: # noqa: BLE001
raise HTTPException(status_code=400, detail="Invalid JSON")
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload type")
payload = {**payload, "model": model}
await _log_request(request, raw_body=raw, parsed=payload)
unified = normalize_to_unified(payload)
unified.stream = False
incoming = {
"method": request.method,
"url": _sanitize_url(str(request.url)),
"path": request.url.path,
"query": request.url.query,
"headers": dict(request.headers),
"json": payload,
}
macro_ctx = build_macro_context(unified, incoming=incoming)
pipeline = load_pipeline()
executor = PipelineExecutor(pipeline)
last = await executor.run(macro_ctx)
result = last.get("result") or await execute_pipeline_echo(unified)
await _log_response(request, 200, result)
return JSONResponse(result)
# Catch-all для случаев, когда двоеточие в пути закодировано как %3A
@app.post("/v1beta/models/{rest_of_path:path}")
async def gemini_generate_content_v1beta_catchall(rest_of_path: str, request: Request, key: Optional[str] = Query(default=None)) -> JSONResponse: # noqa: ARG001
decoded = unquote(rest_of_path)
if ":generateContent" not in decoded:
raise HTTPException(status_code=404, detail="Not Found")
model = decoded.split(":generateContent", 1)[0]
raw = await request.body()
try:
payload = json.loads(raw or b"{}")
except Exception: # noqa: BLE001
raise HTTPException(status_code=400, detail="Invalid JSON")
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload type")
payload = {**payload, "model": model}
await _log_request(request, raw_body=raw, parsed=payload)
unified = normalize_to_unified(payload)
unified.stream = False
incoming = {
"method": request.method,
"url": _sanitize_url(str(request.url)),
"path": request.url.path,
"query": request.url.query,
"headers": dict(request.headers),
"json": payload,
}
macro_ctx = build_macro_context(unified, incoming=incoming)
pipeline = load_pipeline()
executor = PipelineExecutor(pipeline)
last = await executor.run(macro_ctx)
result = last.get("result") or await execute_pipeline_echo(unified)
await _log_response(request, 200, result)
return JSONResponse(result)
@app.post("/v1/models/{rest_of_path:path}")
async def gemini_generate_content_v1_catchall(rest_of_path: str, request: Request, key: Optional[str] = Query(default=None)) -> JSONResponse: # noqa: ARG001
decoded = unquote(rest_of_path)
if ":generateContent" not in decoded:
raise HTTPException(status_code=404, detail="Not Found")
model = decoded.split(":generateContent", 1)[0]
raw = await request.body()
try:
payload = json.loads(raw or b"{}")
except Exception: # noqa: BLE001
raise HTTPException(status_code=400, detail="Invalid JSON")
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload type")
payload = {**payload, "model": model}
await _log_request(request, raw_body=raw, parsed=payload)
unified = normalize_to_unified(payload)
unified.stream = False
incoming = {
"method": request.method,
"url": _sanitize_url(str(request.url)),
"path": request.url.path,
"query": request.url.query,
"headers": dict(request.headers),
"json": payload,
}
macro_ctx = build_macro_context(unified, incoming=incoming)
pipeline = load_pipeline()
executor = PipelineExecutor(pipeline)
last = await executor.run(macro_ctx)
result = last.get("result") or await execute_pipeline_echo(unified)
await _log_response(request, 200, result)
return JSONResponse(result)
# Anthropic Claude messages endpoint compatibility
@app.post("/v1/messages")
async def claude_messages(request: Request, anthropic_version: Optional[str] = Header(default=None)) -> JSONResponse: # noqa: ARG001
raw = await request.body()
try:
payload = json.loads(raw or b"{}")
except Exception: # noqa: BLE001
raise HTTPException(status_code=400, detail="Invalid JSON")
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload type")
# Помечаем как Anthropic, передаём версию из заголовка в payload для детекции
if anthropic_version:
payload = {**payload, "anthropic_version": anthropic_version}
else:
payload = {**payload, "anthropic_version": payload.get("anthropic_version", "2023-06-01")}
await _log_request(request, raw_body=raw, parsed=payload)
unified = normalize_to_unified(payload)
unified.stream = False
incoming = {
"method": request.method,
"url": _sanitize_url(str(request.url)),
"path": request.url.path,
"query": request.url.query,
"headers": dict(request.headers),
"json": payload,
}
macro_ctx = build_macro_context(unified, incoming=incoming)
pipeline = load_pipeline()
executor = PipelineExecutor(pipeline)
last = await executor.run(macro_ctx)
result = last.get("result") or await execute_pipeline_echo(unified)
await _log_response(request, 200, result)
return JSONResponse(result)
app.mount("/ui", StaticFiles(directory="static", html=True), name="ui")
# Admin API для пайплайна
@app.get("/admin/pipeline")
async def get_pipeline() -> JSONResponse:
return JSONResponse(load_pipeline())
@app.post("/admin/pipeline")
async def set_pipeline(request: Request) -> JSONResponse:
raw = await request.body()
try:
pipeline = json.loads(raw or b"{}")
except Exception: # noqa: BLE001
raise HTTPException(status_code=400, detail="Invalid JSON")
# простая проверка
if not isinstance(pipeline, dict) or "nodes" not in pipeline:
raise HTTPException(status_code=400, detail="Invalid pipeline format")
save_pipeline(pipeline)
return JSONResponse({"ok": True})
# Presets
@app.get("/admin/presets")
async def get_presets() -> JSONResponse:
return JSONResponse({"items": list_presets()})
@app.get("/admin/presets/{name}")
async def get_preset(name: str) -> JSONResponse:
try:
return JSONResponse(load_preset(name))
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Preset not found")
@app.post("/admin/presets/{name}")
async def put_preset(name: str, request: Request) -> JSONResponse:
raw = await request.body()
try:
payload = json.loads(raw or b"{}")
except Exception: # noqa: BLE001
raise HTTPException(status_code=400, detail="Invalid JSON")
if not isinstance(payload, dict) or "nodes" not in payload:
raise HTTPException(status_code=400, detail="Invalid pipeline format")
save_preset(name, payload)
return JSONResponse({"ok": True})
return app
app = create_app()

61
agentui/config.py Normal file
View File

@@ -0,0 +1,61 @@
from typing import Dict, Optional
from pathlib import Path
from urllib.parse import quote
def _parse_proxy_line(line: str) -> Optional[str]:
# Формат: scheme:ip:port[:login[:pass]]
# Примеры:
# socks5:127.0.0.1:9050
# socks5:127.0.0.1:9050:user:pass
# http:127.0.0.1:8888
parts = [p.strip() for p in line.strip().split(":")]
if len(parts) < 3:
return None
scheme, host, port = parts[0], parts[1], parts[2]
user = parts[3] if len(parts) >= 4 and parts[3] else None
password = parts[4] if len(parts) >= 5 and parts[4] else None
auth = ""
if user:
auth = quote(user)
if password:
auth += f":{quote(password)}"
auth += "@"
# Исправление для socks5: httpx ожидает схему socks5:// (не socks://)
if scheme == "socks":
scheme = "socks5"
# Явно проверяем протокол, чтобы был http://, https:// или socks5://
if not scheme.startswith(("http", "socks")):
scheme = "http"
return f"{scheme}://{auth}{host}:{port}"
def _read_proxy_from_file() -> Optional[str]:
file_path = Path("proxy.txt")
if not file_path.exists():
return None
try:
for raw in file_path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
url = _parse_proxy_line(line)
if url:
return url
except Exception:
return None
return None
def build_httpx_proxies() -> Optional[Dict[str, str]]:
# Читаем только из proxy.txt (без переменных окружения)
url = _read_proxy_from_file()
if not url:
return None
# Для httpx корректнее указывать схемы явно
return {
"http://": url,
"https://": url,
}

View File

@@ -0,0 +1,2 @@

View File

@@ -0,0 +1,14 @@
from typing import Any, Dict
def default_pipeline() -> Dict[str, Any]:
# Минимальный дефолт без устаревших нод.
# Если пайплайн пустой, сервер вернёт echo-ответ (см. server.execute_pipeline_echo).
return {
"id": "pipeline_default",
"name": "Default Chat Pipeline",
"parallel_limit": 8,
"nodes": []
}

View File

@@ -0,0 +1,888 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional
from urllib.parse import urljoin
import json
import re
import asyncio
from agentui.providers.http_client import build_client
# --- Templating helpers ------------------------------------------------------
_OUT_MACRO_RE = re.compile(r"\[\[\s*OUT\s*[:\s]\s*([^\]]+?)\s*\]\]", re.IGNORECASE)
_VAR_MACRO_RE = re.compile(r"\[\[\s*VAR\s*[:\s]\s*([^\]]+?)\s*\]\]", re.IGNORECASE)
# Unified prompt fragment macro (provider-specific JSON fragment)
_PROMPT_MACRO_RE = re.compile(r"\[\[\s*PROMPT\s*\]\]", re.IGNORECASE)
# Short form: [[OUT1]] -> best-effort text from node n1
_OUT_SHORT_RE = re.compile(r"\[\[\s*OUT\s*(\d+)\s*\]\]", re.IGNORECASE)
_BRACES_RE = re.compile(r"\{\{\s*([^}]+?)\s*\}\}")
def _split_path(path: str) -> List[str]:
return [p.strip() for p in str(path).split(".") if str(p).strip()]
def _get_by_path(obj: Any, path: Optional[str]) -> Any:
if path is None or path == "":
return obj
cur = obj
for seg in _split_path(path):
if isinstance(cur, dict):
if seg in cur:
cur = cur[seg]
else:
return None
elif isinstance(cur, list):
try:
idx = int(seg)
except Exception: # noqa: BLE001
return None
if 0 <= idx < len(cur):
cur = cur[idx]
else:
return None
else:
return None
return cur
def _stringify_for_template(val: Any) -> str:
if val is None:
return ""
if isinstance(val, bool):
# JSON-friendly booleans (useful when embedding into JSON-like templates)
return "true" if val else "false"
if isinstance(val, (dict, list)):
try:
return json.dumps(val, ensure_ascii=False)
except Exception: # noqa: BLE001
return str(val)
return str(val)
def _deep_find_text(obj: Any, max_nodes: int = 5000) -> Optional[str]:
"""
Best-effort поиск первого текстового значения в глубине структуры JSON.
Сначала пытаемся по ключам content/text, затем общий обход.
"""
try:
# Быстрые ветки
if isinstance(obj, str):
return obj
if isinstance(obj, dict):
c = obj.get("content")
if isinstance(c, str):
return c
t = obj.get("text")
if isinstance(t, str):
return t
parts = obj.get("parts")
if isinstance(parts, list) and parts:
for p in parts:
if isinstance(p, dict) and isinstance(p.get("text"), str):
return p.get("text")
# Общий нерекурсивный обход в ширину
queue: List[Any] = [obj]
seen = 0
while queue and seen < max_nodes:
cur = queue.pop(0)
seen += 1
if isinstance(cur, str):
return cur
if isinstance(cur, dict):
# часто встречающиеся поля
for k in ("text", "content"):
v = cur.get(k)
if isinstance(v, str):
return v
# складываем все значения
for v in cur.values():
queue.append(v)
elif isinstance(cur, list):
for it in cur:
queue.append(it)
except Exception:
pass
return None
def _best_text_from_outputs(node_out: Any) -> str:
"""
Унифицированное извлечение "текста" из выхода ноды.
Поддерживает:
- PromptTemplate: {"text": ...}
- LLMInvoke: {"response_text": ...}
- ProviderCall/RawForward: {"result": <provider_json>}, извлекаем текст для openai/gemini/claude
- Общий глубокий поиск текста, если специфичные ветки не сработали
"""
# Строка сразу
if isinstance(node_out, str):
return node_out
if not isinstance(node_out, dict):
return ""
# Явные короткие поля
if isinstance(node_out.get("response_text"), str) and node_out.get("response_text"):
return str(node_out["response_text"])
if isinstance(node_out.get("text"), str) and node_out.get("text"):
return str(node_out["text"])
res = node_out.get("result")
base = res if isinstance(res, (dict, list)) else node_out
# OpenAI
try:
if isinstance(base, dict):
ch0 = (base.get("choices") or [{}])[0]
msg = ch0.get("message") or {}
c = msg.get("content")
if isinstance(c, str):
return c
except Exception:
pass
# Gemini
try:
if isinstance(base, dict):
cand0 = (base.get("candidates") or [{}])[0]
content = cand0.get("content") or {}
parts0 = (content.get("parts") or [{}])[0]
t = parts0.get("text")
if isinstance(t, str):
return t
except Exception:
pass
# Claude
try:
if isinstance(base, dict):
blocks = base.get("content") or []
texts = [b.get("text") for b in blocks if isinstance(b, dict) and isinstance(b.get("text"), str)]
if texts:
return "\n".join(texts)
except Exception:
pass
# Общий глубокий поиск
txt = _deep_find_text(base)
return txt or ""
def _extract_out_node_id_from_ref(s: Any) -> Optional[str]:
"""
Извлекает node_id из строки с макросом [[OUT:nodeId(.path)*]].
Возвращает None, если макрос не найден.
"""
if not isinstance(s, str):
return None
m = _OUT_MACRO_RE.search(s)
if not m:
return None
body = m.group(1).strip()
node_id = body.split(".", 1)[0].strip()
return node_id or None
def _resolve_in_value(source: Any, context: Dict[str, Any], values: Dict[str, Dict[str, Any]]) -> Any:
"""
Разрешает входные связи/макросы в значение для inputs:
- Нестроковые значения возвращаются как есть
- "macro:path" &rarr; берёт значение из context по точечному пути
- "[[VAR:path]]" &rarr; берёт значение из context
- "[[OUT:nodeId(.path)*]]" &rarr; берёт из уже вычисленных выходов ноды
- "nodeId(.path)*" &rarr; ссылка на выходы ноды
- Иначе пытается взять из context по пути; если не найдено, оставляет исходную строку
"""
if not isinstance(source, str):
return source
s = source.strip()
# macro:path
if s.lower().startswith("macro:"):
path = s.split(":", 1)[1].strip()
return _get_by_path(context, path)
# [[VAR: path]]
m = _VAR_MACRO_RE.fullmatch(s)
if m:
path = m.group(1).strip()
return _get_by_path(context, path)
# [[OUT: nodeId(.path)*]]
m = _OUT_MACRO_RE.fullmatch(s)
if m:
body = m.group(1).strip()
if "." in body:
node_id, rest = body.split(".", 1)
node_val = values.get(node_id)
return _get_by_path(node_val, rest)
node_val = values.get(body)
return node_val
# "nodeId(.path)*"
if "." in s:
node_id, rest = s.split(".", 1)
if node_id in values:
return _get_by_path(values.get(node_id), rest)
if s in values:
return values.get(s)
# fallback: from context by dotted path or raw string
ctx_val = _get_by_path(context, s)
return ctx_val if ctx_val is not None else source
def render_template_simple(template: str, context: Dict[str, Any], out_map: Dict[str, Any]) -> str:
"""
Простая подстановка:
- {{ path }} — берёт из context (или {{ OUT.node.path }} для выходов)
- Поддержка фильтра по умолчанию: {{ path|default(value) }}
value может быть числом, строкой ('..'/".."), массивом/объектом в виде литерала.
- [[VAR:path]] — берёт из context
- [[OUT:nodeId(.path)*]] — берёт из out_map
Возвращает строку.
"""
if template is None:
return ""
s = str(template)
# 1) Макросы [[VAR:...]] и [[OUT:...]]
def repl_var(m: re.Match) -> str:
path = m.group(1).strip()
val = _get_by_path(context, path)
return _stringify_for_template(val)
def repl_out(m: re.Match) -> str:
body = m.group(1).strip()
if "." in body:
node_id, rest = body.split(".", 1)
node_val = out_map.get(node_id)
val = _get_by_path(node_val, rest)
else:
val = out_map.get(body)
return _stringify_for_template(val)
s = _VAR_MACRO_RE.sub(repl_var, s)
s = _OUT_MACRO_RE.sub(repl_out, s)
# [[OUT1]] → текст из ноды n1 (best-effort)
def repl_out_short(m: re.Match) -> str:
try:
num = int(m.group(1))
node_id = f"n{num}"
node_out = out_map.get(node_id)
txt = _best_text_from_outputs(node_out)
return _stringify_for_template(txt)
except Exception:
return ""
s = _OUT_SHORT_RE.sub(repl_out_short, s)
# [[PROMPT]] expands to raw provider-specific JSON fragment prepared in context["PROMPT"]
s = _PROMPT_MACRO_RE.sub(lambda _m: str(context.get("PROMPT") or ""), s)
# 2) Подстановки {{ ... }} (+ simple default filter)
def repl_braces(m: re.Match) -> str:
expr = m.group(1).strip()
def eval_path(p: str) -> Any:
p = p.strip()
if p.startswith("OUT."):
body = p[4:]
if "." in body:
node_id, rest = body.split(".", 1)
node_val = out_map.get(node_id)
return _get_by_path(node_val, rest)
return out_map.get(body)
return _get_by_path(context, p)
default_match = re.match(r"([^|]+)\|\s*default\((.*)\)\s*$", expr)
if default_match:
base_path = default_match.group(1).strip()
fallback_raw = default_match.group(2).strip()
# Снимем внешние кавычки, если это строковый литерал
if len(fallback_raw) >= 2 and ((fallback_raw[0] == "'" and fallback_raw[-1] == "'") or (fallback_raw[0] == '"' and fallback_raw[-1] == '"')):
fallback_val: Any = fallback_raw[1:-1]
else:
# Иначе оставляем как есть (числа/массивы/объекты — литералами)
fallback_val = fallback_raw
raw_val = eval_path(base_path)
val = raw_val if raw_val not in (None, "") else fallback_val
else:
val = eval_path(expr)
return _stringify_for_template(val)
s = _BRACES_RE.sub(repl_braces, s)
return s
def detect_vendor(payload: Dict[str, Any]) -> str:
if not isinstance(payload, dict):
return "unknown"
if "anthropic_version" in payload or payload.get("provider") == "anthropic":
return "claude"
# Gemini typical payload keys
if "contents" in payload or "generationConfig" in payload:
return "gemini"
# OpenAI typical keys
if "messages" in payload or "model" in payload:
return "openai"
return "unknown"
class ExecutionError(Exception):
pass
class Node:
type_name: str = "Base"
def __init__(self, node_id: str, config: Optional[Dict[str, Any]] = None) -> None:
self.node_id = node_id
self.config = config or {}
async def run(self, inputs: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]: # noqa: D401
"""Execute node with inputs and context. Return dict of outputs."""
raise NotImplementedError
# Регистрация поддерживаемых типов нод (минимальный набор)
NODE_REGISTRY: Dict[str, Any] = {}
class PipelineExecutor:
def __init__(self, pipeline: Dict[str, Any]) -> None:
self.pipeline = pipeline
self.nodes_by_id: Dict[str, Node] = {}
for n in pipeline.get("nodes", []):
node_cls = NODE_REGISTRY.get(n.get("type"))
if not node_cls:
raise ExecutionError(f"Unknown node type: {n.get('type')}")
self.nodes_by_id[n["id"]] = node_cls(n["id"], n.get("config", {}))
async def run(self, context: Dict[str, Any]) -> Dict[str, Any]:
"""
Исполнитель пайплайна с динамическим порядком на основе зависимостей графа.
Новый режим: волновое (level-by-level) исполнение с параллелизмом и барьером.
Все узлы «готовой волны» стартуют параллельно, ждём всех, затем открывается следующая волна.
Ограничение параллелизма берётся из pipeline.parallel_limit (по умолчанию 8).
Политика ошибок: fail-fast — при исключении любой задачи волны прерываем пайплайн.
"""
nodes: List[Dict[str, Any]] = list(self.pipeline.get("nodes", []))
id_set = set(self.nodes_by_id.keys())
# Собираем зависимости: node_id -> set(parent_ids), и обратные связи dependents
deps_map: Dict[str, set] = {n["id"]: set() for n in nodes}
dependents: Dict[str, set] = {n["id"]: set() for n in nodes}
for n in nodes:
nid = n["id"]
for _, source in (n.get("in") or {}).items():
if not isinstance(source, str):
# Нестрочные значения считаем константами — зависимостей нет
continue
if source.startswith("macro:"):
# Макросы берутся из контекста, без зависимостей
continue
# [[VAR:...]] — макрос из контекста, зависимостей нет
if re.fullmatch(r"\[\[\s*VAR\s*[:\s]\s*[^\]]+\s*\]\]", source.strip()):
continue
# [[OUT:nodeId(.key)*]] — зависимость от указанной ноды
out_ref_node = _extract_out_node_id_from_ref(source)
if out_ref_node and out_ref_node in id_set:
deps_map[nid].add(out_ref_node)
dependents[out_ref_node].add(nid)
continue
# Ссылки вида "node.outKey" или "node"
src_id = source.split(".", 1)[0] if "." in source else source
if src_id in id_set:
deps_map[nid].add(src_id)
dependents[src_id].add(nid)
# Входящие степени и первая волна
in_degree: Dict[str, int] = {nid: len(deps) for nid, deps in deps_map.items()}
ready: List[str] = [nid for nid, deg in in_degree.items() if deg == 0]
processed: List[str] = []
values: Dict[str, Dict[str, Any]] = {}
last_result: Dict[str, Any] = {}
node_def_by_id: Dict[str, Dict[str, Any]] = {n["id"]: n for n in nodes}
# Параметры параллелизма
try:
parallel_limit = int(self.pipeline.get("parallel_limit", 8))
except Exception:
parallel_limit = 8
if parallel_limit <= 0:
parallel_limit = 1
# Вспомогательная корутина исполнения одной ноды со снапшотом OUT
async def exec_one(node_id: str, values_snapshot: Dict[str, Any]) -> tuple[str, Dict[str, Any]]:
ndef = node_def_by_id.get(node_id)
if not ndef:
raise ExecutionError(f"Node definition not found: {node_id}")
node = self.nodes_by_id[node_id]
# Снимок контекста и OUT на момент старта волны
ctx = dict(context)
ctx["OUT"] = values_snapshot
# Разрешаем inputs для ноды
inputs: Dict[str, Any] = {}
for name, source in (ndef.get("in") or {}).items():
inputs[name] = _resolve_in_value(source, ctx, values_snapshot)
out = await node.run(inputs, ctx)
return node_id, out
# Волновое исполнение
while ready:
wave_nodes = list(ready)
ready = [] # будет заполнено после завершения волны
wave_results: Dict[str, Dict[str, Any]] = {}
# Один общий снапшот OUT для всей волны (барьер — узлы волны не видят результаты друг друга)
values_snapshot = dict(values)
# Чанковый запуск с лимитом parallel_limit
for i in range(0, len(wave_nodes), parallel_limit):
chunk = wave_nodes[i : i + parallel_limit]
# fail-fast: при исключении любой задачи gather бросит и отменит остальные
results = await asyncio.gather(
*(exec_one(nid, values_snapshot) for nid in chunk),
return_exceptions=False,
)
# Коммитим результаты чанка в локальное хранилище волны
for nid, out in results:
wave_results[nid] = out
last_result = out # обновляем на каждом успешном результате
# После завершения волны — коммитим все её результаты в общие values
values.update(wave_results)
processed.extend(wave_nodes)
# Обновляем входящие степени для зависимых и формируем следующую волну
for done in wave_nodes:
for child in dependents.get(done, ()):
in_degree[child] -= 1
next_ready = [nid for nid, deg in in_degree.items() if deg == 0 and nid not in processed and nid not in wave_nodes]
# Исключаем уже учтённые и добавляем только те, которые действительно готовы
ready = next_ready
# Проверка на циклы/недостижимые ноды
if len(processed) != len(nodes):
remaining = [n["id"] for n in nodes if n["id"] not in processed]
raise ExecutionError(f"Cycle detected or unresolved dependencies among nodes: {remaining}")
return last_result
class ProviderCallNode(Node):
type_name = "ProviderCall"
# --- Prompt Manager helpers -------------------------------------------------
def _get_blocks(self) -> List[Dict[str, Any]]:
"""Return normalized list of prompt blocks from config."""
raw = self.config.get("blocks") or self.config.get("prompt_blocks") or []
if not isinstance(raw, list):
return []
norm: List[Dict[str, Any]] = []
for i, b in enumerate(raw):
if not isinstance(b, dict):
continue
role = str(b.get("role", "user")).lower().strip()
if role not in {"system", "user", "assistant", "tool"}:
role = "user"
# order fallback: keep original index if not provided/correct
try:
order = int(b.get("order")) if b.get("order") is not None else i
except Exception: # noqa: BLE001
order = i
norm.append(
{
"id": b.get("id") or f"b{i}",
"name": b.get("name") or f"Block {i+1}",
"role": role,
"prompt": b.get("prompt") or "",
"enabled": bool(b.get("enabled", True)),
"order": order,
}
)
return norm
def _render_blocks_to_unified(self, context: Dict[str, Any]) -> List[Dict[str, Any]]:
"""
Filter+sort+render blocks to unified messages:
[{role, content, name?}]
"""
out_map = context.get("OUT") or {}
blocks = [b for b in self._get_blocks() if b.get("enabled", True)]
blocks.sort(key=lambda x: x.get("order", 0))
messages: List[Dict[str, Any]] = []
for b in blocks:
content = render_template_simple(str(b.get("prompt") or ""), context, out_map)
msg = {"role": b["role"], "content": content}
if b.get("name"):
msg["name"] = b["name"]
messages.append(msg)
return messages
def _messages_to_payload(self, provider: str, messages: List[Dict[str, Any]], context: Dict[str, Any]) -> Dict[str, Any]:
"""Convert unified messages to provider-specific request payload."""
params = context.get("params") or {}
model = context.get("model") or ""
if provider == "openai":
payload: Dict[str, Any] = {
"model": model,
"messages": [
{k: v for k, v in {"role": m["role"], "content": m["content"], "name": m.get("name")}.items() if v is not None}
for m in messages
],
"temperature": params.get("temperature", 0.7),
}
if params.get("max_tokens") is not None:
payload["max_tokens"] = params.get("max_tokens")
if params.get("top_p") is not None:
payload["top_p"] = params.get("top_p")
if params.get("stop") is not None:
payload["stop"] = params.get("stop")
return payload
if provider == "gemini":
sys_text = "\n\n".join([m["content"] for m in messages if m["role"] == "system"]).strip()
contents = []
for m in messages:
if m["role"] == "system":
continue
role = "model" if m["role"] == "assistant" else "user"
contents.append({"role": role, "parts": [{"text": m["content"]}]})
gen_cfg: Dict[str, Any] = {}
if params.get("temperature") is not None:
gen_cfg["temperature"] = params.get("temperature")
if params.get("max_tokens") is not None:
gen_cfg["maxOutputTokens"] = params.get("max_tokens")
if params.get("top_p") is not None:
gen_cfg["topP"] = params.get("top_p")
if params.get("stop") is not None:
gen_cfg["stopSequences"] = params.get("stop")
payload = {"model": model, "contents": contents}
if sys_text:
payload["systemInstruction"] = {"parts": [{"text": sys_text}]}
if gen_cfg:
payload["generationConfig"] = gen_cfg
return payload
if provider == "claude":
sys_text = "\n\n".join([m["content"] for m in messages if m["role"] == "system"]).strip()
msgs = []
for m in messages:
if m["role"] == "system":
continue
role = m["role"] if m["role"] in {"user", "assistant"} else "user"
msgs.append({"role": role, "content": [{"type": "text", "text": m["content"]}]})
payload: Dict[str, Any] = {
"model": model,
"messages": msgs,
"anthropic_version": context.get("anthropic_version", "2023-06-01"),
}
if sys_text:
payload["system"] = sys_text
if params.get("temperature") is not None:
payload["temperature"] = params.get("temperature")
if params.get("max_tokens") is not None:
payload["max_tokens"] = params.get("max_tokens")
if params.get("top_p") is not None:
payload["top_p"] = params.get("top_p")
if params.get("stop") is not None:
payload["stop"] = params.get("stop")
return payload
return {}
def _blocks_struct_for_template(self, provider: str, messages: List[Dict[str, Any]], context: Dict[str, Any]) -> Dict[str, Any]:
"""
Сформировать структуру для вставки в шаблон (template) из Prompt Blocks.
Возвращает provider-специфичные ключи, которые можно вставлять в JSON:
- openai: { "messages": [...] , "system_text": "..." }
- gemini: { "contents": [...], "systemInstruction": {...}, "system_text": "..." }
- claude: { "system_text": "...", "system": "...", "messages": [...] }
"""
provider = (provider or "openai").lower()
# Гарантируем список
msgs = messages or []
if provider == "openai":
# Уже в формате {"role","content","name?"}
sys_text = "\n\n".join([m["content"] for m in msgs if m.get("role") == "system"]).strip()
# Вставляем как есть (editor будет встраивать JSON массива без кавычек)
return {
"messages": [
{k: v for k, v in {"role": m["role"], "content": m.get("content"), "name": m.get("name")}.items() if v is not None}
for m in msgs
],
"system_text": sys_text,
}
if provider == "gemini":
sys_text = "\n\n".join([m["content"] for m in msgs if m.get("role") == "system"]).strip()
contents = []
for m in msgs:
if m.get("role") == "system":
continue
role = "model" if m.get("role") == "assistant" else "user"
contents.append({"role": role, "parts": [{"text": str(m.get("content") or "")}]})
sys_instr = {"parts": [{"text": sys_text}]} if sys_text else {} # всегда корректный JSON-объект
return {
"contents": contents,
"systemInstruction": sys_instr,
"system_text": sys_text,
}
if provider == "claude":
sys_text = "\n\n".join([m["content"] for m in msgs if m.get("role") == "system"]).strip()
out_msgs = []
for m in msgs:
if m.get("role") == "system":
continue
role = m.get("role")
role = role if role in {"user", "assistant"} else "user"
out_msgs.append({"role": role, "content": [{"type": "text", "text": str(m.get("content") or "")}]})
return {
"system_text": sys_text,
"system": sys_text, # удобно для шаблона: "system": "{{ pm.system_text }}"
"messages": out_msgs,
}
# По умолчанию ничего, но это валидный JSON
return {"messages": []}
async def run(self, inputs: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
provider = (self.config.get("provider") or "openai").lower()
# Support provider-specific configs stored in UI as provider_configs.{provider}
prov_cfg: Dict[str, Any] = {}
try:
cfgs = self.config.get("provider_configs") or {}
if isinstance(cfgs, dict):
prov_cfg = cfgs.get(provider) or {}
except Exception: # noqa: BLE001
prov_cfg = {}
base_url = prov_cfg.get("base_url") or self.config.get("base_url")
if not base_url:
raise ExecutionError(f"Node {self.node_id} ({self.type_name}) requires 'base_url' in config")
if not str(base_url).startswith(("http://", "https://")):
base_url = "http://" + str(base_url)
endpoint_tmpl: str = prov_cfg.get("endpoint") or self.config.get("endpoint") or ""
template: str = prov_cfg.get("template") or self.config.get("template") or "{}"
headers_json: str = prov_cfg.get("headers") or self.config.get("headers") or "{}"
# Default endpoints if not set
if not endpoint_tmpl:
if provider == "openai":
endpoint_tmpl = "/v1/chat/completions"
elif provider == "gemini":
endpoint_tmpl = "/v1beta/models/{{ model }}:generateContent"
elif provider == "claude":
endpoint_tmpl = "/v1/messages"
# Подготовим Prompt Blocks + pm-структуру для шаблона
unified_msgs = self._render_blocks_to_unified(context)
pm_struct = self._blocks_struct_for_template(provider, unified_msgs, context)
# Расширяем контекст для рендера шаблонов
render_ctx = dict(context)
render_ctx["pm"] = pm_struct
# Единый JSON-фрагмент PROMPT для шаблонов: [[PROMPT]]
prompt_fragment = ""
try:
if provider == "openai":
prompt_fragment = '"messages": ' + json.dumps(pm_struct.get("messages", []), ensure_ascii=False)
elif provider == "gemini":
parts = []
contents = pm_struct.get("contents")
if contents is not None:
parts.append('"contents": ' + json.dumps(contents, ensure_ascii=False))
sysi = pm_struct.get("systemInstruction")
# даже если пустой объект {}, это валидно
if sysi is not None:
parts.append('"systemInstruction": ' + json.dumps(sysi, ensure_ascii=False))
prompt_fragment = ", ".join(parts)
elif provider == "claude":
parts = []
sys_text = pm_struct.get("system_text") or pm_struct.get("system")
if sys_text is not None:
parts.append('"system": ' + json.dumps(sys_text, ensure_ascii=False))
msgs = pm_struct.get("messages")
if msgs is not None:
parts.append('"messages": ' + json.dumps(msgs, ensure_ascii=False))
prompt_fragment = ", ".join(parts)
except Exception: # noqa: BLE001
prompt_fragment = ""
render_ctx["PROMPT"] = prompt_fragment
# Render helper с поддержкой [[VAR]], [[OUT]] и {{ ... }}
def render(s: str) -> str:
return render_template_simple(s or "", render_ctx, render_ctx.get("OUT") or {})
# Рендер endpoint с макросами/шаблонами
endpoint = render(endpoint_tmpl)
# Формируем тело ТОЛЬКО из template/[[PROMPT]] (без сырого payload/входов)
try:
rendered = render(template)
payload = json.loads(rendered)
except Exception:
# Fallback: используем генерацию из Prompt Blocks в формате провайдера
payload = self._messages_to_payload(provider, unified_msgs, context)
# Заголовки — полностью из редактируемого JSON с макросами
try:
headers_src = render(headers_json) if headers_json else "{}"
headers = json.loads(headers_src) if headers_src else {}
if not isinstance(headers, dict):
raise ValueError("headers must be a JSON object")
except Exception as exc: # noqa: BLE001
raise ExecutionError(f"ProviderCall headers invalid JSON: {exc}")
# Итоговый URL
if not base_url.startswith(("http://", "https://")):
base_url = "http://" + base_url
url = endpoint if endpoint.startswith("http") else urljoin(base_url.rstrip('/') + '/', endpoint.lstrip('/'))
# Debug logs to validate config selection and payload
try:
payload_preview = ""
try:
payload_preview = json.dumps(payload, ensure_ascii=False)[:400]
except Exception:
payload_preview = str(payload)[:400]
print(f"DEBUG: ProviderCallNode provider={provider} URL={url}")
print(f"DEBUG: ProviderCallNode headers_keys={list(headers.keys())}")
print(f"DEBUG: ProviderCallNode payload_preview={payload_preview}")
except Exception:
pass
async with build_client() as client:
resp = await client.post(url, json=payload, headers={"Content-Type": "application/json", **headers})
resp.raise_for_status()
data = resp.json()
# Извлекаем текст best-effort
text = None
if provider == "openai":
try:
text = data.get("choices", [{}])[0].get("message", {}).get("content")
except Exception: # noqa: BLE001
text = None
elif provider == "gemini":
try:
text = data.get("candidates", [{}])[0].get("content", {}).get("parts", [{}])[0].get("text")
except Exception: # noqa: BLE001
text = None
elif provider == "claude":
try:
blocks = data.get("content") or []
texts = [b.get("text") for b in blocks if isinstance(b, dict) and b.get("type") == "text"]
text = "\n".join([t for t in texts if isinstance(t, str)])
except Exception: # noqa: BLE001
text = None
return {"result": data, "response_text": text or ""}
class RawForwardNode(Node):
type_name = "RawForward"
async def run(self, inputs: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
incoming = context.get("incoming", {})
raw_payload = incoming.get("json")
base_url: Optional[str] = self.config.get("base_url")
override_path: Optional[str] = self.config.get("override_path")
# Разрешаем макросы в конфиге RawForward (base_url, override_path)
out_map_for_macros = context.get("OUT") or {}
if base_url:
base_url = render_template_simple(str(base_url), context, out_map_for_macros)
if override_path:
override_path = render_template_simple(str(override_path), context, out_map_for_macros)
# Если base_url не указан, включаем автодетекцию
if not base_url:
vendor = detect_vendor(raw_payload)
if vendor == "openai":
base_url = "https://api.openai.com"
elif vendor == "claude":
base_url = "https://api.anthropic.com"
elif vendor == "gemini":
base_url = "https://generativelanguage.googleapis.com"
else:
raise ExecutionError(f"Node {self.node_id} ({self.type_name}): 'base_url' is not configured and vendor could not be detected.")
# Гарантируем наличие схемы у base_url
if not base_url.startswith(("http://", "https://")):
base_url = "http://" + base_url
path = override_path or incoming.get("path") or "/"
query = incoming.get("query")
if query:
path_with_qs = f"{path}?{query}"
else:
path_with_qs = path
url = urljoin(base_url.rstrip("/") + "/", path_with_qs.lstrip("/"))
passthrough_headers: bool = bool(self.config.get("passthrough_headers", True))
extra_headers_json: str = self.config.get("extra_headers") or "{}"
# Макросы в extra_headers
try:
extra_headers_src = render_template_simple(extra_headers_json, context, out_map_for_macros) if extra_headers_json else "{}"
extra_headers = json.loads(extra_headers_src) if extra_headers_src else {}
if not isinstance(extra_headers, dict):
raise ValueError("extra_headers must be an object")
except Exception as exc: # noqa: BLE001
raise ExecutionError(f"RawForward extra_headers invalid JSON: {exc}")
headers: Dict[str, str] = {}
if passthrough_headers:
inc_headers = incoming.get("headers") or {}
# Копируем все заголовки, кроме Host и Content-Length
for k, v in inc_headers.items():
if k.lower() not in ['host', 'content-length']:
headers[k] = v
# Убедимся, что Content-Type на месте, если его не было
if 'content-type' not in {k.lower() for k in headers}:
headers['Content-Type'] = 'application/json'
headers.update(extra_headers)
print(f"DEBUG: RawForwardNode sending request to URL: {url}")
print(f"DEBUG: RawForwardNode sending with HEADERS: {headers}")
async with build_client() as client:
resp = await client.post(url, json=raw_payload, headers=headers)
# Логируем ответ от целевого API для диагностики
try:
data = resp.json()
print(f"DEBUG: RawForwardNode received response. Status: {resp.status_code}, Body: {data}")
except Exception:
data = {"error": "Failed to decode JSON from upstream", "text": resp.text}
print(f"DEBUG: RawForwardNode received non-JSON response. Status: {resp.status_code}, Text: {resp.text}")
# Не выбрасываем исключение, а просто проксируем ответ
# resp.raise_for_status()
return {"result": data}
NODE_REGISTRY.update({
ProviderCallNode.type_name: ProviderCallNode,
RawForwardNode.type_name: RawForwardNode,
})

View File

@@ -0,0 +1,44 @@
from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List
import json
from agentui.pipeline.defaults import default_pipeline
PIPELINE_FILE = Path("pipeline.json")
PRESETS_DIR = Path("presets")
def load_pipeline() -> Dict[str, Any]:
if PIPELINE_FILE.exists():
try:
return json.loads(PIPELINE_FILE.read_text(encoding="utf-8"))
except Exception:
pass
return default_pipeline()
def save_pipeline(pipeline: Dict[str, Any]) -> None:
PIPELINE_FILE.write_text(json.dumps(pipeline, ensure_ascii=False, indent=2), encoding="utf-8")
def list_presets() -> List[str]:
PRESETS_DIR.mkdir(parents=True, exist_ok=True)
return sorted([p.stem for p in PRESETS_DIR.glob("*.json")])
def load_preset(name: str) -> Dict[str, Any]:
PRESETS_DIR.mkdir(parents=True, exist_ok=True)
path = PRESETS_DIR / f"{name}.json"
if not path.exists():
raise FileNotFoundError(name)
return json.loads(path.read_text(encoding="utf-8"))
def save_preset(name: str, pipeline: Dict[str, Any]) -> None:
PRESETS_DIR.mkdir(parents=True, exist_ok=True)
path = PRESETS_DIR / f"{name}.json"
path.write_text(json.dumps(pipeline, ensure_ascii=False, indent=2), encoding="utf-8")

View File

@@ -0,0 +1,2 @@

View File

@@ -0,0 +1,14 @@
from __future__ import annotations
import httpx
from typing import Optional, Dict
from agentui.config import build_httpx_proxies
def build_client(timeout: float = 60.0) -> httpx.AsyncClient:
proxies: Optional[Dict[str, str]] = build_httpx_proxies()
# httpx сам понимает схемы socks://, socks5:// при установленном extras [socks]
client = httpx.AsyncClient(timeout=timeout, proxies=proxies, follow_redirects=True)
return client

182
docs/VARIABLES.md Normal file
View File

@@ -0,0 +1,182 @@
# Переменные и макросы AgentUI
Этот файл — простая шпаргалка по переменным/макросам, которые можно использовать в шаблонах узла ProviderCall и в Prompt Blocks.
Правила ввода:
- Квадратные макросы [[...]] — простая подстановка. Хорошо подходят для строк и для URL/заголовков.
- Фигурные {{ ... }} — «джинджа‑лайт»: умеют фильтр |default(...), корректно вставляют объекты и массивы внутрь JSON без лишних кавычек.
- Любые значения, вставляемые в JSON через макросы, приводятся к корректному JSON когда это возможно.
Служебные файлы/строки реализации:
- Рендеринг и макросы: [render_template_simple()](agentui/pipeline/executor.py:125)
- Провайдерный узел с формированием PROMPT: [ProviderCallNode.run()](agentui/pipeline/executor.py:565)
---
## Общие переменные контекста
- [[model]] — активная модель (строка)
- [[vendor_format]] — openai | gemini | claude | unknown
- [[system]] — системный текст, если был во входящем запросе
- [[params.temperature]], [[params.max_tokens]], [[params.top_p]], [[params.stop]]
- [[chat.last_user]] — последнее userсообщение
- [[chat.messages]] — массив унифицированных сообщений
- [[incoming.path]] — путь входящего HTTPзапроса
- [[incoming.query]] — строка query (?a=1&b=2)
- [[incoming.query_params]] — объект query, например {"key":"..."}
- [[incoming.headers]] — заголовки входящего запроса
- [[incoming.json]] — JSONтело входящего запроса клиента
- [[incoming.api_keys.authorization]] — значение Authorization (если есть)
- [[incoming.api_keys.key]] — значение ?key=... в URL (удобно для Gemini)
- [[incoming.api_keys.secret]] — запасной слот
Те же поля доступны через {{ ... }}: например {{ params.temperature|default(0.7) }}, {{ incoming.json }} и т.д.
---
## Макросы OUT (выходы нод)
Доступ к выходам нод возможен в двух формах:
### 1) Короткая форма (besteffort текст)
- [[OUT1]] — «текст» из ноды n1
- [[OUT2]] — из ноды n2 и т.д.
Что делает «besteffort текст»:
- Если нода вернула response_text или text — берётся он
- Если нода вернула объект провайдера:
- OpenAI: choices[0].message.content
- Gemini: candidates[0].content.parts[0].text
- Claude: content[].text (склейка)
- Если ничего из выше не подошло — выполняется глубокий поиск текстовых полей ("text"/"content")
Реализация: [_best_text_from_outputs()](agentui/pipeline/executor.py:45) и подстановка коротких OUT: [render_template_simple()](agentui/pipeline/executor.py:155)
### 2) Полная форма (точный путь)
- [[OUT:n1.result]] — целиком результат ноды n1
- [[OUT:n1.result.candidates.0.content.parts.0.text]] — конкретный путь
- Эквивалент через фигурные скобки: {{ OUT.n1.result.candidates.0.content.parts.0.text }}
Совет: используйте короткий [[OUTx]] если нужно «просто текст». Используйте полную форму, если нужен конкретный фрагмент/массив.
---
## Единый фрагмент [[PROMPT]]
[[PROMPT]] — это уже собранный JSONфрагмент из ваших Prompt Blocks. Он зависит от выбранного провайдера ноды:
- OpenAI → "messages": [...]
- Gemini → "contents": [...], "systemInstruction": {...}
- Claude → "system": "...", "messages": [...]
Как использовать внутри JSONшаблона:
{
"model": "{{ model }}",
[[PROMPT]],
"temperature": {{ params.temperature|default(0.7) }}
}
Вы также можете использовать сырьевые структуры:
- {{ pm.messages }}
- {{ pm.contents }}
- {{ pm.systemInstruction }}
- {{ pm.system_text }}
Но рекомендуемый путь — [[PROMPT]]: меньше шансов сломать JSON.
---
## Примеры по провайдерам
### OpenAI (/v1/chat/completions)
{
"model": "{{ model }}",
[[PROMPT]],
"temperature": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},
"top_p": {{ incoming.json.top_p|default(params.top_p|default(1)) }},
"max_tokens": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},
"stop": {{ incoming.json.stop|default(params.stop|default([])) }}
}
### Gemini (/v1beta/models/{model}:generateContent?key=...)
{
"model": "{{ model }}",
[[PROMPT]],
"safetySettings": {{ incoming.json.safetySettings|default([]) }},
"generationConfig": {
"temperature": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},
"topP": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},
"maxOutputTokens": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},
"stopSequences": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }}
}
}
### Claude (/v1/messages)
{
"model": "{{ model }}",
[[PROMPT]],
"temperature": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},
"top_p": {{ incoming.json.top_p|default(params.top_p|default(1)) }},
"max_tokens": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }}
}
---
## Частые кейсы
1) Взять текст пользователя из входящего запроса и передать в Prompt Blocks
- Gemini: [[VAR:incoming.json.contents.0.parts.0.text]]
- OpenAI: [[VAR:incoming.json.messages.0.content]]
- Claude: [[VAR:incoming.json.messages.0.content.0.text]]
2) Переписать ответ предыдущей ноды «как текст»
- [[OUT1]] — если предыдущая нода имеет id n1
3) Добавить ключ Gemini из query в endpoint
- /v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]
---
## Почему местами нужны {{ ... }}
Внутри JSON нам важно вставлять объекты/массивы без кавычек и иметь дефолты:
- {{ pm.contents }} — вставит массив как массив
- {{ params.temperature|default(0.7) }} — если нет значения, подставится 0.7
Квадратные [[...]] хорошо подходят для строк/простых значений и для URL/заголовков.
---
## Отладка
- Проверьте лог DEBUG в консоли: ProviderCallNode показывает провайдера, URL и первые 400 символов тела запроса.
- Если «ничего не подставилось»:
- убедитесь, что не подаёте входной payload в ProviderCall (иначе шаблон игнорируется);
- проверьте валидность JSON после подстановок;
- проверьте, что макрос написан корректно (OUT против OUTn).
---
## МиниFAQ
В: Почему [[OUT1]] пустой?
О: Возможно, нода n1 не вернула текстового поля, и глубокий поиск не нашёл текста. Уточните путь через полную форму [[OUT:n1....]].
В: Можно ли получить весь «сырой» ответ?
О: [[OUT:n1.result]] — вернёт весь JSON результата ноды n1.
В: Почему фигурные скобки иногда обязательны?
О: Они умеют |default(...) и корректно вставляют объекты/массивы внутрь JSON.
---
## Ссылки на реализацию
- Макросы/рендер: [render_template_simple()](agentui/pipeline/executor.py:125)
- Единый [[PROMPT]]: [ProviderCallNode.run()](agentui/pipeline/executor.py:604)
- Короткий [[OUTx]] и извлечение текста: [render_template_simple()](agentui/pipeline/executor.py:155), [_best_text_from_outputs()](agentui/pipeline/executor.py:45)
Удачного редактирования!

13
main.py Normal file
View File

@@ -0,0 +1,13 @@
import uvicorn
import os
def main() -> None:
port = int(os.environ.get("PORT", "7860"))
uvicorn.run("agentui.api.server:app", host="127.0.0.1", port=port, reload=False)
if __name__ == "__main__":
main()

8
requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
fastapi==0.112.2
uvicorn==0.30.6
pydantic==2.8.2
httpx==0.27.0
starlette==0.38.2
httpx[socks]==0.27.0

26
run_agentui.bat Normal file
View File

@@ -0,0 +1,26 @@
@echo off
setlocal
chcp 65001 >NUL
set PORT=7860
echo Installing dependencies...
python -m pip install --upgrade pip
if errorlevel 1 goto :fail
pip install -r requirements.txt
if errorlevel 1 goto :fail
echo Starting НадTavern on http://127.0.0.1:%PORT%/
start "НадTavern UI" python -c "import time,webbrowser,os; time.sleep(1); webbrowser.open('http://127.0.0.1:%s/ui/editor.html'%os.environ.get('PORT','7860'))"
python -m uvicorn agentui.api.server:app --host 127.0.0.1 --port %PORT% --log-level info
if errorlevel 1 goto :fail
goto :end
:fail
echo.
echo Server failed with errorlevel %errorlevel%.
echo Check the console output above and the file agentui.log for details.
pause
:end
pause
endlocal

19
run_server.py Normal file
View File

@@ -0,0 +1,19 @@
import os
import webbrowser
def main() -> None:
port = int(os.environ.get("PORT", "7860"))
url = f"http://127.0.0.1:{port}/"
print(f"Starting НадTavern on {url}")
try:
# Open browser after server is up. We'll rely on .bat to start uvicorn and then open UI.
webbrowser.open(url)
except Exception:
pass
if __name__ == "__main__":
main()

304
static/editor.css Normal file
View File

@@ -0,0 +1,304 @@
:root {
/* Цвета темы (совпадают с editor.html) */
color-scheme: dark;
--bg: #0b0d10;
--panel: #11151a;
--muted: #a7b0bf;
--border: #1f2937;
--accent: #6ee7b7; /* зелёный */
--accent-2: #60a5fa; /* синий */
--node: #0e1116;
--node-border: #334155;
--node-selected: #1f2937;
--connector: #7aa2f7;
--connector-muted: #3b82f6;
}
/* Узлы: аккуратные контейнеры + предотвращение вылезания текста */
.drawflow .drawflow-node {
background: transparent !important;
box-shadow: none !important;
}
.drawflow .drawflow-node .title-box {
background: var(--node);
border: 1px solid var(--node-border);
color: #e5e7eb;
border-radius: 12px 12px 0 0;
padding: 6px 10px;
}
.drawflow .drawflow-node .box {
background: var(--node);
border: 1px solid var(--node-border);
border-top: 0;
color: #e5e7eb;
border-radius: 0 0 12px 12px;
overflow: hidden; /* не даём контенту вылезать за края */
}
.drawflow .drawflow-node .box textarea,
.drawflow .drawflow-node .box pre,
.drawflow .drawflow-node .box input[type="text"] {
background: #0f141a;
border: 1px solid #2b3646;
border-radius: 8px;
color: #e5e7eb;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.df-node .box textarea {
white-space: pre-wrap;
word-break: break-word;
overflow: auto;
max-height: 180px; /* предотвращаем бесконечную высоту */
}
/* Выделение выбранного узла — мягкое */
.drawflow .drawflow-node.selected .title-box,
.drawflow .drawflow-node.selected .box {
border-color: var(--accent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent);
}
/* Порты: более аккуратные, без «оранжевого» */
.drawflow .drawflow-node .inputs .input,
.drawflow .drawflow-node .outputs .output {
background: var(--accent-2) !important;
border: 2px solid color-mix(in srgb, var(--accent-2) 70%, white 0%) !important;
width: 12px !important;
height: 12px !important;
box-shadow: 0 0 0 2px rgba(0,0,0,.25);
}
/* Линии соединений: плавные, аккуратные цвета */
.drawflow .connection .main-path {
stroke: var(--connector) !important;
stroke-width: 2.5px !important;
opacity: 0.95 !important;
}
.drawflow .connection .main-path.selected,
.drawflow .connection:hover .main-path {
stroke: var(--accent-2) !important;
stroke-width: 3px !important;
}
/* Точки изгибов/ручки */
.drawflow .connection .point {
stroke: var(--connector-muted) !important;
fill: var(--panel) !important;
}
/* Убираем «уродливый крестик» удаления соединений (оставляем удаление через контекст-меню/клавиши) */
.drawflow .connection .delete,
.drawflow .connection .remove,
.drawflow .connection .connection-remove,
.drawflow .connection [class*="remove"],
.drawflow .connection [class*="delete"] {
display: none !important;
}
/* Сайдбар: выравнивание и аккуратные подсказки */
.group-title {
font-size: 12px;
text-transform: uppercase;
color: var(--muted);
margin: 12px 0 6px;
letter-spacing: .08em;
}
.hint {
color: var(--muted);
font-size: 12px;
margin-top: 4px;
}
details.help { margin: 6px 0; }
details.help summary {
list-style: none;
cursor: pointer;
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: #334155;
color: #e5e7eb;
font-weight: 700;
border: 1px solid #2b3646;
}
details.help summary::-webkit-details-marker { display: none; }
details.help .panel {
margin-top: 8px;
background: #0f141a;
border: 1px solid #2b3646;
padding: 10px;
border-radius: 8px;
}
/* Инпуты/тексты внутри нод — одинаковые отступы и скругления */
textarea, input[type=text] {
width: 100%;
background: #0f141a;
color: #e5e7eb;
border: 1px solid #2b3646;
border-radius: 8px;
padding: 8px;
}
/* Кнопки */
button {
background: #1f2937;
border: 1px solid #334155;
color: #e5e7eb;
padding: 6px 10px;
border-radius: 8px;
cursor: pointer;
}
button:hover { background: #273246; }
/* Внутренние заголовки в блоке ноды */
#inspector label { font-size: 12px; color: var(--muted); display: block; margin: 8px 0 4px; }
/* Мелкие фиксы */
.drawflow .drawflow-node .input, .drawflow .drawflow-node .output { color: var(--muted); }
/* Connection delete control — show and restyle (kept functional) */
.drawflow .connection foreignObject,
.drawflow .connection [class*="remove"],
.drawflow .connection [class*="delete"],
.drawflow .connection .connection-remove {
display: inline-flex !important;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 999px;
background: #0f141a;
color: #e5e7eb;
border: 1px solid #334155;
box-shadow: 0 2px 6px rgba(0,0,0,.35);
cursor: pointer;
opacity: .85;
transition: transform .12s ease, opacity .12s ease, box-shadow .12s ease, border-color .12s ease, background-color .12s ease;
}
.drawflow .connection:hover foreignObject,
.drawflow .connection:hover [class*="remove"],
.drawflow .connection:hover [class*="delete"],
.drawflow .connection:hover .connection-remove {
opacity: 1;
transform: scale(1.05);
border-color: var(--accent-2);
box-shadow: 0 0 0 3px rgba(96,165,250,.20), 0 4px 10px rgba(0,0,0,.35);
}
/* If delete control is rendered inside foreignObject, normalize inner box */
.drawflow .connection foreignObject div,
.drawflow .connection foreignObject span {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 999px;
background: #0f141a;
color: #e5e7eb;
border: 1px solid #334155;
}
/* If delete control is rendered as SVG text "x" */
.drawflow .connection text {
font-family: Inter, system-ui, Arial, sans-serif;
font-size: 12px;
font-weight: 700;
fill: #e5e7eb;
}
/* Subtle canvas background (lightweight dot grid) */
#canvas {
background-color: var(--bg);
background-image: radial-gradient(circle at 1px 1px, rgba(255,255,255,0.06) 1px, transparent 0);
background-size: 24px 24px;
}
/* Port hover affordance (no heavy effects) */
.drawflow .drawflow-node .inputs .input,
.drawflow .drawflow-node .outputs .output {
transition: transform .08s ease;
will-change: transform;
}
.drawflow .drawflow-node .inputs .input:hover,
.drawflow .drawflow-node .outputs .output:hover {
transform: scale(1.25);
box-shadow: 0 0 0 3px rgba(96,165,250,.25);
}
/* Node delete "X" — minimal, clean, consistent with theme (kept functional) */
.drawflow .drawflow-node .close {
position: absolute !important; /* stays in node corner */
top: -8px !important;
right: -8px !important;
width: 18px !important;
height: 18px !important;
display: grid !important;
place-items: center !important;
border-radius: 999px !important;
font-size: 12px !important;
line-height: 1 !important;
font-weight: 700 !important;
background: #0f141a !important; /* dark chip */
color: #e5e7eb !important;
border: 1px solid #334155 !important; /* subtle border */
box-shadow: 0 2px 6px rgba(0,0,0,.35) !important;
cursor: pointer !important;
z-index: 10 !important;
transition: transform .12s ease, box-shadow .12s ease, background-color .12s ease, border-color .12s ease, color .12s ease !important;
}
.drawflow .drawflow-node .close:hover {
transform: scale(1.06) !important;
background: #1f2937 !important;
border-color: var(--accent-2) !important;
color: #f8fafc !important;
box-shadow: 0 0 0 3px rgba(96,165,250,.22), 0 4px 10px rgba(0,0,0,.35) !important;
}
.drawflow .drawflow-node .close:active {
transform: scale(0.98) !important;
box-shadow: 0 0 0 2px rgba(96,165,250,.20), 0 2px 6px rgba(0,0,0,.35) !important;
}
/* Drawflow floating delete handle (class: .drawflow-delete) — restyle but keep behavior */
#drawflow .drawflow-delete,
.drawflow-delete {
position: absolute !important;
transform: translate(-50%, -50%) !important;
width: 20px !important;
height: 20px !important;
display: grid !important;
place-items: center !important;
border-radius: 999px !important;
background: #0f141a !important;
border: 1px solid #334155 !important;
color: transparent !important; /* hide default "x" text to avoid double symbol */
box-shadow: 0 2px 6px rgba(0,0,0,.35) !important;
cursor: pointer !important;
z-index: 1000 !important;
transition: transform .12s ease, box-shadow .12s ease, background-color .12s ease, border-color .12s ease !important;
}
#drawflow .drawflow-delete::before,
.drawflow-delete::before {
content: "×";
font-family: Inter, system-ui, Arial, sans-serif;
font-size: 13px;
font-weight: 700;
line-height: 1;
color: #e5e7eb;
}
#drawflow .drawflow-delete:hover,
.drawflow-delete:hover {
transform: translate(-50%, -50%) scale(1.06) !important;
background: #1f2937 !important;
border-color: var(--accent-2) !important;
box-shadow: 0 0 0 3px rgba(96,165,250,.22), 0 4px 10px rgba(0,0,0,.35) !important;
}
#drawflow .drawflow-delete:active,
.drawflow-delete:active {
transform: translate(-50%, -50%) scale(0.97) !important;
}

981
static/editor.html Normal file
View File

@@ -0,0 +1,981 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>НадTavern — Визуальный редактор нод</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/drawflow@0.0.55/dist/drawflow.min.css" />
<style>
:root {
color-scheme: dark;
--bg: #0b0d10;
--panel: #11151a;
--muted: #a7b0bf;
--border: #1f2937;
--accent: #6ee7b7;
--accent-2: #60a5fa;
--node: #0e1116;
--node-border: #334155;
--node-selected: #1f2937;
--connector: #94a3b8;
}
body { margin: 0; font-family: Inter, Arial, sans-serif; background: var(--bg); color: #e5e7eb; }
header { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-bottom: 1px solid var(--border); background: var(--panel); }
header .actions { display: flex; gap: 8px; }
button { background: #1f2937; border: 1px solid #334155; color: #e5e7eb; padding: 6px 10px; border-radius: 8px; cursor: pointer; }
button:hover { background: #273246; }
#container { display: grid; grid-template-columns: 260px 1fr 360px; height: calc(100vh - 52px); }
#sidebar { border-right: 1px solid var(--border); padding: 12px; background: var(--panel); overflow: auto; }
#canvas { position: relative; }
#inspector { border-left: 1px solid var(--border); padding: 12px; overflow: auto; background: var(--panel); }
#drawflow { width: 100%; height: 100%; }
.group-title { font-size: 12px; text-transform: uppercase; color: var(--muted); margin: 12px 0 6px; letter-spacing: .08em; }
.node-btn { width: 100%; text-align: left; margin-bottom: 6px; border-left: 3px solid transparent; }
.node-btn:hover { border-left-color: var(--accent-2); }
.hint { color: var(--muted); font-size: 12px; margin-top: 4px; }
.df-node .title-box { background: var(--node); border: 1px solid var(--node-border); color: #e5e7eb; border-radius: 10px 10px 0 0; }
.df-node .box { background: var(--node); border: 1px solid var(--node-border); border-top: 0; color: #e5e7eb; border-radius: 0 0 10px 10px; }
/* Override default blue styles from drawflow */
.drawflow .drawflow-node .title { background: transparent !important; color: inherit !important; }
.drawflow .drawflow-node { background: transparent !important; }
/* moved to external CSS: ports styling is defined in editor.css */
.drawflow .drawflow-node.selected { box-shadow: none; }
.drawflow .connection .main-path { stroke: var(--connector); }
.drawflow .connection .point { stroke: var(--connector); fill: var(--panel); }
.drawflow .drawflow-node.selected .title-box, .drawflow .drawflow-node.selected .box { border-color: var(--accent); box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 50%, transparent); }
.df-node .input, .df-node .output { color: var(--muted); }
textarea, input[type=text] { width: 100%; background: #0f141a; color: #e5e7eb; border: 1px solid #2b3646; border-radius: 8px; padding: 8px; }
label { font-size: 12px; color: var(--muted); display: block; margin: 8px 0 4px; }
pre { background: #0f141a; border: 1px solid #2b3646; padding: 10px; border-radius: 8px; overflow: auto; }
.preview { pointer-events: none; opacity: .85; }
/* Help popups */
details.help { margin: 6px 0; }
details.help summary { list-style: none; cursor: pointer; display: inline-block; width: 20px; height: 20px; border-radius: 50%; background: #334155; color: #e5e7eb; text-align: center; line-height: 20px; font-weight: 700; border: 1px solid #2b3646; }
details.help summary::-webkit-details-marker { display: none; }
details.help .panel { margin-top: 8px; background: #0f141a; border: 1px solid #2b3646; padding: 10px; border-radius: 8px; }
</style>
<link rel="stylesheet" href="/ui/editor.css" />
</head>
<body>
<header>
<div>
<strong>НадTavern</strong> — Визуальный редактор нод
</div>
<div class="actions">
<button id="btn-load">Загрузить пайплайн</button>
<button id="btn-save">Сохранить пайплайн</button>
<input id="preset-name" placeholder="имя пресета" style="width:140px"/>
<button id="btn-save-preset">Сохранить пресет</button>
<select id="preset-select" style="width:160px"></select>
<button id="btn-load-preset">Загрузить пресет</button>
<a href="/" style="text-decoration:none"><button>Домой</button></a>
</div>
</header>
<div id="container">
<aside id="sidebar">
<div class="group-title">Ноды</div>
<button title="Запрос к провайдеру (openai/gemini/claude) с настраиваемым endpoint и JSON" class="node-btn" data-node="ProviderCall">ProviderCall</button>
<button title="Прямой форвард входящего запроса как reverse-proxy" class="node-btn" data-node="RawForward">RawForward</button>
<div class="hint">Подсказка: соедините выход предыдущей ноды с входом следующей, сохраните и тестируйте через /ui.</div>
<div class="group-title">Переменные и макросы</div>
<div class="hint">Используйте переменные в шаблонах как <code>[[variable]]</code>. Наведите курсор на имя переменной, чтобы увидеть подсказку.</div>
<div class="hint"><strong>Общие:</strong>
<code title="Системные инструкции для LLM">[[system]]</code>,
<code title="Имя выбранной модели">[[model]]</code>,
<code title="Формат ответа провайдера (например, OpenAI, Gemini)">[[vendor_format]]</code>
</div>
<div class="hint"><strong>Чат:</strong>
<code title="Последнее сообщение пользователя">[[chat.last_user]]</code>,
<code title="Все сообщения в чате">[[chat.messages]]</code>
</div>
<div class="hint"><strong>Параметры:</strong>
<code title="Температура выборки (01)">[[params.temperature]]</code>,
<code title="Максимальное число токенов">[[params.max_tokens]]</code>,
<code title="Вероятностный срез top-p sampling">[[params.top_p]]</code>,
<code title="Стоп-слова/условия обрыва генерации">[[params.stop]]</code>
</div>
<div class="hint"><strong>Входящий запрос:</strong>
<code title="Путь в URL запроса">[[incoming.path]]</code>,
<code title="Query-параметры запроса">[[incoming.query]]</code>
</div>
<div class="hint"><strong>Заголовки/тело:</strong>
<code title="Все заголовки HTTP-запроса">[[incoming.headers]]</code>,
<code title="JSON-тело входящего запроса">[[incoming.json]]</code>
</div>
<div class="hint"><strong>Ключи (API Keys):</strong>
<code title="Основной ключ авторизации (например Authorization: Bearer ...)">[[incoming.api_keys.authorization]]</code>,
<code title="Альтернативное имя ключа, если используется">[[incoming.api_keys.key]]</code>,
<code title="Вторичный ключ или секрет, если задан">[[incoming.api_keys.secret]]</code>
</div>
<div class="hint"><strong>Быстрые макросы:</strong>
<code title="Единый JSONфрагмент из Prompt Blocks (подставляется провайдер‑специфично)">[[PROMPT]]</code>,
<code title="Текст из выхода ноды n1 (besteffort, вытаскивает content/text из JSON ответа)">[[OUT1]]</code>,
<code title="Текст из выхода ноды n2">[[OUT2]]</code>
<span style="opacity:.85"> | Расширенно: <code>[[OUT:n1.result...]]</code> или <code>{{ OUT.n1.result... }}</code></span>
</div>
<div class="group-title">Отладка</div>
<pre id="status"></pre>
</aside>
<main id="canvas">
<div id="drawflow"></div>
</main>
<aside id="inspector">
<div class="group-title">Свойства ноды</div>
<div id="inspector-content">Выберите ноду…</div>
</aside>
</div>
<script src="https://cdn.jsdelivr.net/npm/drawflow@0.0.55/dist/drawflow.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<script>
// Типы портов и их имена в нашем контракте
const NODE_IO = {
// depends: используется только для порядка выполнения (зависимости), данные не читаются
ProviderCall: { inputs: ['depends'], outputs: ['result','response_text'] },
RawForward: { inputs: [], outputs: ['result'] }
};
const editor = new Drawflow(document.getElementById('drawflow'));
editor.reroute = true;
editor.start();
// Провайдерные пресеты для ProviderCall (редактируемые пользователем).
// Шаблоны используют {{ pm.* }} — это JSON-структуры, которые сервер собирает из Prompt Blocks.
// Поэтому подстановки в template дадут корректный JSON (массивы/объекты без кавычек).
function providerDefaults(provider) {
const p = (provider || 'openai').toLowerCase();
const T_OPENAI = `{
"model": "{{ model }}",
[[PROMPT]],
"temperature": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},
"top_p": {{ incoming.json.top_p|default(params.top_p|default(1)) }},
"max_tokens": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},
"max_completion_tokens": {{ incoming.json.max_completion_tokens|default(params.max_tokens|default(256)) }},
"presence_penalty": {{ incoming.json.presence_penalty|default(0) }},
"frequency_penalty": {{ incoming.json.frequency_penalty|default(0) }},
"stop": {{ incoming.json.stop|default(params.stop|default([])) }},
"stream": {{ incoming.json.stream|default(false) }}
}`;
const T_GEMINI = `{
"model": "{{ model }}",
[[PROMPT]],
"safetySettings": {{ incoming.json.safetySettings|default([]) }},
"generationConfig": {
"temperature": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},
"topP": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},
"maxOutputTokens": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},
"stopSequences": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }},
"candidateCount": {{ incoming.json.generationConfig.candidateCount|default(1) }},
"thinkingConfig": {
"includeThoughts": {{ incoming.json.generationConfig.thinkingConfig.includeThoughts|default(false) }},
"thinkingBudget": {{ incoming.json.generationConfig.thinkingConfig.thinkingBudget|default(0) }}
}
}
}`;
const T_CLAUDE = `{
"model": "{{ model }}",
[[PROMPT]],
"temperature": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},
"top_p": {{ incoming.json.top_p|default(params.top_p|default(1)) }},
"max_tokens": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},
"stop_sequences": {{ incoming.json.stop_sequences|default(params.stop|default([])) }},
"stream": {{ incoming.json.stream|default(false) }},
"thinking": {
"type": "{{ incoming.json.thinking.type|default('disabled') }}",
"budget_tokens": {{ incoming.json.thinking.budget_tokens|default(0) }}
},
"anthropic_version": "{{ anthropic_version|default('2023-06-01') }}"
}`;
if (p === 'openai') {
return {
base_url: 'https://api.openai.com',
endpoint: '/v1/chat/completions',
headers: `{"Authorization":"Bearer [[VAR:incoming.headers.authorization]]"}`,
template: T_OPENAI
};
}
if (p === 'gemini') {
// По умолчанию ключ часто идёт в query (?key=..). Заголовок оставляем пустым.
return {
base_url: 'https://generativelanguage.googleapis.com',
endpoint: '/v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]',
headers: `{}`,
template: T_GEMINI
};
}
if (p === 'claude') {
return {
base_url: 'https://api.anthropic.com',
endpoint: '/v1/messages',
headers: `{"x-api-key":"[[VAR:incoming.headers.x-api-key]]","anthropic-version":"2023-06-01","anthropic-beta":"[[VAR:incoming.headers.anthropic-beta]]"}`,
template: T_CLAUDE
};
}
// Unknown — пустые значения, чтобы пользователь всё заполнил руками
return { base_url: '', endpoint: '', headers: `{}`, template: `{}` };
}
// Helpers for provider-specific configs
function ensureProviderConfigs(d) {
if (!d) return;
if (!d.provider) d.provider = 'openai';
if (!d.provider_configs || typeof d.provider_configs !== 'object') d.provider_configs = {};
['openai','gemini','claude'].forEach(p=>{
if (!d.provider_configs[p]) d.provider_configs[p] = providerDefaults(p);
});
}
function getActiveProv(d) {
return (d && d.provider ? String(d.provider) : 'openai').toLowerCase();
}
function getActiveCfg(d) {
ensureProviderConfigs(d);
const p = getActiveProv(d);
return d.provider_configs[p] || {};
}
// Нормализуем/заполняем дефолты конфигов нод, чтобы ключи попадали в сериализацию
function applyNodeDefaults(type, data) {
const d = { ...(data || {}) };
if (type === 'ProviderCall') {
if (d.provider == null) d.provider = 'openai';
ensureProviderConfigs(d);
// Back-compat: top-level fields may exist, but UI prefers provider_configs
if (!Array.isArray(d.blocks)) d.blocks = [];
}
if (type === 'RawForward') {
if (d.passthrough_headers == null) d.passthrough_headers = true;
if (d.extra_headers == null) d.extra_headers = '{}';
}
return d;
}
const status = (t) => { document.getElementById('status').textContent = t; };
// Runtime CSS availability check
try {
const linkEl = document.querySelector('link[href$="editor.css"]');
if (linkEl) {
const href = linkEl.getAttribute('href');
fetch(href, { method: 'HEAD' })
.then(r => {
console.debug('[НадTavern] CSS HEAD', href, r.status);
if (!r.ok) {
try { status('CSS not reachable: ' + href + ' ' + r.status); } catch (e) {}
}
})
.catch(e => { try { status('CSS load error: ' + String(e)); } catch (e2) {} });
}
} catch (e) {}
function makeNodeHtml(type, data) {
if (type === 'ProviderCall') {
const provider = data.provider || 'openai';
const cfg = getActiveCfg(data);
const base_url = cfg.base_url || '';
const endpoint = cfg.endpoint || '';
const headers = (cfg.headers != null ? cfg.headers : '{"Authorization":"Bearer YOUR_KEY"}');
let tmpl;
if (cfg.template != null) {
tmpl = cfg.template;
} else if (provider === 'openai') {
tmpl = '{"model":"{{ model }}","messages":[{"role":"system","content":"{{ system }}"},{"role":"user","content":"{{ chat.last_user }}"}],"temperature":{{ params.temperature|default(0.7) }}}';
} else if (provider === 'gemini') {
tmpl = '{"contents":[{"role":"user","parts":[{"text":"{{ chat.last_user }}"}]}]}';
} else {
tmpl = '{"model":"{{ model }}","messages":[{"role":"user","content":[{"type":"text","text":"{{ chat.last_user }}"}]}],"max_tokens":256}';
}
const template = tmpl;return `<div class="box preview">
<label>provider</label>
<input type="text" value="${provider}" readonly />
<label>base_url</label>
<input type="text" value="${base_url.replace(/"/g,'&quot;')}" readonly />
<label>endpoint</label>
<input type="text" value="${endpoint.replace(/"/g,'&quot;')}" readonly />
<label>headers (preview JSON)</label>
<textarea readonly>${headers.replace(/</g,'&lt;')}</textarea>
<label>template (preview JSON)</label>
<textarea readonly>${template.replace(/</g,'&lt;')}</textarea>
</div>`;
}
if (type === 'RawForward') {
const base_url = data.base_url || '';
const override_path = data.override_path || '';
const passthrough_headers = (data.passthrough_headers ?? true) ? 'checked' : '';
const extra_headers = data.extra_headers || '{}';
return `<div class="box preview">
<label>base_url</label>
<input type="text" value="${base_url.replace(/"/g,'&quot;')}" readonly />
<label>override_path</label>
<input type="text" value="${override_path.replace(/"/g,'&quot;')}" readonly />
<label><input type="checkbox" ${passthrough_headers} disabled/> passthrough_headers</label>
<label>extra_headers (preview JSON)</label>
<textarea readonly>${extra_headers.replace(/</g,'&lt;')}</textarea>
</div>`;
}
return `<div class="box"></div>`;
}
function addNode(type, pos = {x: 100, y: 100}, data = {}) {
const io = NODE_IO[type];
const dataWithDefaults = applyNodeDefaults(type, data);
const html = makeNodeHtml(type, dataWithDefaults);
const id = editor.addNode(
type,
io.inputs.length,
io.outputs.length,
pos.x,
pos.y,
type,
dataWithDefaults,
html
);
// Привяжем данные к DOM для inline-редакторов
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = editor.getNodeFromId(id).data;
return id;
}
// Инспектор
editor.on('nodeSelected', function(id) {
const n = editor.getNodeFromId(id);
renderInspector(id, n);
// Обновим визуальные классы для лучшей читабельности
const el = document.querySelector(`#node-${id}`);
if (el) {
el.style.background = 'transparent';
el.style.borderRadius = '10px';
}
});
editor.on('nodeCreated', function(id) {
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = editor.getNodeFromId(id).data;
});
editor.on('nodeRemoved', function(id) {
document.getElementById('inspector-content').innerHTML = 'Выберите ноду…';
});
function renderInspector(id, node) {
const type = node.name;
node.data = applyNodeDefaults(type, node.data || {});
const data = node.data;
try { editor.updateNodeDataFromId(id, node.data); } catch(e) {}
const shownId = node.data?._origId || id;
let html = `<div><strong>${type}</strong> (#${shownId})</div>`;
if (type === 'ProviderCall') {
const cfg = getActiveCfg(data);
html += `
<label>provider</label>
<select id="f-provider">
<option value="openai">openai</option>
<option value="gemini">gemini</option>
<option value="claude">claude</option>
</select>
<label>base_url</label><input id="f-baseurl" type="text" value="${(cfg.base_url||'').replace(/"/g,'"')}" placeholder="https://api.openai.com">
<label>endpoint</label><input id="f-endpoint" type="text" value="${(cfg.endpoint||'').replace(/"/g,'"')}" placeholder="/v1/chat/completions">
<label>headers (JSON)</label><textarea id="f-headers">${(cfg.headers||'{}').replace(/</g,'<')}</textarea>
<label>template (JSON)</label>
<textarea id="f-template">${(cfg.template||'{}').replace(/</g,'<')}</textarea>
<div style="margin-top:6px">
<details class="help">
<summary title="Подсказка по шаблону">?</summary>
<div class="panel">
Шаблон поддерживает макросы и вставки из Prompt Blocks.
Рекомендуется использовать единый фрагмент <code>[[PROMPT]]</code> — он разворачивается провайдер‑специфично:
<br/>• openai → <code>"messages": [...]</code>
<br/>• gemini → <code>"contents": [...], "systemInstruction": {...}</code>
<br/>• claude → <code>"system": "...", "messages": [...]</code>
<br/>Также доступны структуры <code>{{ pm.* }}</code> для тонкой настройки (напр. <code>{{ pm.messages }}</code>).
<br/><strong>Важно:</strong> вход <code>depends</code> используется только для порядка выполнения, данные из него не читаются. Данные предыдущих нод вставляйте макросами <code>[[OUTx]]</code> или <code>[[OUT:nId...]]</code>.
</div>
</details>
</div>
`;
html += `
<div class="group-title" style="margin-top:16px">Prompt Blocks</div>
<div style="display:flex; gap:8px; align-items:center; margin-bottom:6px">
<button id="pm-add">Создать блок</button>
<details class="help" style="margin-left:4px">
<summary title="Подсказка по Prompt Blocks">?</summary>
<div class="panel">
Перетаскивайте блоки для изменения порядка. Включайте/выключайте тумблером.
<br/>Доступна переменная <code>[[PROMPT]]</code> — единый JSONфрагмент из этих блоков. Вставьте её в template объекта, например:
<br/><code>{ "model":"{{ model }}", [[PROMPT]], "temperature": {{ params.temperature|default(0.7) }} }</code>
<br/>Она автоматически формируется в зависимости от выбранного провайдера ноды.
</div>
</details>
</div>
<ul id="pm-list" style="list-style:none; padding-left:0; margin:0;"></ul>
<div id="pm-editor" style="margin-top:10px; display:none">
<label>Name</label>
<input id="pm-name" type="text" value="">
<label>Role</label>
<select id="pm-role">
<option value="system">system</option>
<option value="user">user</option>
<option value="assistant">assistant</option>
<option value="tool">tool</option>
</select>
<label>Prompt</label>
<textarea id="pm-prompt" rows="6"></textarea>
<div style="display:flex; gap:8px; margin-top:8px">
<button id="pm-save">Сохранить</button>
<button id="pm-cancel">Отмена</button>
</div>
</div>
`;
} else if (type === 'RawForward') {
html += `
<label>base_url</label><input id="f-baseurl" type="text" value="${(data.base_url||'').replace(/"/g,'&quot;')}" placeholder="https://api.openai.com">
<label>override_path</label><input id="f-override" type="text" value="${(data.override_path||'').replace(/"/g,'&quot;')}" placeholder="переопределить путь (опционально)">
<label><input id="f-pass" type="checkbox" ${(data.passthrough_headers??true)?'checked':''}> passthrough_headers</label>
<label>extra_headers (JSON)</label><textarea id="f-extra">${(data.extra_headers||'{}').replace(/</g,'&lt;')}</textarea>
<div class="hint">Берёт path, query, headers, json из incoming.*</div>
`;
}
html += `
<div style="margin-top:10px">
<button id="btn-save-node">Сохранить параметры</button>
</div>
`;
// html += makeNodeHtml(type, data); // Убираем дублирование превью в инспекторе
document.getElementById('inspector-content').innerHTML = html;
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = node.data; // синхронизация
document.querySelectorAll('#inspector textarea, #inspector input').forEach(inp => {
inp.addEventListener('input', () => {
const n = editor.getNodeFromId(id);
if (!n) return;
const d = n.data || {};
if (type === 'ProviderCall') {
ensureProviderConfigs(d);
const p = (d.provider || 'openai').toLowerCase();
const cfg = d.provider_configs[p] || (d.provider_configs[p] = providerDefaults(p));
if (inp.id === 'f-template') cfg.template = inp.value;
if (inp.id === 'f-baseurl') cfg.base_url = inp.value;
if (inp.id === 'f-endpoint') cfg.endpoint = inp.value;
if (inp.id === 'f-headers') cfg.headers = inp.value;
if (inp.id === 'f-provider') d.provider = inp.value; // select changes provider
} else {
if (inp.id === 'f-template') d.template = inp.value;
if (inp.id === 'f-model') d.model = inp.value;
if (inp.id === 'f-extra') d.extra_headers = inp.value;
if (inp.id === 'f-override') d.override_path = inp.value;
if (inp.id === 'f-pass') d.passthrough_headers = inp.checked;
}
// Синхронизуем в Drawflow, чтобы export() видел обновления
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = d;
});
});
// Поддержка select#f-provider + автоподстановка пресетов (без жесткого перезаписывания ручных правок)
const provSel = document.getElementById('f-provider');
if (provSel) {
// Установим текущее значение
provSel.value = (node.data?.provider || 'openai');
provSel.addEventListener('change', () => {
const n = editor.getNodeFromId(id);
if (!n) return;
const d = n.data || {};
d.provider = provSel.value;
ensureProviderConfigs(d);
const cfg = getActiveCfg(d);
const baseEl = document.getElementById('f-baseurl');
const endEl = document.getElementById('f-endpoint');
const headEl = document.getElementById('f-headers');
const tmplEl = document.getElementById('f-template');
// Отображаем сохранённые значения выбранного провайдера
if (baseEl) baseEl.value = cfg.base_url || '';
if (endEl) endEl.value = cfg.endpoint || '';
if (headEl) headEl.value = (cfg.headers != null ? cfg.headers : '{}');
if (tmplEl) tmplEl.value = (cfg.template != null ? cfg.template : '{}');
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = d;
try { console.debug('[ProviderCall] provider switched to', d.provider, cfg); } catch (e) {}
});
}
// Кнопка сохранить параметры
const saveBtnNode = document.getElementById('btn-save-node');
if (saveBtnNode) {
saveBtnNode.addEventListener('click', () => {
const n = editor.getNodeFromId(id);
if (!n) return;
try { editor.updateNodeDataFromId(id, n.data || {}); } catch (e) {}
const el = document.querySelector(`#node-${id}`);
if (el) el.__data = JSON.parse(JSON.stringify(n.data || {}));
try { savePipeline(); } catch (e) {}
status("Параметры ноды сохранены в pipeline.json");
});
}
// ensure blocks explicitly kept in node data
const ncheck = editor.getNodeFromId(id);
if (ncheck && Array.isArray(ncheck.data.blocks)) {
ncheck.data.blocks = [...ncheck.data.blocks];
}
// Prompt Manager UI for ProviderCall
if (type === 'ProviderCall') {
const n2 = editor.getNodeFromId(id);
const d2 = n2.data;
if (!Array.isArray(d2.blocks)) d2.blocks = [];
// Ensure node.data and DOM __data always reflect latest blocks
function syncNodeDataBlocks() {
try {
const n = editor.getNodeFromId(id);
if (!n) return;
// Готовим новые данные с глубокой копией blocks
const newData = { ...(n.data || {}) , blocks: Array.isArray(d2.blocks) ? d2.blocks.map(b => ({...b})) : [] };
// 1) Обновляем внутреннее состояние Drawflow, чтобы export() возвращал актуальные данные
try { editor.updateNodeDataFromId(id, newData); } catch (e) {}
// 2) Обновляем DOM-отражение (источник правды для toPipelineJSON)
const el2 = document.querySelector(`#node-${id}`);
if (el2) el2.__data = JSON.parse(JSON.stringify(newData));
} catch (e) {}
}
// Initial sync to attach blocks into __data for toPipelineJSON
syncNodeDataBlocks();
const listEl = document.getElementById('pm-list');
const addBtn = document.getElementById('pm-add');
const editorBox = document.getElementById('pm-editor');
const nameInp = document.getElementById('pm-name');
const roleSel = document.getElementById('pm-role');
const promptTxt = document.getElementById('pm-prompt');
const saveBtn = document.getElementById('pm-save');
const cancelBtn = document.getElementById('pm-cancel');
let editingId = null;
// Изменения блока применяются только по кнопке «Сохранить» внутри редактора блока.
// --- FIX: Drag&Drop через SortableJS ---
if (window.Sortable && listEl && !listEl.__sortable) {
listEl.__sortable = new Sortable(listEl, {
animation: 150,
handle: '.pm-handle',
onEnd(evt) {
const oldIndex = evt.oldIndex;
const newIndex = evt.newIndex;
if (oldIndex === newIndex) return;
const moved = d2.blocks.splice(oldIndex, 1)[0];
d2.blocks.splice(newIndex, 0, moved);
d2.blocks.forEach((b,i)=> b.order = i);
syncNodeDataBlocks();
}
});
}
function sortAndReindex() {
d2.blocks.sort((a,b)=> (a.order ?? 0) - (b.order ?? 0));
d2.blocks.forEach((b,i)=> b.order = i);
}
function findBlockByDomId(domId) {
return d2.blocks.find(b => (b.id || '') === domId);
}
function renderList() {
sortAndReindex();
listEl.innerHTML = '';
d2.blocks.forEach((b,i)=>{
const domId = b.id || ('b'+i);
const li = document.createElement('li');
li.draggable = true;
li.dataset.id = domId;
li.style.display = 'flex';
li.style.alignItems = 'center';
li.style.gap = '6px';
li.style.padding = '4px 0';
li.innerHTML = `
<span class="pm-handle" style="cursor:grab;">☰</span>
<input type="checkbox" class="pm-enabled" ${b.enabled!==false?'checked':''} title="enabled"/>
<span class="pm-name" style="flex:1">${(b.name||('Block '+(i+1))).replace(/</g,'<')}</span>
<span class="pm-role" style="opacity:.8">${b.role||'user'}</span>
<button class="pm-edit" title="Редактировать">✎</button>
<button class="pm-del" title="Удалить">🗑</button>
`;
// DnD
li.addEventListener('dragstart', e => { e.dataTransfer.setData('text/plain', domId); });
li.addEventListener('dragover', e => { e.preventDefault(); });
li.addEventListener('drop', e => {
e.preventDefault();
const srcId = e.dataTransfer.getData('text/plain');
const tgtId = domId;
if (!srcId || srcId === tgtId) return;
const srcIdx = d2.blocks.findIndex(x => (x.id||'') === srcId);
const tgtIdx = d2.blocks.findIndex(x => (x.id||'') === tgtId);
if (srcIdx < 0 || tgtIdx < 0) return;
const [moved] = d2.blocks.splice(srcIdx, 1);
d2.blocks.splice(tgtIdx, 0, moved);
sortAndReindex();
renderList();
syncNodeDataBlocks();
});
// toggle
li.querySelector('.pm-enabled').addEventListener('change', ev => {
b.enabled = ev.target.checked;
syncNodeDataBlocks();
});
// edit
li.querySelector('.pm-edit').addEventListener('click', () => {
openEditor(b);
});
// delete
li.querySelector('.pm-del').addEventListener('click', () => {
const idx = d2.blocks.indexOf(b);
if (idx >= 0) d2.blocks.splice(idx, 1);
sortAndReindex();
renderList();
syncNodeDataBlocks();
if (editingId && editingId === (b.id || null)) {
editorBox.style.display = 'none';
editingId = null;
}
});
listEl.appendChild(li);
});
}
function openEditor(b) {
// Гарантируем наличие id у редактируемого блока
if (!b.id) {
b.id = 'b' + Date.now().toString(36);
syncNodeDataBlocks();
}
editingId = b.id;
editorBox.style.display = '';
nameInp.value = b.name || '';
roleSel.value = (b.role || 'user');
promptTxt.value = b.prompt || '';
}
addBtn?.addEventListener('click', () => {
const idv = 'b' + Date.now().toString(36);
const nb = { id: idv, name: 'New Block', role: 'system', prompt: '', enabled: true, order: d2.blocks.length };
d2.blocks.push(nb);
sortAndReindex();
renderList();
syncNodeDataBlocks();
openEditor(nb);
});
saveBtn?.addEventListener('click', () => {
if (!editingId) { editorBox.style.display = 'none'; return; }
const b = d2.blocks.find(x => (x.id || null) === editingId);
if (b) {
b.name = nameInp.value;
b.role = roleSel.value;
b.prompt = promptTxt.value;
// Пересоберём массив, чтобы избежать проблем с мутацией по ссылке
d2.blocks = d2.blocks.map(x => (x.id === b.id ? ({...b}) : x));
}
editorBox.style.display = 'none';
editingId = null;
renderList();
syncNodeDataBlocks();
try { savePipeline(); } catch (e) {}
try { status('Блок сохранён в pipeline.json'); } catch (e) {}
});
cancelBtn?.addEventListener('click', () => {
editorBox.style.display = 'none';
editingId = null;
});
renderList();
}
}
// Добавление нод из сайдбара
document.querySelectorAll('.node-btn').forEach(btn => {
btn.addEventListener('click', () => {
const type = btn.dataset.node;
addNode(type, {x: 120 + Math.random()*60, y: 120 + Math.random()*40});
});
});
// Сериализация: Drawflow -> наш pipeline JSON
function toPipelineJSON() {
const data = editor.export();
const nodes = [];
const idMap = {}; // drawflow id -> generated id like n1, n2
const dfNodes = (data && data.drawflow && data.drawflow.Home && data.drawflow.Home.data) ? data.drawflow.Home.data : {};
// 1) Собираем ноды
let idx = 1;
for (const id in dfNodes) {
const df = dfNodes[id];
const genId = `n${idx++}`;
idMap[id] = genId;
const el = document.querySelector(`#node-${id}`);
// Берём источник правды из DOM.__data (куда жмём «Сохранить параметры») или из drawflow.data
const datacopySrc = el && el.__data ? el.__data : (df.data || {});
const datacopy = applyNodeDefaults(df.name, JSON.parse(JSON.stringify(datacopySrc)));
nodes.push({
id: genId,
type: df.name,
pos_x: df.pos_x,
pos_y: df.pos_y,
config: datacopy,
in: {}
});
}
// 2) Восстанавливаем связи по входам (inputs)
// В Drawflow v0.0.55 inputs/outputs — это объекты вида input_1/output_1
for (const id in dfNodes) {
const df = dfNodes[id];
const targetNode = nodes.find(n => n.id === idMap[id]);
if (!targetNode) continue;
const io = NODE_IO[targetNode.type] || { inputs: [], outputs: [] };
for (let i = 0; i < io.inputs.length; i++) {
const inputKey = `input_${i + 1}`;
const input = df.inputs && df.inputs[inputKey];
if (!input || !Array.isArray(input.connections) || input.connections.length === 0) continue;
// Один вход — одна связь
const conn = input.connections[0];
const sourceDfId = String(conn.node);
const outKey = String(conn.output ?? '');
// conn.output может быть "output_1", "1" (строкой), либо числом 1
let sourceOutIdx = -1;
let m = outKey.match(/output_(\d+)/);
if (m) {
sourceOutIdx = parseInt(m[1], 10) - 1;
} else if (/^\d+$/.test(outKey)) {
sourceOutIdx = parseInt(outKey, 10) - 1;
} else if (typeof conn.output === 'number') {
sourceOutIdx = conn.output - 1;
}
if (!(sourceOutIdx >= 0)) sourceOutIdx = 0; // safety to avoid -1
const sourceNode = nodes.find(n => n.id === idMap[sourceDfId]);
if (!sourceNode) continue;
const sourceIo = NODE_IO[sourceNode.type] || { outputs: [] };
// Каноничное имя выхода: по NODE_IO, иначе out{0-based}
const sourceOutName = (sourceIo.outputs && sourceIo.outputs[sourceOutIdx] != null)
? sourceIo.outputs[sourceOutIdx]
: `out${sourceOutIdx}`;
// Каноничное имя входа: по NODE_IO, иначе in{0-based}
const targetInName = (io.inputs && io.inputs[i] != null)
? io.inputs[i]
: `in${i}`;
if (!targetNode.in) targetNode.in = {};
targetNode.in[targetInName] = `${sourceNode.id}.${sourceOutName}`;
}
}
return { id: 'pipeline_editor', name: 'Edited Pipeline', nodes };
}
// Десериализация: pipeline JSON -> Drawflow
async function fromPipelineJSON(p) {
editor.clear();
let x = 100; let y = 120; // Fallback
const idMap = {}; // pipeline id -> drawflow id
const logs = [];
const $ = (sel) => document.querySelector(sel);
const resolveOutIdx = (type, outName) => {
const outs = (NODE_IO[type]?.outputs) || [];
let idx = outs.indexOf(outName);
if (idx < 0 && typeof outName === 'string') {
// поддержка: out-1, out_1, output_1, out1, out0
const s = String(outName);
let m = s.match(/^out(?:put)?[_-]?(\d+)$/);
if (m) {
const n = parseInt(m[1], 10);
idx = n > 0 ? n - 1 : 0;
} else {
m = s.match(/^out(\d+)$/); // совместимость со старым out0
if (m) idx = parseInt(m[1], 10) | 0;
}
}
return idx;
};
const resolveInIdx = (type, inName) => {
const ins = (NODE_IO[type]?.inputs) || [];
let idx = ins.indexOf(inName);
if (idx < 0 && typeof inName === 'string') {
// поддержка: in-1, in_1, in1, in0
const s = String(inName);
let m = s.match(/^in[_-]?(\d+)$/);
if (m) {
const n = parseInt(m[1], 10);
idx = n > 0 ? n - 1 : 0;
} else {
m = s.match(/^in(\d+)$/); // совместимость со старым in0
if (m) idx = parseInt(m[1], 10) | 0;
}
}
return idx;
};
// Ожидание появления порта в DOM (устранение гонки рендера)
async function waitForPort(dfid, kind, idx, tries = 60, delay = 16) {
// Drawflow создаёт DOM-узел с id="node-${dfid}"
const sel = `#node-${dfid} .${kind}_${idx}`;
for (let i = 0; i < tries; i++) {
if ($(sel)) return true;
await new Promise(r => setTimeout(r, delay));
}
logs.push(`port missing: #${dfid} ${kind}_${idx}`);
return false;
}
// Повторные попытки соединить порты, пока DOM не готов
async function connectWithRetry(srcDfId, tgtDfId, outNum, inNum, tries = 120, delay = 25) {
const outClass = `output_${outNum}`;
const inClass = `input_${inNum}`;
for (let i = 0; i < tries; i++) {
const okOut = await waitForPort(srcDfId, 'output', outNum, 1, delay);
const okIn = await waitForPort(tgtDfId, 'input', inNum, 1, delay);
if (okOut && okIn) {
try {
editor.addConnection(srcDfId, tgtDfId, outClass, inClass);
return true;
} catch (e) {
// retry on next loop
}
}
await new Promise(r => setTimeout(r, delay));
}
return false;
}
// 1) Создаём ноды
for (const n of p.nodes) {
const pos = { x: n.pos_x || x, y: n.pos_y || y };
const dfid = addNode(n.type, pos, { ...(n.config || {}), _origId: n.id });
idMap[n.id] = dfid;
if (!n.pos_x) x += 260; // раскладываем по горизонтали, если нет сохраненной позиции
}
// 2) Дождёмся полного рендера DOM
await new Promise(r => setTimeout(r, 0));
if (typeof requestAnimationFrame === 'function') {
await new Promise(r => requestAnimationFrame(() => r()));
await new Promise(r => requestAnimationFrame(() => r())); // двойной rAF для надежности
} else {
await new Promise(r => setTimeout(r, 32));
}
// 3) Проставляем связи из in
for (const n of p.nodes) {
if (!n.in) continue;
const targetDfId = idMap[n.id];
const targetIo = NODE_IO[n.type] || { inputs: [] };
for (const [inName, ref] of Object.entries(n.in)) {
if (!ref || typeof ref !== 'string' || !ref.includes('.')) continue;
const [srcId, outName] = ref.split('.');
const sourceDfId = idMap[srcId];
if (!sourceDfId) { logs.push(`skip: src ${srcId} not found`); continue; }
const srcType = p.nodes.find(nn=>nn.id===srcId)?.type;
let outIdx = resolveOutIdx(srcType, outName);
let inIdx = resolveInIdx(n.type, inName);
// Fallback на первый порт, если неизвестные имена, но порт существует
if (outIdx < 0) outIdx = 0;
if (inIdx < 0) inIdx = 0;
const outClass = `output_${outIdx + 1}`;
const inClass = `input_${inIdx + 1}`;
const ok = await connectWithRetry(sourceDfId, targetDfId, outIdx + 1, inIdx + 1, 200, 25);
if (ok) {
logs.push(`connect: ${srcId}.${outName} (#${sourceDfId}.${outClass}) -> ${n.id}.${inName} (#${targetDfId}.${inClass})`);
} else {
logs.push(`skip connect (ports not ready after retries): ${srcId}.${outName} -> ${n.id}.${inName}`);
}
}
}
// 4) Обновим линии и выведем лог
try {
Object.values(idMap).forEach((dfid) => {
editor.updateConnectionNodes?.(`node-${dfid}`);
});
} catch {}
if (logs.length) {
try { status('Загружено (links):\n' + logs.join('\n')); } catch {}
try { console.debug('[fromPipelineJSON]', logs); } catch {}
}
}
// Загрузка/сохранение
async function loadPipeline() {
const res = await fetch('/admin/pipeline');
const p = await res.json();
await fromPipelineJSON(p);
// Не затираем логи, которые вывел fromPipelineJSON
const st = document.getElementById('status').textContent;
if (!st) status('Загружено');
}
async function savePipeline() {
try {
const p = toPipelineJSON();
const res = await fetch('/admin/pipeline', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(p) });
const out = await res.json();
status('Сохранено: ' + JSON.stringify(out) + ' | nodes=' + (p.nodes?.length || 0));
} catch (err) {
status('Ошибка сохранения пайплайна: ' + (err?.message || String(err)));
}
}
async function refreshPresets() {
const res = await fetch('/admin/presets');
const j = await res.json();
const sel = document.getElementById('preset-select');
sel.innerHTML = '';
(j.items||[]).forEach(name => {
const opt = document.createElement('option');
opt.value = name; opt.textContent = name; sel.appendChild(opt);
});
}
async function savePreset() {
const name = document.getElementById('preset-name').value.trim();
if (!name) { status('Укажите имя пресета'); return; }
try {
const p = toPipelineJSON();
const res = await fetch('/admin/presets/' + encodeURIComponent(name), { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(p) });
const out = await res.json();
status('Пресет сохранён: ' + JSON.stringify(out) + ' | nodes=' + (p.nodes?.length || 0));
refreshPresets();
} catch (err) {
status('Ошибка сохранения пресета: ' + (err?.message || String(err)));
}
}
async function loadPreset() {
const name = document.getElementById('preset-select').value;
if (!name) { status('Выберите пресет'); return; }
const res = await fetch('/admin/presets/' + encodeURIComponent(name));
const p = await res.json();
await fromPipelineJSON(p);
// Сделаем загруженный пресет активным пайплайном (сохранение в pipeline.json)
await fetch('/admin/pipeline', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(p) });
// Не затираем логи соединений, если они уже выведены
const st = document.getElementById('status').textContent || '';
if (!st || !st.includes('(links)')) {
status('Пресет загружен: ' + name);
}
}
document.getElementById('btn-load').onclick = loadPipeline;
document.getElementById('btn-save').onclick = savePipeline;
document.getElementById('btn-save-preset').onclick = savePreset;
document.getElementById('btn-load-preset').onclick = loadPreset;
loadPipeline();
refreshPresets();
</script>
</body>
</html>

60
static/index.html Normal file
View File

@@ -0,0 +1,60 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>НадTavern</title>
<style>
body { font-family: Arial, sans-serif; margin: 24px; }
textarea { width: 100%; height: 200px; }
pre { background: #111; color: #eee; padding: 12px; border-radius: 6px; overflow: auto; }
.row { display: flex; gap: 16px; }
.col { flex: 1; }
</style>
</head>
<body>
<h1>НадTavern — Мини UI</h1>
<p>Тестовый интерфейс для запроса к <code>/v1/chat/completions</code> без стриминга.</p>
<p><a href="/ui/pipeline.html">Открыть редактор пайплайна (JSON)</a></p>
<p><a href="/ui/editor.html">Открыть визуальный редактор нод</a></p>
<div class="row">
<div class="col">
<h3>Ввод (OpenAI-формат)</h3>
<textarea id="payload"></textarea>
<button id="send">Отправить</button>
</div>
<div class="col">
<h3>Ответ</h3>
<pre id="out"></pre>
</div>
</div>
<script>
const sample = {
model: "gpt-4o-mini",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Привет! Расскажи коротко о проекте НадTavern." }
],
temperature: 0.2,
max_tokens: 128
};
document.getElementById('payload').value = JSON.stringify(sample, null, 2);
document.getElementById('send').onclick = async () => {
const raw = document.getElementById('payload').value;
try {
const res = await fetch('/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: raw
});
const json = await res.json();
document.getElementById('out').textContent = JSON.stringify(json, null, 2);
} catch (e) {
document.getElementById('out').textContent = String(e);
}
};
</script>
</body>
</html>

53
static/pipeline.html Normal file
View File

@@ -0,0 +1,53 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>НадTavern — Pipeline Editor (JSON)</title>
<style>
body { font-family: Arial, sans-serif; margin: 24px; }
textarea { width: 100%; height: 70vh; }
pre { background: #111; color: #eee; padding: 12px; border-radius: 6px; }
.row { display: flex; gap: 16px; }
.col { flex: 1; }
</style>
</head>
<body>
<h1>Pipeline Editor (JSON)</h1>
<p>
Редактируйте JSON пайплайна. Нажмите "Сохранить" — используется немедленно.
<a href="/">Домой</a>
</p>
<div>
<button id="btn-load">Загрузить</button>
<button id="btn-save">Сохранить</button>
</div>
<textarea id="editor"></textarea>
<pre id="status"></pre>
<script>
async function loadPipeline() {
const res = await fetch('/admin/pipeline');
const json = await res.json();
document.getElementById('editor').value = JSON.stringify(json, null, 2);
setStatus('Загружено');
}
async function savePipeline() {
try {
const body = document.getElementById('editor').value;
JSON.parse(body);
const res = await fetch('/admin/pipeline', { method: 'POST', headers: {'Content-Type': 'application/json'}, body });
const out = await res.json();
setStatus('Сохранено: ' + JSON.stringify(out));
} catch (e) {
setStatus('Ошибка: ' + e.message);
}
}
function setStatus(t) { document.getElementById('status').textContent = t; }
document.getElementById('btn-load').onclick = loadPipeline;
document.getElementById('btn-save').onclick = savePipeline;
loadPipeline();
</script>
</body>
</html>

View File

@@ -0,0 +1,70 @@
from agentui.pipeline.executor import ProviderCallNode
def run_checks():
# Конфиг ноды с prompt-блоками
blocks = [
{"id": "b1", "name": "Sys", "role": "system", "prompt": "System: {{ model }}", "enabled": True, "order": 0},
{"id": "b2", "name": "UserMsg", "role": "user", "prompt": "User says [[VAR:chat.last_user]]", "enabled": True, "order": 1},
{"id": "b3", "name": "Asst", "role": "assistant", "prompt": "Prev assistant turn", "enabled": True, "order": 2},
]
node = ProviderCallNode("test", {"blocks": blocks})
context = {
"model": "gpt-x",
"params": {"temperature": 0.4, "max_tokens": 100, "top_p": 0.9, "stop": ["STOP"]},
"chat": {"last_user": "Привет"},
"OUT": {},
"vendor_format": "openai",
}
# 1) Рендер в унифицированные сообщения
unified = node._render_blocks_to_unified(context)
assert len(unified) == 3
assert unified[0]["role"] == "system" and unified[0]["content"] == "System: gpt-x"
assert unified[1]["role"] == "user" and "Привет" in unified[1]["content"]
assert unified[2]["role"] == "assistant"
# 2) OpenAI
p_openai = node._messages_to_payload("openai", unified, context)
assert p_openai["model"] == "gpt-x"
assert isinstance(p_openai["messages"], list) and len(p_openai["messages"]) == 3
assert p_openai["messages"][0]["role"] == "system"
assert p_openai["messages"][1]["role"] == "user" and "Привет" in p_openai["messages"][1]["content"]
assert p_openai["temperature"] == 0.4
assert p_openai["max_tokens"] == 100
assert p_openai["top_p"] == 0.9
assert p_openai["stop"] == ["STOP"]
# 3) Gemini
p_gemini = node._messages_to_payload("gemini", unified, context)
assert p_gemini["model"] == "gpt-x"
assert "contents" in p_gemini and isinstance(p_gemini["contents"], list)
# system уходит в systemInstruction
assert "systemInstruction" in p_gemini and "parts" in p_gemini["systemInstruction"]
assert p_gemini["systemInstruction"]["parts"][0]["text"] == "System: gpt-x"
# user/assistant -> contents (assistant => role=model)
roles = [c["role"] for c in p_gemini["contents"]]
assert roles == ["user", "model"]
assert "Привет" in p_gemini["contents"][0]["parts"][0]["text"]
gen = p_gemini.get("generationConfig", {})
assert gen.get("temperature") == 0.4
assert gen.get("maxOutputTokens") == 100
assert gen.get("topP") == 0.9
assert gen.get("stopSequences") == ["STOP"]
# 4) Claude
p_claude = node._messages_to_payload("claude", unified, context)
assert p_claude["model"] == "gpt-x"
assert p_claude["system"] == "System: gpt-x"
assert isinstance(p_claude["messages"], list)
assert p_claude["messages"][0]["role"] == "user"
assert p_claude["messages"][0]["content"][0]["type"] == "text"
assert "Привет" in p_claude["messages"][0]["content"][0]["text"]
assert p_claude["temperature"] == 0.4
assert p_claude["max_tokens"] == 100
assert p_claude["top_p"] == 0.9
assert p_claude["stop"] == ["STOP"]
if __name__ == "__main__":
run_checks()
print("Prompt Manager payload conversion tests: OK")