Initial import
This commit is contained in:
66
.gitignore
vendored
Normal file
66
.gitignore
vendored
Normal 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.*
|
||||
80
PROJECT_OVERVIEW.md
Normal file
80
PROJECT_OVERVIEW.md
Normal 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
3
agentui/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
__all__ = []
|
||||
|
||||
|
||||
2
agentui/api/__init__.py
Normal file
2
agentui/api/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
588
agentui/api/server.py
Normal file
588
agentui/api/server.py
Normal 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
61
agentui/config.py
Normal 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,
|
||||
}
|
||||
|
||||
|
||||
2
agentui/pipeline/__init__.py
Normal file
2
agentui/pipeline/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
14
agentui/pipeline/defaults.py
Normal file
14
agentui/pipeline/defaults.py
Normal 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": []
|
||||
}
|
||||
|
||||
|
||||
888
agentui/pipeline/executor.py
Normal file
888
agentui/pipeline/executor.py
Normal 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" → берёт значение из context по точечному пути
|
||||
- "[[VAR:path]]" → берёт значение из context
|
||||
- "[[OUT:nodeId(.path)*]]" → берёт из уже вычисленных выходов ноды
|
||||
- "nodeId(.path)*" → ссылка на выходы ноды
|
||||
- Иначе пытается взять из 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,
|
||||
})
|
||||
|
||||
|
||||
44
agentui/pipeline/storage.py
Normal file
44
agentui/pipeline/storage.py
Normal 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")
|
||||
|
||||
|
||||
2
agentui/providers/__init__.py
Normal file
2
agentui/providers/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
14
agentui/providers/http_client.py
Normal file
14
agentui/providers/http_client.py
Normal 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
182
docs/VARIABLES.md
Normal 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) Короткая форма (best‑effort текст)
|
||||
- [[OUT1]] — «текст» из ноды n1
|
||||
- [[OUT2]] — из ноды n2 и т.д.
|
||||
|
||||
Что делает «best‑effort текст»:
|
||||
- Если нода вернула 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
13
main.py
Normal 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
8
requirements.txt
Normal 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
26
run_agentui.bat
Normal 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
19
run_server.py
Normal 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
304
static/editor.css
Normal 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
981
static/editor.html
Normal 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="Температура выборки (0–1)">[[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 (best‑effort, вытаскивает 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,'"')}" readonly />
|
||||
<label>endpoint</label>
|
||||
<input type="text" value="${endpoint.replace(/"/g,'"')}" readonly />
|
||||
<label>headers (preview JSON)</label>
|
||||
<textarea readonly>${headers.replace(/</g,'<')}</textarea>
|
||||
<label>template (preview JSON)</label>
|
||||
<textarea readonly>${template.replace(/</g,'<')}</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,'"')}" readonly />
|
||||
<label>override_path</label>
|
||||
<input type="text" value="${override_path.replace(/"/g,'"')}" readonly />
|
||||
<label><input type="checkbox" ${passthrough_headers} disabled/> passthrough_headers</label>
|
||||
<label>extra_headers (preview JSON)</label>
|
||||
<textarea readonly>${extra_headers.replace(/</g,'<')}</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,'"')}" placeholder="https://api.openai.com">
|
||||
<label>override_path</label><input id="f-override" type="text" value="${(data.override_path||'').replace(/"/g,'"')}" 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,'<')}</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
60
static/index.html
Normal 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
53
static/pipeline.html
Normal 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>
|
||||
|
||||
|
||||
70
tests/test_prompt_manager.py
Normal file
70
tests/test_prompt_manager.py
Normal 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")
|
||||
Reference in New Issue
Block a user