HadTavern 0.01: Gemini/Claude fixes; UI _origId reuse; docs; .bat open
This commit is contained in:
@@ -3,13 +3,14 @@ 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.responses import JSONResponse, HTMLResponse, StreamingResponse
|
||||
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
|
||||
from agentui.common.vendors import detect_vendor
|
||||
|
||||
|
||||
class UnifiedParams(BaseModel):
|
||||
@@ -38,17 +39,6 @@ class UnifiedChatRequest(BaseModel):
|
||||
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)
|
||||
@@ -278,6 +268,34 @@ def create_app() -> FastAPI:
|
||||
logger.addHandler(stream_handler)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
# --- Simple in-process SSE hub (subscriptions per browser tab) ---
|
||||
import asyncio as _asyncio
|
||||
|
||||
class _SSEHub:
|
||||
def __init__(self) -> None:
|
||||
self._subs: List[_asyncio.Queue] = []
|
||||
|
||||
def subscribe(self) -> _asyncio.Queue:
|
||||
q: _asyncio.Queue = _asyncio.Queue()
|
||||
self._subs.append(q)
|
||||
return q
|
||||
|
||||
def unsubscribe(self, q: _asyncio.Queue) -> None:
|
||||
try:
|
||||
self._subs.remove(q)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
async def publish(self, event: Dict[str, Any]) -> None:
|
||||
# Fan-out to all subscribers; drop if queue is full
|
||||
for q in list(self._subs):
|
||||
try:
|
||||
await q.put(event)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_trace_hub = _SSEHub()
|
||||
|
||||
def _mask_headers(h: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# Временно отключаем маскировку Authorization для отладки
|
||||
hidden = {"x-api-key", "cookie"}
|
||||
@@ -369,7 +387,15 @@ def create_app() -> FastAPI:
|
||||
macro_ctx = build_macro_context(unified, incoming=incoming)
|
||||
pipeline = load_pipeline()
|
||||
executor = PipelineExecutor(pipeline)
|
||||
last = await executor.run(macro_ctx)
|
||||
|
||||
async def _trace(evt: Dict[str, Any]) -> None:
|
||||
try:
|
||||
base = {"pipeline_id": pipeline.get("id", "pipeline_editor")}
|
||||
await _trace_hub.publish({**base, **evt})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
last = await executor.run(macro_ctx, trace=_trace)
|
||||
result = last.get("result") or await execute_pipeline_echo(unified)
|
||||
await _log_response(request, 200, result)
|
||||
return JSONResponse(result)
|
||||
@@ -402,7 +428,13 @@ def create_app() -> FastAPI:
|
||||
macro_ctx = build_macro_context(unified, incoming=incoming)
|
||||
pipeline = load_pipeline()
|
||||
executor = PipelineExecutor(pipeline)
|
||||
last = await executor.run(macro_ctx)
|
||||
async def _trace(evt: Dict[str, Any]) -> None:
|
||||
try:
|
||||
base = {"pipeline_id": pipeline.get("id", "pipeline_editor")}
|
||||
await _trace_hub.publish({**base, **evt})
|
||||
except Exception:
|
||||
pass
|
||||
last = await executor.run(macro_ctx, trace=_trace)
|
||||
result = last.get("result") or await execute_pipeline_echo(unified)
|
||||
await _log_response(request, 200, result)
|
||||
return JSONResponse(result)
|
||||
@@ -431,7 +463,13 @@ def create_app() -> FastAPI:
|
||||
macro_ctx = build_macro_context(unified, incoming=incoming)
|
||||
pipeline = load_pipeline()
|
||||
executor = PipelineExecutor(pipeline)
|
||||
last = await executor.run(macro_ctx)
|
||||
async def _trace(evt: Dict[str, Any]) -> None:
|
||||
try:
|
||||
base = {"pipeline_id": pipeline.get("id", "pipeline_editor")}
|
||||
await _trace_hub.publish({**base, **evt})
|
||||
except Exception:
|
||||
pass
|
||||
last = await executor.run(macro_ctx, trace=_trace)
|
||||
result = last.get("result") or await execute_pipeline_echo(unified)
|
||||
await _log_response(request, 200, result)
|
||||
return JSONResponse(result)
|
||||
@@ -465,7 +503,13 @@ def create_app() -> FastAPI:
|
||||
macro_ctx = build_macro_context(unified, incoming=incoming)
|
||||
pipeline = load_pipeline()
|
||||
executor = PipelineExecutor(pipeline)
|
||||
last = await executor.run(macro_ctx)
|
||||
async def _trace(evt: Dict[str, Any]) -> None:
|
||||
try:
|
||||
base = {"pipeline_id": pipeline.get("id", "pipeline_editor")}
|
||||
await _trace_hub.publish({**base, **evt})
|
||||
except Exception:
|
||||
pass
|
||||
last = await executor.run(macro_ctx, trace=_trace)
|
||||
result = last.get("result") or await execute_pipeline_echo(unified)
|
||||
await _log_response(request, 200, result)
|
||||
return JSONResponse(result)
|
||||
@@ -498,7 +542,13 @@ def create_app() -> FastAPI:
|
||||
macro_ctx = build_macro_context(unified, incoming=incoming)
|
||||
pipeline = load_pipeline()
|
||||
executor = PipelineExecutor(pipeline)
|
||||
last = await executor.run(macro_ctx)
|
||||
async def _trace(evt: Dict[str, Any]) -> None:
|
||||
try:
|
||||
base = {"pipeline_id": pipeline.get("id", "pipeline_editor")}
|
||||
await _trace_hub.publish({**base, **evt})
|
||||
except Exception:
|
||||
pass
|
||||
last = await executor.run(macro_ctx, trace=_trace)
|
||||
result = last.get("result") or await execute_pipeline_echo(unified)
|
||||
await _log_response(request, 200, result)
|
||||
return JSONResponse(result)
|
||||
@@ -532,11 +582,16 @@ def create_app() -> FastAPI:
|
||||
macro_ctx = build_macro_context(unified, incoming=incoming)
|
||||
pipeline = load_pipeline()
|
||||
executor = PipelineExecutor(pipeline)
|
||||
last = await executor.run(macro_ctx)
|
||||
async def _trace(evt: Dict[str, Any]) -> None:
|
||||
try:
|
||||
base = {"pipeline_id": pipeline.get("id", "pipeline_editor")}
|
||||
await _trace_hub.publish({**base, **evt})
|
||||
except Exception:
|
||||
pass
|
||||
last = await executor.run(macro_ctx, trace=_trace)
|
||||
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 для пайплайна
|
||||
@@ -580,6 +635,30 @@ def create_app() -> FastAPI:
|
||||
raise HTTPException(status_code=400, detail="Invalid pipeline format")
|
||||
save_preset(name, payload)
|
||||
return JSONResponse({"ok": True})
|
||||
# --- SSE endpoint for live pipeline trace ---
|
||||
@app.get("/admin/trace/stream")
|
||||
async def sse_trace() -> StreamingResponse:
|
||||
loop = _asyncio.get_event_loop()
|
||||
q = _trace_hub.subscribe()
|
||||
|
||||
async def _gen():
|
||||
try:
|
||||
# warm-up: send a comment to keep connection open
|
||||
yield ":ok\n\n"
|
||||
while True:
|
||||
evt = await q.get()
|
||||
try:
|
||||
line = f"data: {json.dumps(evt, ensure_ascii=False)}\n\n"
|
||||
except Exception:
|
||||
line = "data: {}\n\n"
|
||||
yield line
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
_trace_hub.unsubscribe(q)
|
||||
|
||||
return StreamingResponse(_gen(), media_type="text/event-stream")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
1
agentui/common/__init__.py
Normal file
1
agentui/common/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
__all__ = ["vendors"]
|
||||
43
agentui/common/vendors.py
Normal file
43
agentui/common/vendors.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
__all__ = ["detect_vendor"]
|
||||
|
||||
|
||||
def detect_vendor(payload: Dict[str, Any] | None) -> str:
|
||||
"""
|
||||
Определение вендора по форме payload.
|
||||
Возвращает одно из: "openai" | "gemini" | "claude" | "unknown".
|
||||
|
||||
Правила (порядок важен):
|
||||
- Anthropic (Claude):
|
||||
* наличие ключа "anthropic_version" (официальный заголовок/поле)
|
||||
* явный маркер provider == "anthropic"
|
||||
- Gemini:
|
||||
* наличие "contents" или "generationConfig" (Google AI Studio / Vertex)
|
||||
- OpenAI:
|
||||
* наличие "messages" или "model"
|
||||
- Фоллбэк: "unknown"
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return "unknown"
|
||||
|
||||
# Явные подсказки, если заранее указали
|
||||
hint = str(payload.get("vendor_format") or payload.get("vendor") or "").lower()
|
||||
if hint in {"openai", "gemini", "claude"}:
|
||||
return hint
|
||||
|
||||
# Anthropic (Claude)
|
||||
if "anthropic_version" in payload or payload.get("provider") == "anthropic":
|
||||
return "claude"
|
||||
|
||||
# Gemini (Google)
|
||||
if "contents" in payload or "generationConfig" in payload:
|
||||
return "gemini"
|
||||
|
||||
# OpenAI
|
||||
if "messages" in payload or "model" in payload:
|
||||
return "openai"
|
||||
|
||||
return "unknown"
|
||||
@@ -1,175 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Callable, Awaitable
|
||||
from urllib.parse import urljoin
|
||||
import json
|
||||
import re
|
||||
import asyncio
|
||||
import time
|
||||
from agentui.providers.http_client import build_client
|
||||
from agentui.common.vendors import detect_vendor
|
||||
from agentui.pipeline.templating import (
|
||||
_OUT_MACRO_RE,
|
||||
_VAR_MACRO_RE,
|
||||
_PROMPT_MACRO_RE,
|
||||
_OUT_SHORT_RE,
|
||||
_BRACES_RE,
|
||||
_split_path,
|
||||
_get_by_path,
|
||||
_stringify_for_template,
|
||||
_deep_find_text,
|
||||
_best_text_from_outputs,
|
||||
render_template_simple,
|
||||
)
|
||||
|
||||
|
||||
# --- Templating helpers ------------------------------------------------------
|
||||
# --- Templating helpers are imported from agentui.pipeline.templating ---
|
||||
|
||||
_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*\}\}")
|
||||
# moved to agentui.pipeline.templating
|
||||
|
||||
# moved to agentui.pipeline.templating
|
||||
|
||||
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 ""
|
||||
# moved to agentui.pipeline.templating
|
||||
|
||||
# moved to agentui.pipeline.templating
|
||||
|
||||
def _extract_out_node_id_from_ref(s: Any) -> Optional[str]:
|
||||
"""
|
||||
@@ -190,12 +52,16 @@ def _resolve_in_value(source: Any, context: Dict[str, Any], values: Dict[str, Di
|
||||
"""
|
||||
Разрешает входные связи/макросы в значение для inputs:
|
||||
- Нестроковые значения возвращаются как есть
|
||||
- "macro:path" → берёт значение из context по точечному пути
|
||||
- "[[VAR:path]]" → берёт значение из context
|
||||
- "[[OUT:nodeId(.path)*]]" → берёт из уже вычисленных выходов ноды
|
||||
- "nodeId(.path)*" → ссылка на выходы ноды
|
||||
- "macro:path" → берёт значение из context по точечному пути
|
||||
- "[[VAR:path]]" → берёт значение из context
|
||||
- "[[OUT:nodeId(.path)*]]" → берёт из уже вычисленных выходов ноды
|
||||
- "nodeId(.path)*" → ссылка на выходы ноды
|
||||
- Если передан список ссылок — вернёт список разрешённых значений
|
||||
- Иначе пытается взять из context по пути; если не найдено, оставляет исходную строку
|
||||
"""
|
||||
# Поддержка массивов ссылок (для multi-depends или будущих списковых входов)
|
||||
if isinstance(source, list):
|
||||
return [_resolve_in_value(s, context, values) for s in source]
|
||||
if not isinstance(source, str):
|
||||
return source
|
||||
s = source.strip()
|
||||
@@ -235,100 +101,7 @@ def _resolve_in_value(source: Any, context: Dict[str, Any], values: Dict[str, Di
|
||||
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"
|
||||
# moved to agentui.pipeline.templating
|
||||
|
||||
|
||||
class ExecutionError(Exception):
|
||||
@@ -360,7 +133,11 @@ class PipelineExecutor:
|
||||
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]:
|
||||
async def run(
|
||||
self,
|
||||
context: Dict[str, Any],
|
||||
trace: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Исполнитель пайплайна с динамическим порядком на основе зависимостей графа.
|
||||
Новый режим: волновое (level-by-level) исполнение с параллелизмом и барьером.
|
||||
@@ -378,26 +155,29 @@ class PipelineExecutor:
|
||||
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)
|
||||
# Разворачиваем массивы ссылок (multi-depends)
|
||||
sources = source if isinstance(source, list) else [source]
|
||||
for src in sources:
|
||||
if not isinstance(src, str):
|
||||
# Нестрочные значения и массивы констант — зависимостей нет
|
||||
continue
|
||||
if src.startswith("macro:"):
|
||||
# Макросы берутся из контекста, без зависимостей
|
||||
continue
|
||||
# [[VAR:...]] — макрос из контекста, зависимостей нет
|
||||
if re.fullmatch(r"\[\[\s*VAR\s*[:\s]\s*[^\]]+\s*\]\]", src.strip()):
|
||||
continue
|
||||
# [[OUT:nodeId(.key)*]] — зависимость от указанной ноды
|
||||
out_ref_node = _extract_out_node_id_from_ref(src)
|
||||
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 = src.split(".", 1)[0] if "." in src else src
|
||||
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()}
|
||||
@@ -407,6 +187,8 @@ class PipelineExecutor:
|
||||
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}
|
||||
# Накопитель пользовательских переменных (SetVars) — доступен как context["vars"]
|
||||
user_vars: Dict[str, Any] = {}
|
||||
|
||||
# Параметры параллелизма
|
||||
try:
|
||||
@@ -417,49 +199,103 @@ class PipelineExecutor:
|
||||
parallel_limit = 1
|
||||
|
||||
# Вспомогательная корутина исполнения одной ноды со снапшотом OUT
|
||||
async def exec_one(node_id: str, values_snapshot: Dict[str, Any]) -> tuple[str, Dict[str, Any]]:
|
||||
async def exec_one(node_id: str, values_snapshot: Dict[str, Any], wave_num: int) -> 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
|
||||
|
||||
# Пользовательские переменные (накопленные SetVars)
|
||||
try:
|
||||
ctx["vars"] = dict(user_vars)
|
||||
except Exception:
|
||||
ctx["vars"] = {}
|
||||
|
||||
# Разрешаем inputs для ноды
|
||||
inputs: Dict[str, Any] = {}
|
||||
for name, source in (ndef.get("in") or {}).items():
|
||||
inputs[name] = _resolve_in_value(source, ctx, values_snapshot)
|
||||
if isinstance(source, list):
|
||||
inputs[name] = [_resolve_in_value(s, ctx, values_snapshot) for s in source]
|
||||
else:
|
||||
inputs[name] = _resolve_in_value(source, ctx, values_snapshot)
|
||||
|
||||
out = await node.run(inputs, ctx)
|
||||
return node_id, out
|
||||
# Трассировка старта
|
||||
if trace is not None:
|
||||
try:
|
||||
await trace({"event": "node_start", "node_id": ndef["id"], "wave": wave_num, "ts": int(time.time() * 1000)})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
out = await node.run(inputs, ctx)
|
||||
except Exception as exc:
|
||||
if trace is not None:
|
||||
try:
|
||||
await trace({
|
||||
"event": "node_error",
|
||||
"node_id": ndef["id"],
|
||||
"wave": wave_num,
|
||||
"ts": int(time.time() * 1000),
|
||||
"error": str(exc),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
else:
|
||||
dur_ms = int((time.perf_counter() - started) * 1000)
|
||||
if trace is not None:
|
||||
try:
|
||||
await trace({
|
||||
"event": "node_done",
|
||||
"node_id": ndef["id"],
|
||||
"wave": wave_num,
|
||||
"ts": int(time.time() * 1000),
|
||||
"duration_ms": dur_ms,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return node_id, out
|
||||
|
||||
# Волновое исполнение
|
||||
wave_idx = 0
|
||||
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),
|
||||
*(exec_one(nid, values_snapshot, wave_idx) 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)
|
||||
|
||||
|
||||
# Соберём пользовательские переменные из SetVars узлов волны
|
||||
try:
|
||||
for _nid, out in wave_results.items():
|
||||
if isinstance(out, dict):
|
||||
v = out.get("vars")
|
||||
if isinstance(v, dict):
|
||||
user_vars.update(v)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Обновляем входящие степени для зависимых и формируем следующую волну
|
||||
for done in wave_nodes:
|
||||
for child in dependents.get(done, ()):
|
||||
@@ -467,6 +303,7 @@ class PipelineExecutor:
|
||||
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
|
||||
wave_idx += 1
|
||||
|
||||
# Проверка на циклы/недостижимые ноды
|
||||
if len(processed) != len(nodes):
|
||||
@@ -476,6 +313,162 @@ class PipelineExecutor:
|
||||
return last_result
|
||||
|
||||
|
||||
class SetVarsNode(Node):
|
||||
type_name = "SetVars"
|
||||
|
||||
def _normalize(self) -> List[Dict[str, Any]]:
|
||||
raw = self.config.get("variables") or []
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
norm: List[Dict[str, Any]] = []
|
||||
for i, b in enumerate(raw):
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
name = str(b.get("name", "")).strip()
|
||||
mode = str(b.get("mode", "string")).lower().strip()
|
||||
value = b.get("value", "")
|
||||
try:
|
||||
order = int(b.get("order")) if b.get("order") is not None else i
|
||||
except Exception:
|
||||
order = i
|
||||
norm.append({
|
||||
"id": b.get("id") or f"v{i}",
|
||||
"name": name,
|
||||
"mode": "expr" if mode == "expr" else "string",
|
||||
"value": value,
|
||||
"order": order,
|
||||
})
|
||||
return norm
|
||||
|
||||
def _safe_eval_expr(self, expr: str) -> Any:
|
||||
"""
|
||||
Безопасная оценка выражений для SetVars.
|
||||
|
||||
Поддержка:
|
||||
- Литералы: числа/строки/bool/None, списки, кортежи, словари
|
||||
- JSON‑литералы: true/false/null, объекты и массивы (парсятся как Python True/False/None, dict/list)
|
||||
- Арифметика: + - * / // %, унарные +-
|
||||
- Логика: and/or, сравнения (== != < <= > >=, цепочки)
|
||||
- Безопасные функции: rand(), randint(a,b), choice(list)
|
||||
|
||||
Запрещено: имя/атрибуты/индексация/условные/импорты/прочие вызовы функций.
|
||||
"""
|
||||
import ast
|
||||
import operator as op
|
||||
import random
|
||||
|
||||
# 0) Попытаться распознать чистый JSON‑литерал (включая true/false/null, объекты/массивы/числа/строки).
|
||||
# Это не вмешивается в математику: для выражений вида "1+2" json.loads бросит исключение и мы пойдём в AST.
|
||||
try:
|
||||
s = str(expr).strip()
|
||||
return json.loads(s)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
allowed_bin = {
|
||||
ast.Add: op.add, ast.Sub: op.sub, ast.Mult: op.mul, ast.Div: op.truediv,
|
||||
ast.FloorDiv: op.floordiv, ast.Mod: op.mod,
|
||||
}
|
||||
allowed_unary = {ast.UAdd: lambda x: +x, ast.USub: lambda x: -x}
|
||||
allowed_cmp = {
|
||||
ast.Eq: op.eq, ast.NotEq: op.ne, ast.Lt: op.lt, ast.LtE: op.le, ast.Gt: op.gt, ast.GtE: op.ge,
|
||||
}
|
||||
|
||||
def eval_node(node: ast.AST) -> Any:
|
||||
if isinstance(node, ast.Expression):
|
||||
return eval_node(node.body)
|
||||
if isinstance(node, ast.Constant):
|
||||
return node.value
|
||||
if isinstance(node, ast.Tuple):
|
||||
return tuple(eval_node(e) for e in node.elts)
|
||||
if isinstance(node, ast.List):
|
||||
return [eval_node(e) for e in node.elts]
|
||||
if isinstance(node, ast.Dict):
|
||||
return {eval_node(k): eval_node(v) for k, v in zip(node.keys, node.values)}
|
||||
if isinstance(node, ast.UnaryOp) and type(node.op) in allowed_unary:
|
||||
return allowed_unary[type(node.op)](eval_node(node.operand))
|
||||
if isinstance(node, ast.BinOp) and type(node.op) in allowed_bin:
|
||||
return allowed_bin[type(node.op)](eval_node(node.left), eval_node(node.right))
|
||||
if isinstance(node, ast.BoolOp):
|
||||
vals = [eval_node(v) for v in node.values]
|
||||
if isinstance(node.op, ast.And):
|
||||
res = True
|
||||
for v in vals:
|
||||
res = res and bool(v)
|
||||
return res
|
||||
if isinstance(node.op, ast.Or):
|
||||
res = False
|
||||
for v in vals:
|
||||
res = res or bool(v)
|
||||
return res
|
||||
if isinstance(node, ast.Compare):
|
||||
left = eval_node(node.left)
|
||||
for opnode, comparator in zip(node.ops, node.comparators):
|
||||
if type(opnode) not in allowed_cmp:
|
||||
raise ExecutionError("Unsupported comparison operator")
|
||||
right = eval_node(comparator)
|
||||
if not allowed_cmp[type(opnode)](left, right):
|
||||
return False
|
||||
left = right
|
||||
return True
|
||||
# Разрешённые вызовы: rand(), randint(a,b), choice(list)
|
||||
if isinstance(node, ast.Call):
|
||||
# Никаких kwargs, *args
|
||||
if node.keywords or isinstance(getattr(node, "starargs", None), ast.AST) or isinstance(getattr(node, "kwargs", None), ast.AST):
|
||||
raise ExecutionError("Call with kwargs/starargs is not allowed")
|
||||
fn = node.func
|
||||
if not isinstance(fn, ast.Name):
|
||||
raise ExecutionError("Only simple function calls are allowed")
|
||||
name = fn.id
|
||||
if name == "rand":
|
||||
if len(node.args) != 0:
|
||||
raise ExecutionError("rand() takes no arguments")
|
||||
return random.random()
|
||||
if name == "randint":
|
||||
if len(node.args) != 2:
|
||||
raise ExecutionError("randint(a,b) requires two arguments")
|
||||
a = eval_node(node.args[0])
|
||||
b = eval_node(node.args[1])
|
||||
try:
|
||||
return random.randint(int(a), int(b))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ExecutionError(f"randint invalid arguments: {exc}")
|
||||
if name == "choice":
|
||||
if len(node.args) != 1:
|
||||
raise ExecutionError("choice(list) requires one argument")
|
||||
seq = eval_node(node.args[0])
|
||||
if not isinstance(seq, (list, tuple)):
|
||||
raise ExecutionError("choice() expects list or tuple")
|
||||
if not seq:
|
||||
raise ExecutionError("choice() on empty sequence")
|
||||
return random.choice(seq)
|
||||
raise ExecutionError(f"Function {name} is not allowed")
|
||||
# Запрещаем всё остальное (Name/Attribute/Subscript/IfExp/Comprehensions и пр.)
|
||||
raise ExecutionError("Expression not allowed")
|
||||
|
||||
try:
|
||||
tree = ast.parse(str(expr), mode="eval")
|
||||
except Exception as exc:
|
||||
raise ExecutionError(f"SetVars expr parse error: {exc}") from exc
|
||||
return eval_node(tree)
|
||||
|
||||
async def run(self, inputs: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]: # noqa: D401
|
||||
out_map = context.get("OUT") or {}
|
||||
result: Dict[str, Any] = {}
|
||||
import re as _re
|
||||
for v in sorted(self._normalize(), key=lambda x: x.get("order", 0)):
|
||||
name = v.get("name") or ""
|
||||
if not _re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name or ""):
|
||||
raise ExecutionError(f"SetVars invalid variable name: {name!r}")
|
||||
mode = v.get("mode", "string")
|
||||
raw_val = v.get("value", "")
|
||||
if mode == "expr":
|
||||
resolved = self._safe_eval_expr(str(raw_val))
|
||||
else:
|
||||
resolved = render_template_simple(str(raw_val or ""), context, out_map)
|
||||
result[name] = resolved
|
||||
return {"vars": result}
|
||||
|
||||
class ProviderCallNode(Node):
|
||||
type_name = "ProviderCall"
|
||||
|
||||
@@ -520,9 +513,8 @@ class ProviderCallNode(Node):
|
||||
messages: List[Dict[str, Any]] = []
|
||||
for b in blocks:
|
||||
content = render_template_simple(str(b.get("prompt") or ""), context, out_map)
|
||||
# name поля блоков не передаются в провайдерские payload'ы
|
||||
msg = {"role": b["role"], "content": content}
|
||||
if b.get("name"):
|
||||
msg["name"] = b["name"]
|
||||
messages.append(msg)
|
||||
return messages
|
||||
|
||||
@@ -534,7 +526,7 @@ class ProviderCallNode(Node):
|
||||
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}
|
||||
{"role": m["role"], "content": m["content"]}
|
||||
for m in messages
|
||||
],
|
||||
"temperature": params.get("temperature", 0.7),
|
||||
@@ -582,7 +574,6 @@ class ProviderCallNode(Node):
|
||||
payload: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": msgs,
|
||||
"anthropic_version": context.get("anthropic_version", "2023-06-01"),
|
||||
}
|
||||
if sys_text:
|
||||
payload["system"] = sys_text
|
||||
@@ -611,12 +602,12 @@ class ProviderCallNode(Node):
|
||||
msgs = messages or []
|
||||
|
||||
if provider == "openai":
|
||||
# Уже в формате {"role","content","name?"}
|
||||
# Уже в формате {"role","content"}
|
||||
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}
|
||||
{"role": m["role"], "content": m.get("content")}
|
||||
for m in msgs
|
||||
],
|
||||
"system_text": sys_text,
|
||||
@@ -630,12 +621,13 @@ class ProviderCallNode(Node):
|
||||
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 {
|
||||
d: Dict[str, Any] = {
|
||||
"contents": contents,
|
||||
"systemInstruction": sys_instr,
|
||||
"system_text": sys_text,
|
||||
}
|
||||
if sys_text:
|
||||
d["systemInstruction"] = {"parts": [{"text": sys_text}]}
|
||||
return d
|
||||
|
||||
if provider == "claude":
|
||||
sys_text = "\n\n".join([m["content"] for m in msgs if m.get("role") == "system"]).strip()
|
||||
@@ -729,13 +721,23 @@ class ProviderCallNode(Node):
|
||||
# Рендер endpoint с макросами/шаблонами
|
||||
endpoint = render(endpoint_tmpl)
|
||||
|
||||
# Формируем тело ТОЛЬКО из template/[[PROMPT]] (без сырого payload/входов)
|
||||
# Формируем тело ТОЛЬКО из template/[[PROMPT]] (без сырого payload/входов).
|
||||
# Больше НИКАКОГО фоллбэка на unified-построение: если шаблон невалиден — это ошибка ноды.
|
||||
try:
|
||||
rendered = render(template)
|
||||
# DEBUG: печать отрендеренного шаблона с номерами строк для точной диагностики JSONDecodeError
|
||||
try:
|
||||
_lines = rendered.splitlines()
|
||||
_preview = "\n".join(f"{i+1:03d}: {_lines[i]}" for i in range(min(len(_lines), 120)))
|
||||
print(f"DEBUG: ProviderCallNode rendered_template node={self.node_id} provider={provider}\\n{_preview}")
|
||||
except Exception:
|
||||
try:
|
||||
print(f"DEBUG: ProviderCallNode rendered_template(node={self.node_id}, provider={provider}) len={len(rendered)}")
|
||||
except Exception:
|
||||
pass
|
||||
payload = json.loads(rendered)
|
||||
except Exception:
|
||||
# Fallback: используем генерацию из Prompt Blocks в формате провайдера
|
||||
payload = self._messages_to_payload(provider, unified_msgs, context)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ExecutionError(f"ProviderCall template invalid JSON: {exc}")
|
||||
|
||||
# Заголовки — полностью из редактируемого JSON с макросами
|
||||
try:
|
||||
@@ -752,22 +754,55 @@ class ProviderCallNode(Node):
|
||||
url = endpoint if endpoint.startswith("http") else urljoin(base_url.rstrip('/') + '/', endpoint.lstrip('/'))
|
||||
|
||||
# Debug logs to validate config selection and payload
|
||||
# Brute request/response logging (FULL, no masking)
|
||||
try:
|
||||
payload_preview = ""
|
||||
final_headers = {"Content-Type": "application/json", **headers}
|
||||
print("===== ProviderCall REQUEST BEGIN =====")
|
||||
print(f"node={self.node_id} type={self.type_name} provider={provider}")
|
||||
print(f"URL: {url}")
|
||||
try:
|
||||
payload_preview = json.dumps(payload, ensure_ascii=False)[:400]
|
||||
print("Headers:")
|
||||
print(json.dumps(final_headers, ensure_ascii=False, indent=2))
|
||||
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}")
|
||||
print(f"Headers(raw): {final_headers}")
|
||||
try:
|
||||
print("Body JSON:")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
except Exception:
|
||||
print(f"Body(raw): {payload}")
|
||||
print("===== ProviderCall REQUEST END =====")
|
||||
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()
|
||||
resp = await client.post(url, json=payload, headers=final_headers)
|
||||
# Do not raise_for_status: keep body/logs on 4xx/5xx
|
||||
try:
|
||||
print("===== ProviderCall RESPONSE BEGIN =====")
|
||||
print(f"node={self.node_id} type={self.type_name} provider={provider}")
|
||||
print(f"Status: {resp.status_code}")
|
||||
try:
|
||||
print("Headers:")
|
||||
print(json.dumps(dict(resp.headers), ensure_ascii=False, indent=2))
|
||||
except Exception:
|
||||
try:
|
||||
print(f"Headers(raw): {dict(resp.headers)}")
|
||||
except Exception:
|
||||
print("Headers(raw): <unavailable>")
|
||||
try:
|
||||
body_text = resp.text
|
||||
except Exception:
|
||||
body_text = "<resp.text decode error>"
|
||||
print("Body Text:")
|
||||
print(body_text)
|
||||
print("===== ProviderCall RESPONSE END =====")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = {"error": "Failed to decode JSON from upstream", "text": resp.text}
|
||||
|
||||
# Извлекаем текст best-effort
|
||||
text = None
|
||||
@@ -860,29 +895,143 @@ class RawForwardNode(Node):
|
||||
|
||||
headers.update(extra_headers)
|
||||
|
||||
print(f"DEBUG: RawForwardNode sending request to URL: {url}")
|
||||
print(f"DEBUG: RawForwardNode sending with HEADERS: {headers}")
|
||||
# Brute request/response logging (FULL, no masking)
|
||||
try:
|
||||
print("===== RawForward REQUEST BEGIN =====")
|
||||
print(f"node={self.node_id} type={self.type_name}")
|
||||
print(f"URL: {url}")
|
||||
try:
|
||||
print("Headers:")
|
||||
print(json.dumps(headers, ensure_ascii=False, indent=2))
|
||||
except Exception:
|
||||
print(f"Headers(raw): {headers}")
|
||||
try:
|
||||
print("Body JSON:")
|
||||
print(json.dumps(raw_payload, ensure_ascii=False, indent=2))
|
||||
except Exception:
|
||||
print(f"Body(raw): {raw_payload}")
|
||||
print("===== RawForward REQUEST END =====")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async with build_client() as client:
|
||||
resp = await client.post(url, json=raw_payload, headers=headers)
|
||||
|
||||
# Логируем ответ от целевого API для диагностики
|
||||
|
||||
# Response logging
|
||||
try:
|
||||
print("===== RawForward RESPONSE BEGIN =====")
|
||||
print(f"node={self.node_id} type={self.type_name}")
|
||||
print(f"Status: {resp.status_code}")
|
||||
try:
|
||||
print("Headers:")
|
||||
print(json.dumps(dict(resp.headers), ensure_ascii=False, indent=2))
|
||||
except Exception:
|
||||
try:
|
||||
print(f"Headers(raw): {dict(resp.headers)}")
|
||||
except Exception:
|
||||
print("Headers(raw): <unavailable>")
|
||||
try:
|
||||
body_text = resp.text
|
||||
except Exception:
|
||||
body_text = "<resp.text decode error>"
|
||||
print("Body Text:")
|
||||
print(body_text)
|
||||
print("===== RawForward RESPONSE END =====")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Decode JSON if possible, otherwise return text
|
||||
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}")
|
||||
return {"result": data}
|
||||
|
||||
# Не выбрасываем исключение, а просто проксируем ответ
|
||||
# resp.raise_for_status()
|
||||
|
||||
return {"result": data}
|
||||
|
||||
class ReturnNode(Node):
|
||||
type_name = "Return"
|
||||
|
||||
async def run(self, inputs: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]: # noqa: D401
|
||||
# Определяем целевой формат
|
||||
cfg = self.config or {}
|
||||
target = str(cfg.get("target_format", "auto")).lower().strip() or "auto"
|
||||
if target == "auto":
|
||||
target = str(context.get("vendor_format") or "openai").lower().strip() or "openai"
|
||||
|
||||
# Рендерим текст из шаблона (по умолчанию берём [[OUT1]])
|
||||
out_map = context.get("OUT") or {}
|
||||
template = cfg.get("text_template")
|
||||
if template is None or template == "":
|
||||
template = "[[OUT1]]"
|
||||
try:
|
||||
text = render_template_simple(str(template), context, out_map)
|
||||
except Exception:
|
||||
text = ""
|
||||
|
||||
model = str(context.get("model") or "")
|
||||
|
||||
# Форматтеры под провайдеры (как в execute_pipeline_echo)
|
||||
def fmt_openai(t: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": "ret_mock_123",
|
||||
"object": "chat.completion",
|
||||
"model": model,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": t},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": len((t or "").split()), "total_tokens": 0},
|
||||
}
|
||||
|
||||
def fmt_gemini(t: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"role": "model",
|
||||
"parts": [{"text": t}],
|
||||
},
|
||||
"finishReason": "STOP",
|
||||
"index": 0,
|
||||
}
|
||||
],
|
||||
"modelVersion": model,
|
||||
}
|
||||
|
||||
def fmt_claude(t: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": "msg_ret_123",
|
||||
"type": "message",
|
||||
"model": model,
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": t}
|
||||
],
|
||||
"stop_reason": "end_turn",
|
||||
}
|
||||
|
||||
if target == "openai":
|
||||
result = fmt_openai(text)
|
||||
elif target == "gemini":
|
||||
result = fmt_gemini(text)
|
||||
elif target == "claude":
|
||||
result = fmt_claude(text)
|
||||
else:
|
||||
# неизвестное значение — безопасный дефолт
|
||||
result = fmt_openai(text)
|
||||
|
||||
return {"result": result, "response_text": text}
|
||||
|
||||
|
||||
|
||||
NODE_REGISTRY.update({
|
||||
SetVarsNode.type_name: SetVarsNode,
|
||||
ProviderCallNode.type_name: ProviderCallNode,
|
||||
RawForwardNode.type_name: RawForwardNode,
|
||||
ReturnNode.type_name: ReturnNode,
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
308
agentui/pipeline/templating.py
Normal file
308
agentui/pipeline/templating.py
Normal file
@@ -0,0 +1,308 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
__all__ = [
|
||||
"_OUT_MACRO_RE",
|
||||
"_VAR_MACRO_RE",
|
||||
"_PROMPT_MACRO_RE",
|
||||
"_OUT_SHORT_RE",
|
||||
"_BARE_MACRO_RE",
|
||||
"_BRACES_RE",
|
||||
"_split_path",
|
||||
"_get_by_path",
|
||||
"_stringify_for_template",
|
||||
"_deep_find_text",
|
||||
"_best_text_from_outputs",
|
||||
"render_template_simple",
|
||||
]
|
||||
|
||||
# Regex-макросы (общие для бэка)
|
||||
_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)
|
||||
# Единый фрагмент PROMPT (провайдеро-специфичный JSON-фрагмент)
|
||||
_PROMPT_MACRO_RE = re.compile(r"\[\[\s*PROMPT\s*\]\]", re.IGNORECASE)
|
||||
# Короткая форма: [[OUT1]] — best-effort текст из ноды n1
|
||||
_OUT_SHORT_RE = re.compile(r"\[\[\s*OUT\s*(\d+)\s*\]\]", re.IGNORECASE)
|
||||
# Голые переменные: [[NAME]] или [[path.to.value]] — сначала ищем в vars, затем в контексте
|
||||
_BARE_MACRO_RE = re.compile(r"\[\[\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[^\]]+?)?)\s*\]\]")
|
||||
# Подстановки {{ ... }} (включая простейший фильтр |default(...))
|
||||
_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 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]] — провайдеро-специфичный JSON-фрагмент, подготовленный в context["PROMPT"]
|
||||
s = _PROMPT_MACRO_RE.sub(lambda _m: str(context.get("PROMPT") or ""), s)
|
||||
|
||||
# 1.5) Голые [[NAME]] / [[path.to.value]]
|
||||
def repl_bare(m: re.Match) -> str:
|
||||
name = m.group(1).strip()
|
||||
# Зарезервированные формы уже обработаны выше; бережно пропускаем похожие
|
||||
if name.upper() in {"OUT", "VAR", "PROMPT"} or re.fullmatch(r"OUT\d+", name.upper() or ""):
|
||||
return m.group(0)
|
||||
# Сначала пользовательские переменные
|
||||
vmap = context.get("vars") or {}
|
||||
if isinstance(vmap, dict) and name in vmap:
|
||||
return _stringify_for_template(vmap.get(name))
|
||||
# Затем путь из общего контекста
|
||||
val = _get_by_path(context, name)
|
||||
return _stringify_for_template(val)
|
||||
s = _BARE_MACRO_RE.sub(repl_bare, 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()
|
||||
# Приоритет пользовательских переменных для простых идентификаторов {{ NAME }}
|
||||
vmap = context.get("vars") or {}
|
||||
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", p) and isinstance(vmap, dict) and p in vmap:
|
||||
return vmap.get(p)
|
||||
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()
|
||||
|
||||
# Рекурсивная обработка вложенных default(...) и путей
|
||||
def eval_default(raw: str) -> Any:
|
||||
raw = raw.strip()
|
||||
# Вложенный default: a|default(b)
|
||||
dm = re.match(r"([^|]+)\|\s*default\((.*)\)\s*$", raw)
|
||||
if dm:
|
||||
base2 = dm.group(1).strip()
|
||||
fb2 = dm.group(2).strip()
|
||||
v2 = eval_path(base2)
|
||||
if v2 not in (None, ""):
|
||||
return v2
|
||||
return eval_default(fb2)
|
||||
# Пробуем как путь
|
||||
v = eval_path(raw)
|
||||
if v not in (None, ""):
|
||||
return v
|
||||
# Явная строка в кавычках
|
||||
if len(raw) >= 2 and ((raw[0] == '"' and raw[-1] == '"') or (raw[0] == "'" and raw[-1] == "'")):
|
||||
return raw[1:-1]
|
||||
# Пробуем распарсить как JSON литерал (число/объект/массив/true/false/null)
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
# Последний вариант: вернуть сырой текст. Для строк рекомендуется default('...') с кавычками.
|
||||
return raw
|
||||
|
||||
raw_val = eval_path(base_path)
|
||||
val = raw_val if raw_val not in (None, "") else eval_default(fallback_raw)
|
||||
else:
|
||||
val = eval_path(expr)
|
||||
|
||||
return _stringify_for_template(val)
|
||||
|
||||
s = _BRACES_RE.sub(repl_braces, s)
|
||||
return s
|
||||
@@ -1,95 +1,148 @@
|
||||
# Переменные и макросы AgentUI
|
||||
# Переменные и макросы НадTavern
|
||||
|
||||
Этот файл — простая шпаргалка по переменным/макросам, которые можно использовать в шаблонах узла ProviderCall и в Prompt Blocks.
|
||||
Краткая, человеко‑понятная шпаргалка по тому, какие переменные и макросы доступны в шаблонах (в том числе в Prompt Blocks), как они устроены и как их правильно использовать. Док ниже соответствует текущему коду.
|
||||
|
||||
Правила ввода:
|
||||
- Квадратные макросы [[...]] — простая подстановка. Хорошо подходят для строк и для URL/заголовков.
|
||||
- Фигурные {{ ... }} — «джинджа‑лайт»: умеют фильтр |default(...), корректно вставляют объекты и массивы внутрь JSON без лишних кавычек.
|
||||
- Любые значения, вставляемые в JSON через макросы, приводятся к корректному JSON когда это возможно.
|
||||
Реализация формирует единый «контекст» переменных для всех нод пайплайна, дополняет его выходами уже выполненных нод, а узел ProviderCall добавляет свои служебные структуры для удобной сборки промпта.
|
||||
|
||||
Служебные файлы/строки реализации:
|
||||
- Рендеринг и макросы: [render_template_simple()](agentui/pipeline/executor.py:125)
|
||||
- Провайдерный узел с формированием PROMPT: [ProviderCallNode.run()](agentui/pipeline/executor.py:565)
|
||||
Ссылки на код:
|
||||
- Формирование контекста запроса: [build_macro_context()](agentui/api/server.py:142)
|
||||
- Исполнитель пайплайна и снапшот OUT: [PipelineExecutor.run()](agentui/pipeline/executor.py:136)
|
||||
- Узел провайдера (Prompt Blocks → provider payload): [ProviderCallNode.run()](agentui/pipeline/executor.py:650)
|
||||
- Шаблоны/макросы ([[...]] и {{ ... }}): [render_template_simple()](agentui/pipeline/templating.py:187)
|
||||
- Короткая форма [[OUTx]] (извлечение текста): [_best_text_from_outputs()](agentui/pipeline/templating.py:124)
|
||||
- Прямой форвард запросов: [RawForwardNode.run()](agentui/pipeline/executor.py:833)
|
||||
|
||||
---
|
||||
|
||||
## Общие переменные контекста
|
||||
## 1) Общие переменные контекста (для всех нод)
|
||||
|
||||
- [[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]] — запасной слот
|
||||
Эти переменные доступны в шаблонах любой ноды. Они добавляются на стороне сервера при обработке входящего HTTP‑запроса.
|
||||
|
||||
Те же поля доступны через {{ ... }}: например {{ params.temperature|default(0.7) }}, {{ incoming.json }} и т.д.
|
||||
- model — строка с именем модели.
|
||||
Пример: "gpt-4o-mini"
|
||||
- vendor_format — вендор/протокол запроса: "openai" | "gemini" | "claude" | "unknown"
|
||||
- system — «системный» текст, если он был во входящем запросе; иначе пустая строка.
|
||||
|
||||
- params — стандартные параметры генерации (можно использовать как дефолты)
|
||||
- params.temperature — число с плавающей точкой (по умолчанию 0.7)
|
||||
- params.max_tokens — целое или null
|
||||
- params.top_p — число (по умолчанию 1.0)
|
||||
- params.stop — массив строк или null
|
||||
|
||||
- chat — сведения о чате во входящем запросе
|
||||
- chat.last_user — последнее сообщение пользователя (строка)
|
||||
- chat.messages — массив сообщений в унифицированной форме:
|
||||
- role — "system" | "user" | "assistant" | "tool"
|
||||
- content — содержимое (обычно строка)
|
||||
- name — опционально, строка
|
||||
- tool_call_id — опционально
|
||||
|
||||
- incoming — детали ВХОДЯЩЕГО HTTP‑запроса
|
||||
- incoming.method — метод ("POST" и т.п.)
|
||||
- incoming.url — полный URL (в query ключи маскируются для логов)
|
||||
- incoming.path — путь (например, /v1/chat/completions)
|
||||
- incoming.query — строка query без вопросительного знака
|
||||
- incoming.query_params — объект со всеми query‑параметрами
|
||||
- incoming.headers — объект всех заголовков запроса
|
||||
- incoming.json — сырой JSON тела запроса, как прислал клиент
|
||||
- incoming.api_keys — удобные «срезы» ключей
|
||||
- incoming.api_keys.authorization — значение из заголовка Authorization (если есть)
|
||||
- incoming.api_keys.key — значение из query (?key=...) — удобно для Gemini
|
||||
|
||||
Пример использования в шаблоне:
|
||||
- [[VAR:incoming.api_keys.key]] — возьмёт ключ из строки запроса (?key=...).
|
||||
- [[VAR:incoming.headers.x-api-key]] — возьмёт ключ из заголовка x-api-key (типично для Anthropic).
|
||||
- {{ params.temperature|default(0.7) }} — безопасно подставит число, если не задано во входящих данных.
|
||||
|
||||
---
|
||||
|
||||
## Макросы OUT (выходы нод)
|
||||
## 2) Выходы нод (OUT) и ссылки на них
|
||||
|
||||
Доступ к выходам нод возможен в двух формах:
|
||||
Во время исполнения пайплайна результаты предыдущих нод собираются в снапшот OUT и доступны при рендере шаблонов следующих нод:
|
||||
|
||||
### 1) Короткая форма (best‑effort текст)
|
||||
- [[OUT1]] — «текст» из ноды n1
|
||||
- [[OUT2]] — из ноды n2 и т.д.
|
||||
- OUT — словарь выходов нод, ключи — id нод в пайплайне (например, "n1", "n2").
|
||||
- OUT.n1, OUT.n2, ... — объект результата соответствующей ноды.
|
||||
|
||||
Что делает «best‑effort текст»:
|
||||
- Если нода вернула response_text или text — берётся он
|
||||
- Если нода вернула объект провайдера:
|
||||
- OpenAI: choices[0].message.content
|
||||
- Gemini: candidates[0].content.parts[0].text
|
||||
- Claude: content[].text (склейка)
|
||||
- Если ничего из выше не подошло — выполняется глубокий поиск текстовых полей ("text"/"content")
|
||||
Формы доступа:
|
||||
- Полная форма: [[OUT:n1.result.choices.0.message.content]]
|
||||
(или фигурными скобками: {{ OUT.n1.result.choices.0.message.content }})
|
||||
- Короткая форма «просто текст»: [[OUT1]], [[OUT2]], ...
|
||||
Это эвристика: берётся самое вероятное «текстовое» поле из результата (см. [_best_text_from_outputs()](agentui/pipeline/templating.py:121)).
|
||||
|
||||
Реализация: [_best_text_from_outputs()](agentui/pipeline/executor.py:45) и подстановка коротких OUT: [render_template_simple()](agentui/pipeline/executor.py:155)
|
||||
Что возвращают встроенные ноды:
|
||||
- ProviderCall:
|
||||
- OUT.nX.result — сырой JSON ответа провайдера
|
||||
- OUT.nX.response_text — уже извлечённый «лучший текст» (строка)
|
||||
- RawForward:
|
||||
- OUT.nX.result — JSON, как пришёл от апстрима (или {"error": "...", "text": "..."} при не‑JSON ответе)
|
||||
|
||||
### 2) Полная форма (точный путь)
|
||||
- [[OUT:n1.result]] — целиком результат ноды n1
|
||||
- [[OUT:n1.result.candidates.0.content.parts.0.text]] — конкретный путь
|
||||
- Эквивалент через фигурные скобки: {{ OUT.n1.result.candidates.0.content.parts.0.text }}
|
||||
|
||||
Совет: используйте короткий [[OUTx]] если нужно «просто текст». Используйте полную форму, если нужен конкретный фрагмент/массив.
|
||||
Подсказка по короткой форме [[OUTx]]:
|
||||
- OpenAI: вернёт choices[0].message.content
|
||||
- Gemini: вернёт candidates[0].content.parts[0].text
|
||||
- Claude: склеит content[].text
|
||||
- Если явных полей нет — выполнит «глубокий поиск» по ключам "text"/"content"
|
||||
|
||||
---
|
||||
|
||||
## Единый фрагмент [[PROMPT]]
|
||||
## 3) Макросы подстановки и синтаксис
|
||||
|
||||
[[PROMPT]] — это уже собранный JSON‑фрагмент из ваших Prompt Blocks. Он зависит от выбранного провайдера ноды:
|
||||
- OpenAI → "messages": [...]
|
||||
- Gemini → "contents": [...], "systemInstruction": {...}
|
||||
- Claude → "system": "...", "messages": [...]
|
||||
В шаблонах доступны обе формы подстановки:
|
||||
|
||||
Как использовать внутри JSON‑шаблона:
|
||||
1) Квадратные скобки [[ ... ]] — простая подстановка
|
||||
- [[VAR:путь]] — взять значение из контекста по точечному пути
|
||||
Пример: [[VAR:incoming.json.max_tokens]]
|
||||
- [[OUT:путь]] — взять значение из OUT (см. раздел выше)
|
||||
Пример: [[OUT:n1.result.choices.0.message.content]]
|
||||
- [[OUT1]] / [[OUT2]] — короткая форма «просто текст»
|
||||
- [[PROMPT]] — специальный JSON‑фрагмент из Prompt Blocks (см. ниже)
|
||||
|
||||
{
|
||||
"model": "{{ model }}",
|
||||
[[PROMPT]],
|
||||
"temperature": {{ params.temperature|default(0.7) }}
|
||||
}
|
||||
|
||||
Вы также можете использовать сырьевые структуры:
|
||||
- {{ pm.messages }}
|
||||
- {{ pm.contents }}
|
||||
- {{ pm.systemInstruction }}
|
||||
- {{ pm.system_text }}
|
||||
|
||||
Но рекомендуемый путь — [[PROMPT]]: меньше шансов сломать JSON.
|
||||
2) Фигурные скобки {{ ... }} — «джинджа‑лайт»
|
||||
- {{ путь }} — взять значение по пути из контекста (или из OUT.* если начать с OUT.)
|
||||
Пример: {{ OUT.n1.result }}
|
||||
- Фильтр по умолчанию: {{ что-то|default(значение) }}
|
||||
Примеры:
|
||||
- {{ params.temperature|default(0.7) }}
|
||||
- {{ incoming.json.stop|default([]) }}
|
||||
- {{ anthropic_version|default('2023-06-01') }} — см. «Опциональные поля» ниже
|
||||
- Фигурные скобки удобны там, где нужно вставить внутрь JSON не строку, а ЧИСЛО/ОБЪЕКТ/МАССИВ без кавычек и/или задать дефолт.
|
||||
|
||||
---
|
||||
|
||||
## Примеры по провайдерам
|
||||
## 4) ProviderCall: Prompt Blocks, pm.* и [[PROMPT]]
|
||||
|
||||
### OpenAI (/v1/chat/completions)
|
||||
Узел ProviderCall собирает ваши Prompt Blocks (блоки вида: роль/текст/вкл‑выкл/порядок) в стандартные «сообщения» и превращает их в структуру для конкретного провайдера.
|
||||
|
||||
Внутри шаблонов этого узла доступны:
|
||||
- pm — «сырьевые» структуры из Prompt Blocks
|
||||
- Для OpenAI:
|
||||
- pm.messages — массив { role, content, name? }
|
||||
- pm.system_text — один большой текст из всех system‑блоков
|
||||
- Для Gemini:
|
||||
- pm.contents — массив { role: "user"|"model", parts: [{text}] }
|
||||
- pm.systemInstruction — объект вида { parts: [{text}] } или пустой {}
|
||||
- pm.system_text — строка
|
||||
- Для Claude:
|
||||
- pm.system_text — строка
|
||||
- pm.system — то же самое (удобно подставлять в поле "system")
|
||||
- pm.messages — массив { role: "user"|"assistant", content: [{type:"text", text:"..."}] }
|
||||
|
||||
- [[PROMPT]] — готовый JSON‑фрагмент на основе pm, безопасный для вставки внутрь шаблона:
|
||||
- OpenAI → подставит: "messages": [...]
|
||||
- Gemini → подставит: "contents": [...], "systemInstruction": {...}
|
||||
- Claude → подставит: "system": "...", "messages": [...]
|
||||
|
||||
Зачем это нужно?
|
||||
- Чтобы 1) удобно собирать промпт из визуальных блоков, 2) не «сломать» JSON руками.
|
||||
Вы можете вручную использовать {{ pm.* }}, но [[PROMPT]] — рекомендуемый и самый безопасный вариант.
|
||||
|
||||
---
|
||||
|
||||
## 5) Частые сценарии и примеры
|
||||
|
||||
Примеры ниже можно вклеивать в поле «template» ноды ProviderCall. Они уже используют [[PROMPT]] и аккуратные дефолты.
|
||||
|
||||
OpenAI (POST /v1/chat/completions):
|
||||
```
|
||||
{
|
||||
"model": "{{ model }}",
|
||||
[[PROMPT]],
|
||||
@@ -98,9 +151,10 @@
|
||||
"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=...)
|
||||
|
||||
Gemini (POST /v1beta/models/{model}:generateContent):
|
||||
```
|
||||
{
|
||||
"model": "{{ model }}",
|
||||
[[PROMPT]],
|
||||
@@ -112,71 +166,163 @@
|
||||
"stopSequences": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }}
|
||||
}
|
||||
}
|
||||
```
|
||||
Подсказка: ключ Gemini удобно брать из строки запроса:
|
||||
в endpoint используйте …?key=[[VAR:incoming.api_keys.key]]
|
||||
|
||||
### Claude (/v1/messages)
|
||||
|
||||
Claude (POST /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)) }}
|
||||
"max_tokens": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},
|
||||
"system": {{ pm.system|default("") }}
|
||||
}
|
||||
```
|
||||
Подсказка: ключ Anthropic обычно передают в заголовке x-api-key.
|
||||
В UI‑пресете это поле уже есть в headers.
|
||||
|
||||
RawForward (прямой форвард входящего запроса):
|
||||
- Поля конфигурации base_url, override_path, extra_headers проходят через те же макросы, поэтому можно подставлять динамику:
|
||||
- base_url: https://generativelanguage.googleapis.com
|
||||
- override_path: [[VAR:incoming.path]] (или задать свой)
|
||||
- extra_headers (JSON): `{"X-Trace":"req-{{ incoming.query_params.session|default('no-session') }}"}`
|
||||
|
||||
---
|
||||
|
||||
## Частые кейсы
|
||||
## 6) Опциональные/редкие поля, о которых стоит знать
|
||||
|
||||
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]]
|
||||
- anthropic_version — используется как HTTP‑заголовок для Claude ("anthropic-version"). В тело запроса не вставляется.
|
||||
Если нужен дефолт, задавайте его в headers (например, в конфиге ноды/шаблоне заголовков). В шаблонах тела используйте [[PROMPT]]/pm.* без anthropic_version.
|
||||
|
||||
2) Переписать ответ предыдущей ноды «как текст»
|
||||
- [[OUT1]] — если предыдущая нода имеет id n1
|
||||
|
||||
3) Добавить ключ Gemini из query в endpoint
|
||||
- /v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]
|
||||
- stream — в MVP стриминг отключён, сервер принудительно не стримит ответ.
|
||||
В шаблонах можно встретить поля stream, но по умолчанию они не включены.
|
||||
|
||||
---
|
||||
|
||||
## Почему местами нужны {{ ... }}
|
||||
## 7) Когда использовать [[...]] и когда {{ ... }}
|
||||
|
||||
Внутри JSON нам важно вставлять объекты/массивы без кавычек и иметь дефолты:
|
||||
- {{ pm.contents }} — вставит массив как массив
|
||||
- {{ params.temperature|default(0.7) }} — если нет значения, подставится 0.7
|
||||
- Внутрь JSON как ОБЪЕКТ/МАССИВ/ЧИСЛО: используйте {{ ... }}
|
||||
(фигурные скобки вставляют «как есть», без кавычек, и умеют |default(...))
|
||||
- Для строк/URL/заголовков/простых значений: можно использовать [[...]]
|
||||
(квадратные скобки удобны и короче писать)
|
||||
|
||||
Квадратные [[...]] хорошо подходят для строк/простых значений и для URL/заголовков.
|
||||
Примеры:
|
||||
- {{ pm.contents }} — вставит массив как настоящий массив (без кавычек)
|
||||
- {{ params.temperature|default(0.7) }} — безопасный дефолт для числа
|
||||
- [[VAR:incoming.api_keys.authorization]] — быстро подставить строку Authorization
|
||||
|
||||
---
|
||||
|
||||
## Отладка
|
||||
## 8) Отладка и рекомендации
|
||||
|
||||
- Проверьте лог DEBUG в консоли: ProviderCallNode показывает провайдера, URL и первые 400 символов тела запроса.
|
||||
- ProviderCall печатает в консоль DEBUG сведения: выбранный провайдер, конечный URL, первые символы тела запроса — удобно для проверки корректности шаблона.
|
||||
- Если «ничего не подставилось»:
|
||||
- убедитесь, что не подаёте входной payload в ProviderCall (иначе шаблон игнорируется);
|
||||
- проверьте валидность JSON после подстановок;
|
||||
- проверьте, что макрос написан корректно (OUT против OUTn).
|
||||
1) Проверьте, что вы НЕ передаёте сырое входное тело напрямую в ProviderCall (узел строит тело из шаблона и Prompt Blocks).
|
||||
2) Убедитесь, что итоговый JSON валиден (закрывающие скобки, запятые).
|
||||
3) Проверьте точность путей в макросах (OUT vs OUTx, правильные id нод n1/n2/...).
|
||||
- Для ссылок на выходы предыдущих нод используйте [[OUT1]] как «просто текст», либо полные пути [[OUT:n1...]] для точного фрагмента.
|
||||
|
||||
---
|
||||
|
||||
## Мини‑FAQ
|
||||
## 9) Быстрая памятка по ключам доступа
|
||||
|
||||
В: Почему [[OUT1]] пустой?
|
||||
О: Возможно, нода n1 не вернула текстового поля, и глубокий поиск не нашёл текста. Уточните путь через полную форму [[OUT:n1....]].
|
||||
|
||||
В: Можно ли получить весь «сырой» ответ?
|
||||
О: [[OUT:n1.result]] — вернёт весь JSON результата ноды n1.
|
||||
|
||||
В: Почему фигурные скобки иногда обязательны?
|
||||
О: Они умеют |default(...) и корректно вставляют объекты/массивы внутрь JSON.
|
||||
- Gemini: [[VAR:incoming.api_keys.key]] — рекомендовано; ключ приходит в query (?key=...).
|
||||
- OpenAI: [[VAR:incoming.headers.authorization]] (или [[VAR:incoming.api_keys.authorization]]) — стандартный Bearer‑токен.
|
||||
- Anthropic: [[VAR:incoming.headers.x-api-key]] — ключ в заголовке.
|
||||
|
||||
---
|
||||
|
||||
## Ссылки на реализацию
|
||||
## 10) Ссылки на реализацию (для интересующихся деталями)
|
||||
|
||||
- Макросы/рендер: [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)
|
||||
- Контекст (переменные): [build_macro_context()](agentui/api/server.py:142)
|
||||
- Исполнение пайплайна, зависимости, снапшоты OUT: [PipelineExecutor.run()](agentui/pipeline/executor.py:136)
|
||||
- Узел провайдера (Prompt Blocks → провайдер): [ProviderCallNode.run()](agentui/pipeline/executor.py:650)
|
||||
- PM‑структуры для шаблонов: [ProviderCallNode._blocks_struct_for_template()](agentui/pipeline/executor.py:592)
|
||||
- Подстановка [[PROMPT]], макросы, дефолты: [render_template_simple()](agentui/pipeline/templating.py:187)
|
||||
- Короткая форма [[OUTx]] и поиск «лучшего текста»: [_best_text_from_outputs()](agentui/pipeline/templating.py:124)
|
||||
- Прямой форвард входящего запроса: [RawForwardNode.run()](agentui/pipeline/executor.py:833)
|
||||
- Детекция вендора по входному payload: [detect_vendor()](agentui/common/vendors.py:8)
|
||||
|
||||
Удачного редактирования!
|
||||
Удачного редактирования!
|
||||
---
|
||||
## Пользовательские переменные (SetVars) — «для людей»
|
||||
|
||||
Задача: в начале пайплайна положить свои значения и потом использовать их в шаблонах одной строкой — например [[MY_KEY]] или {{ MAX_TOKENS }}.
|
||||
|
||||
Где это в UI
|
||||
- В левой панели добавьте ноду SetVars и откройте её в инспекторе.
|
||||
- Жмите «Добавить переменную», у каждой переменной есть три поля:
|
||||
- name — имя переменной (латинские буквы/цифры/подчёркивание, не с цифры): MY_KEY, REGION, MAX_TOKENS
|
||||
- mode — режим обработки значения:
|
||||
- string — строка, в которой работают макросы ([[...]] и {{ ... }})
|
||||
- expr — «мини‑формула» без макросов (подробнее ниже)
|
||||
- value — собственно значение
|
||||
|
||||
Как потом вставлять переменные
|
||||
- Для строк (URL/заголовки/текст) — квадратные скобки: [[MY_KEY]]
|
||||
- Для чисел/массивов/объектов — фигурные скобки: {{ MAX_TOKENS }}, {{ GEN_CFG }}
|
||||
|
||||
Примеры «как надо»
|
||||
- Переменная-строка (mode=string):
|
||||
- name: AUTH
|
||||
- value: "Bearer [[VAR:incoming.headers.authorization]]"
|
||||
- Использование в заголовке: "Authorization": "[[AUTH]]"
|
||||
- Переменная-число (mode=expr):
|
||||
- name: MAX_TOKENS
|
||||
- value: 128 + 64
|
||||
- Использование в JSON: "max_tokens": {{ MAX_TOKENS }}
|
||||
- Переменная-объект (mode=expr):
|
||||
- name: GEN_CFG
|
||||
- value: {"temperature": 0.3, "topP": 0.9, "safe": true}
|
||||
- Использование: "generationConfig": {{ GEN_CFG }}
|
||||
|
||||
Важно про два режима
|
||||
- string — это «шаблон». Внутри работают все макросы ([[VAR:...]], [[OUT:...]], [[PROMPT]], {{ ... }}). Значение прогоняется через рендер [render_template_simple()](agentui/pipeline/templating.py:184).
|
||||
- expr — это «мини‑формула». Внутри НЕТ макросов и НЕТ доступа к контексту; только литералы и операции (см. ниже). Вычисляет значение безопасно — без eval, на белом списке AST (реализация: [SetVarsNode._safe_eval_expr()](agentui/pipeline/executor.py:291)).
|
||||
|
||||
Что умеет expr (мини‑формулы)
|
||||
- Числа и арифметика: 128 + 64, (5 * 60) + 30, 42 % 2, -5, 23 // 10
|
||||
- Строки: "eu" + "-central" → "eu-central" (строки склеиваем знаком +)
|
||||
- Булева логика: (2 < 3) and (10 % 2 == 0), 1 < 2 < 5
|
||||
- Коллекции: ["fast", "safe"], {"temperature": 0.3, "topP": 0.9, "safe": true}
|
||||
- JSON‑литералы: true/false/null, объекты и массивы — если выражение является чистым JSON, оно разбирается напрямую (без макросов), т.е. true→True, null→None и т.п.
|
||||
- Запрещено: функции (кроме специально разрешённых ниже), доступ к переменным/контексту, атрибуты/индексация/условные выражения.
|
||||
|
||||
Рандом в expr
|
||||
- В expr доступны три простые функции случайности:
|
||||
- rand() → число с плавающей точкой в диапазоне [0, 1)
|
||||
- randint(a, b) → целое число от a до b включительно
|
||||
- choice(list) → случайный элемент из списка/кортежа
|
||||
- Примеры:
|
||||
- name: RAND_F, mode: expr, value: rand()
|
||||
- "temperature": {{ RAND_F }}
|
||||
- name: DICE, mode: expr, value: randint(1, 6)
|
||||
- "dice_roll": {{ DICE }}
|
||||
- name: PICK_MODEL, mode: expr, value: choice(["gpt-4o-mini", "gpt-4o", "o3-mini"])
|
||||
- "model": "[[PICK_MODEL]]"
|
||||
- Зерна/seed нет — каждый запуск выдаёт новое значение.
|
||||
|
||||
«Почему в expr нельзя подставлять переменные/макросы?»
|
||||
- Для безопасности и предсказуемости: expr — это закрытый мини‑язык без окружения.
|
||||
- Если нужно использовать другие переменные/макросы — делайте это в режиме string (там всё рендерится шаблонизатором).
|
||||
- Технические детали: защита реализована в [SetVarsNode._safe_eval_expr()](agentui/pipeline/executor.py:291), а вставка string‑значений — через [render_template_simple()](agentui/pipeline/templating.py:184).
|
||||
|
||||
Как это работает внутри (если интересно)
|
||||
- SetVars исполняется как обычная нода пайплайна и отдаёт {"vars": {...}}.
|
||||
- Исполнитель добавляет эти значения в контекст для последующих нод как context.vars (см. [PipelineExecutor.run()](agentui/pipeline/executor.py:131)).
|
||||
- При рендере шаблонов:
|
||||
- [[NAME]] и {{ NAME }} подставляются с приоритетом из пользовательских переменных (см. обработку в [render_template_simple()](agentui/pipeline/templating.py:184)).
|
||||
- Сам SetVars считает переменные в порядке списка и возвращает их одним пакетом (внутри одной ноды значения не зависят друг от друга).
|
||||
|
||||
Частые вопросы
|
||||
- «Хочу собрать строку с частями из внешнего запроса»: делайте mode=string и пишите: "Bearer [[VAR:incoming.headers.authorization]]".
|
||||
- «Хочу массив случайных чисел»: mode=expr → [rand(), rand(), rand()], а в JSON: "numbers": {{ MY_LIST }}
|
||||
- «Почему мои значения не сохраняются?» — нажмите «Сохранить параметры» в инспекторе ноды, затем «Сохранить пайплайн» в шапке. UI синхронизирует данные в node.data и сохраняет в pipeline.json (см. [static/editor.html](static/editor.html)).
|
||||
|
||||
Ссылки на реализацию (для любопытных)
|
||||
- Нода переменных: [SetVarsNode](agentui/pipeline/executor.py:264), [SetVarsNode._safe_eval_expr()](agentui/pipeline/executor.py:291), [SetVarsNode.run()](agentui/pipeline/executor.py:354)
|
||||
- Исполнитель/контекст vars: [PipelineExecutor.run()](agentui/pipeline/executor.py:131)
|
||||
- Шаблоны и макросы (включая «голые» [[NAME]]/{{ NAME }}): [render_template_simple()](agentui/pipeline/templating.py:184)
|
||||
@@ -8,7 +8,8 @@ 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'))"
|
||||
timeout /t 1 /nobreak >NUL
|
||||
start "" "http://127.0.0.1:%PORT%/ui/editor.html"
|
||||
python -m uvicorn agentui.api.server:app --host 127.0.0.1 --port %PORT% --log-level info
|
||||
if errorlevel 1 goto :fail
|
||||
goto :end
|
||||
|
||||
@@ -301,4 +301,27 @@ button:hover { background: #273246; }
|
||||
#drawflow .drawflow-delete:active,
|
||||
.drawflow-delete:active {
|
||||
transform: translate(-50%, -50%) scale(0.97) !important;
|
||||
}
|
||||
/* Execution highlight states (SSE-driven) */
|
||||
.drawflow .drawflow-node .title-box,
|
||||
.drawflow .drawflow-node .box {
|
||||
transition: border-color .12s ease, box-shadow .12s ease, background-color .12s ease;
|
||||
}
|
||||
|
||||
.drawflow .drawflow-node.node-running .title-box,
|
||||
.drawflow .drawflow-node.node-running .box {
|
||||
border-color: #60a5fa !important; /* blue */
|
||||
box-shadow: 0 0 0 2px rgba(96,165,250,.35) !important;
|
||||
}
|
||||
|
||||
.drawflow .drawflow-node.node-ok .title-box,
|
||||
.drawflow .drawflow-node.node-ok .box {
|
||||
border-color: #34d399 !important; /* green */
|
||||
box-shadow: 0 0 0 2px rgba(52,211,153,.35) !important;
|
||||
}
|
||||
|
||||
.drawflow .drawflow-node.node-err .title-box,
|
||||
.drawflow .drawflow-node.node-err .box {
|
||||
border-color: #ef4444 !important; /* red */
|
||||
box-shadow: 0 0 0 2px rgba(239,68,68,.35) !important;
|
||||
}
|
||||
@@ -54,7 +54,7 @@
|
||||
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" />
|
||||
<link rel="stylesheet" href="/ui/editor.css?v=2" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
@@ -74,8 +74,10 @@
|
||||
<div id="container">
|
||||
<aside id="sidebar">
|
||||
<div class="group-title">Ноды</div>
|
||||
<button title="Задать пользовательские переменные, доступные как [[NAME]] и {{ NAME }}" class="node-btn" data-node="SetVars">SetVars</button>
|
||||
<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>
|
||||
<button title="Финализировать ответ в формате целевого провайдера (auto/openai/gemini/claude)" class="node-btn" data-node="Return">Return</button>
|
||||
<div class="hint">Подсказка: соедините выход предыдущей ноды с входом следующей, сохраните и тестируйте через /ui.</div>
|
||||
<div class="group-title">Переменные и макросы</div>
|
||||
<div class="hint">Используйте переменные в шаблонах как <code>[[variable]]</code>. Наведите курсор на имя переменной, чтобы увидеть подсказку.</div>
|
||||
@@ -104,13 +106,13 @@
|
||||
</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>
|
||||
<code title="Альтернативное имя ключа, если используется">[[incoming.api_keys.key]]</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>
|
||||
<code title="Текст из выхода ноды n2">[[OUT2]]</code>,
|
||||
<code title="Пользовательская переменная, заданная в SetVars">[[NAME]]</code>
|
||||
<span style="opacity:.85"> | Расширенно: <code>[[OUT:n1.result...]]</code> или <code>{{ OUT.n1.result... }}</code></span>
|
||||
</div>
|
||||
<div class="group-title">Отладка</div>
|
||||
@@ -127,17 +129,24 @@
|
||||
|
||||
<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 src="/ui/js/serialization.js?v=2"></script>
|
||||
<script src="/ui/js/pm-ui.js?v=2"></script>
|
||||
<script>
|
||||
// Типы портов и их имена в нашем контракте
|
||||
const NODE_IO = {
|
||||
// depends: используется только для порядка выполнения (зависимости), данные не читаются
|
||||
ProviderCall: { inputs: ['depends'], outputs: ['result','response_text'] },
|
||||
RawForward: { inputs: [], outputs: ['result'] }
|
||||
// Провода не переносят данные; OUT/vars берутся из контекста и снапшота.
|
||||
SetVars: { inputs: [], outputs: ['done'] },
|
||||
ProviderCall:{ inputs: ['depends'], outputs: ['done'] },
|
||||
RawForward: { inputs: ['depends'], outputs: ['done'] },
|
||||
Return: { inputs: ['depends'], outputs: [] }
|
||||
};
|
||||
window.NODE_IO = NODE_IO;
|
||||
|
||||
const editor = new Drawflow(document.getElementById('drawflow'));
|
||||
editor.reroute = true;
|
||||
editor.start();
|
||||
window.editor = editor;
|
||||
|
||||
// Провайдерные пресеты для ProviderCall (редактируемые пользователем).
|
||||
// Шаблоны используют {{ pm.* }} — это JSON-структуры, которые сервер собирает из Prompt Blocks.
|
||||
@@ -233,7 +242,17 @@
|
||||
const p = getActiveProv(d);
|
||||
return d.provider_configs[p] || {};
|
||||
}
|
||||
|
||||
|
||||
// HTML escaping helpers for safe attribute/text insertion
|
||||
function escAttr(v) {
|
||||
const s = String(v ?? '');
|
||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
||||
}
|
||||
function escText(v) {
|
||||
const s = String(v ?? '');
|
||||
return s.replace(/&/g, '&').replace(/</g, '<');
|
||||
}
|
||||
|
||||
// Нормализуем/заполняем дефолты конфигов нод, чтобы ключи попадали в сериализацию
|
||||
function applyNodeDefaults(type, data) {
|
||||
const d = { ...(data || {}) };
|
||||
@@ -247,6 +266,13 @@
|
||||
if (d.passthrough_headers == null) d.passthrough_headers = true;
|
||||
if (d.extra_headers == null) d.extra_headers = '{}';
|
||||
}
|
||||
if (type === 'SetVars') {
|
||||
if (!Array.isArray(d.variables)) d.variables = [];
|
||||
}
|
||||
if (type === 'Return') {
|
||||
if (d.target_format == null) d.target_format = 'auto';
|
||||
if (d.text_template == null) d.text_template = '[[OUT1]]';
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
@@ -286,15 +312,15 @@
|
||||
}
|
||||
const template = tmpl;return `<div class="box preview">
|
||||
<label>provider</label>
|
||||
<input type="text" value="${provider}" readonly />
|
||||
<input type="text" value="${escAttr(provider)}" readonly />
|
||||
<label>base_url</label>
|
||||
<input type="text" value="${base_url.replace(/"/g,'"')}" readonly />
|
||||
<input type="text" value="${escAttr(base_url)}" readonly />
|
||||
<label>endpoint</label>
|
||||
<input type="text" value="${endpoint.replace(/"/g,'"')}" readonly />
|
||||
<input type="text" value="${escAttr(endpoint)}" readonly />
|
||||
<label>headers (preview JSON)</label>
|
||||
<textarea readonly>${headers.replace(/</g,'<')}</textarea>
|
||||
<textarea readonly>${escText(headers)}</textarea>
|
||||
<label>template (preview JSON)</label>
|
||||
<textarea readonly>${template.replace(/</g,'<')}</textarea>
|
||||
<textarea readonly>${escText(template)}</textarea>
|
||||
</div>`;
|
||||
}
|
||||
if (type === 'RawForward') {
|
||||
@@ -304,20 +330,71 @@
|
||||
const extra_headers = data.extra_headers || '{}';
|
||||
return `<div class="box preview">
|
||||
<label>base_url</label>
|
||||
<input type="text" value="${base_url.replace(/"/g,'"')}" readonly />
|
||||
<input type="text" value="${escAttr(base_url)}" readonly />
|
||||
<label>override_path</label>
|
||||
<input type="text" value="${override_path.replace(/"/g,'"')}" readonly />
|
||||
<input type="text" value="${escAttr(override_path)}" readonly />
|
||||
<label><input type="checkbox" ${passthrough_headers} disabled/> passthrough_headers</label>
|
||||
<label>extra_headers (preview JSON)</label>
|
||||
<textarea readonly>${extra_headers.replace(/</g,'<')}</textarea>
|
||||
<textarea readonly>${escText(extra_headers)}</textarea>
|
||||
</div>`;
|
||||
}
|
||||
if (type === 'SetVars') {
|
||||
const vars = Array.isArray(data.variables) ? data.variables : [];
|
||||
const names = vars.map(v => v?.name || '').filter(Boolean);
|
||||
return `<div class="box preview">
|
||||
<label>variables</label>
|
||||
<textarea readonly>${escText(names.length ? names.join(', ') : '(нет переменных)')}</textarea>
|
||||
<div class="hint">В шаблонах доступны как [[NAME]] и {{ NAME }}.</div>
|
||||
</div>`;
|
||||
}
|
||||
if (type === 'Return') {
|
||||
const tgt = (data.target_format || 'auto');
|
||||
const tmpl = (data.text_template != null ? data.text_template : '[[OUT1]]');
|
||||
return `<div class="box preview">
|
||||
<label>target_format</label>
|
||||
<input type="text" value="${escAttr(tgt)}" readonly />
|
||||
<label>text_template (preview)</label>
|
||||
<textarea readonly>${escText(tmpl)}</textarea>
|
||||
</div>`;
|
||||
}
|
||||
return `<div class="box"></div>`;
|
||||
}
|
||||
|
||||
// Helpers to manage human-readable original ids (nX)
|
||||
function collectUsedOrigNums() {
|
||||
try {
|
||||
const data = window.editor && window.editor.export ? window.editor.export() : null;
|
||||
const dfNodes = (data && data.drawflow && data.drawflow.Home && data.drawflow.Home.data) ? data.drawflow.Home.data : {};
|
||||
const used = new Set();
|
||||
for (const dfid in dfNodes) {
|
||||
try {
|
||||
const n = window.editor.getNodeFromId(parseInt(dfid, 10));
|
||||
const orig = n && n.data && n.data._origId;
|
||||
if (typeof orig === 'string') {
|
||||
const m = orig.match(/^n(\d+)$/i);
|
||||
if (m) used.add(parseInt(m[1], 10));
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
return used;
|
||||
} catch (e) {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function nextFreeOrigId() {
|
||||
const used = collectUsedOrigNums();
|
||||
let x = 1;
|
||||
while (used.has(x)) x += 1;
|
||||
return 'n' + x;
|
||||
}
|
||||
|
||||
function addNode(type, pos = {x: 100, y: 100}, data = {}) {
|
||||
const io = NODE_IO[type];
|
||||
const dataWithDefaults = applyNodeDefaults(type, data);
|
||||
if (!dataWithDefaults._origId) {
|
||||
try { dataWithDefaults._origId = nextFreeOrigId(); } catch (e) { dataWithDefaults._origId = ''; }
|
||||
}
|
||||
const html = makeNodeHtml(type, dataWithDefaults);
|
||||
const id = editor.addNode(
|
||||
type,
|
||||
@@ -370,11 +447,11 @@
|
||||
<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>base_url</label><input id="f-baseurl" type="text" value="${escAttr(cfg.base_url||'')}" placeholder="https://api.openai.com">
|
||||
<label>endpoint</label><input id="f-endpoint" type="text" value="${escAttr(cfg.endpoint||'')}" placeholder="/v1/chat/completions">
|
||||
<label>headers (JSON)</label><textarea id="f-headers">${escText(cfg.headers||'{}')}</textarea>
|
||||
<label>template (JSON)</label>
|
||||
<textarea id="f-template">${(cfg.template||'{}').replace(/</g,'<')}</textarea>
|
||||
<textarea id="f-template">${escText(cfg.template||'{}')}</textarea>
|
||||
<div style="margin-top:6px">
|
||||
<details class="help">
|
||||
<summary title="Подсказка по шаблону">?</summary>
|
||||
@@ -425,12 +502,56 @@
|
||||
`;
|
||||
} 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>base_url</label><input id="f-baseurl" type="text" value="${escAttr(data.base_url||'')}" placeholder="https://api.openai.com">
|
||||
<label>override_path</label><input id="f-override" type="text" value="${escAttr(data.override_path||'')}" 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>
|
||||
<label>extra_headers (JSON)</label><textarea id="f-extra">${escText(data.extra_headers||'{}')}</textarea>
|
||||
<div class="hint">Берёт path, query, headers, json из incoming.*</div>
|
||||
`;
|
||||
} else if (type === 'Return') {
|
||||
html += `
|
||||
<label>target_format</label>
|
||||
<select id="ret-target">
|
||||
<option value="auto">auto (из исходного запроса)</option>
|
||||
<option value="openai">openai</option>
|
||||
<option value="gemini">gemini</option>
|
||||
<option value="claude">claude</option>
|
||||
</select>
|
||||
<label>text_template</label>
|
||||
<textarea id="ret-template" rows="4">${escText(data.text_template ?? '[[OUT1]]')}</textarea>
|
||||
<div class="hint">Финализирует ответ в выбранный протокол. Макросы [[VAR:...]], [[OUT:...]], [[OUTx]], {{ ... }} поддерживаются.</div>
|
||||
`;
|
||||
} else if (type === 'SetVars') {
|
||||
const list = Array.isArray(data.variables) ? data.variables : [];
|
||||
const rows = list.map((v, i) => {
|
||||
const name = escAttr(v?.name || '');
|
||||
const mode = (v?.mode || 'string');
|
||||
const value = escText(v?.value || '');
|
||||
return `
|
||||
<div class="var-row" data-idx="${i}" style="border:1px solid #2b3646;border-radius:6px;padding:8px;margin:6px 0">
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<label style="min-width:60px">name</label>
|
||||
<input class="v-name" type="text" value="${name}" placeholder="MY_VAR" style="flex:1">
|
||||
<label style="min-width:56px">mode</label>
|
||||
<select class="v-mode">
|
||||
<option value="string"${mode==='string'?' selected':''}>string</option>
|
||||
<option value="expr"${mode==='expr'?' selected':''}>expr</option>
|
||||
</select>
|
||||
<button class="v-del" title="Удалить">🗑</button>
|
||||
</div>
|
||||
<label style="margin-top:6px;display:block">value</label>
|
||||
<textarea class="v-value" rows="3">${value}</textarea>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
html += `
|
||||
<div class="group-title" style="margin-top:8px">Переменные</div>
|
||||
<div id="vars-list">${rows || '<div class="hint">(нет переменных)</div>'}</div>
|
||||
<div style="margin-top:8px">
|
||||
<button id="vars-add">Добавить переменную</button>
|
||||
</div>
|
||||
<div class="hint" style="margin-top:6px">Переменные доступны в шаблонах как [[NAME]] и {{ NAME }}. Mode=expr — мини‑формулы без доступа к Python.</div>
|
||||
`;
|
||||
}
|
||||
html += `
|
||||
<div style="margin-top:10px">
|
||||
@@ -440,8 +561,7 @@
|
||||
// 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 => {
|
||||
document.querySelectorAll('#inspector textarea, #inspector input, #inspector select').forEach(inp => {
|
||||
inp.addEventListener('input', () => {
|
||||
const n = editor.getNodeFromId(id);
|
||||
if (!n) return;
|
||||
@@ -455,19 +575,104 @@
|
||||
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 {
|
||||
// Синхронизуем в Drawflow, чтобы export() видел обновления
|
||||
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
|
||||
const el = document.querySelector(`#node-${id}`);
|
||||
if (el) el.__data = d;
|
||||
} else if (type === 'RawForward') {
|
||||
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;
|
||||
} else if (type === 'Return') {
|
||||
if (inp.id === 'ret-target') d.target_format = inp.value;
|
||||
if (inp.id === 'ret-template') d.text_template = inp.value;
|
||||
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
|
||||
const el = document.querySelector(`#node-${id}`);
|
||||
if (el) el.__data = d;
|
||||
} else if (type === 'SetVars') {
|
||||
// Для SetVars синхронизацию выполняют специализированные обработчики ниже (resync).
|
||||
// Здесь ничего не делаем, чтобы не затереть значения.
|
||||
return;
|
||||
} else {
|
||||
// Прочие типы — на будущее: безопасная синхронизация без изменений
|
||||
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
|
||||
const el = document.querySelector(`#node-${id}`);
|
||||
if (el) el.__data = d;
|
||||
}
|
||||
// Синхронизуем в Drawflow, чтобы export() видел обновления
|
||||
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
|
||||
const el = document.querySelector(`#node-${id}`);
|
||||
if (el) el.__data = d;
|
||||
});
|
||||
});
|
||||
|
||||
// Обработчики для SetVars
|
||||
if (type === 'SetVars') {
|
||||
const n = editor.getNodeFromId(id);
|
||||
if (n) {
|
||||
if (!Array.isArray(n.data.variables)) n.data.variables = [];
|
||||
// Начальный sync, чтобы DOM.__data сразу содержал variables для сериализации
|
||||
try { editor.updateNodeDataFromId(id, n.data || {}); } catch (e) {}
|
||||
const el0 = document.querySelector(`#node-${id}`);
|
||||
if (el0) el0.__data = JSON.parse(JSON.stringify(n.data || {}));
|
||||
}
|
||||
const root = document.getElementById('vars-list');
|
||||
const addBtn = document.getElementById('vars-add');
|
||||
function resync() {
|
||||
const nn = editor.getNodeFromId(id);
|
||||
if (!nn) return;
|
||||
try { editor.updateNodeDataFromId(id, nn.data || {}); } catch (e) {}
|
||||
const el = document.querySelector(`#node-${id}`);
|
||||
if (el) el.__data = JSON.parse(JSON.stringify(nn.data || {}));
|
||||
}
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener('click', () => {
|
||||
const nn = editor.getNodeFromId(id); if (!nn) return;
|
||||
const d = nn.data || {}; if (!Array.isArray(d.variables)) d.variables = [];
|
||||
d.variables.push({ id: 'v'+Date.now().toString(36), name: 'NAME', mode: 'string', value: '' });
|
||||
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
|
||||
resync();
|
||||
renderInspector(id, editor.getNodeFromId(id));
|
||||
});
|
||||
}
|
||||
if (root) {
|
||||
root.querySelectorAll('.var-row').forEach(row => {
|
||||
const idx = parseInt(row.getAttribute('data-idx') || '-1', 10);
|
||||
const nameInp = row.querySelector('.v-name');
|
||||
const modeSel = row.querySelector('.v-mode');
|
||||
const valTxt = row.querySelector('.v-value');
|
||||
const delBtn = row.querySelector('.v-del');
|
||||
if (nameInp) nameInp.addEventListener('input', () => {
|
||||
const nn = editor.getNodeFromId(id); if (!nn) return;
|
||||
const d = nn.data || {}; if (!Array.isArray(d.variables)) d.variables = [];
|
||||
if (d.variables[idx]) d.variables[idx].name = nameInp.value;
|
||||
resync();
|
||||
});
|
||||
if (modeSel) modeSel.addEventListener('change', () => {
|
||||
const nn = editor.getNodeFromId(id); if (!nn) return;
|
||||
const d = nn.data || {}; if (!Array.isArray(d.variables)) d.variables = [];
|
||||
if (d.variables[idx]) d.variables[idx].mode = modeSel.value;
|
||||
resync();
|
||||
});
|
||||
if (valTxt) valTxt.addEventListener('input', () => {
|
||||
const nn = editor.getNodeFromId(id); if (!nn) return;
|
||||
const d = nn.data || {}; if (!Array.isArray(d.variables)) d.variables = [];
|
||||
if (d.variables[idx]) d.variables[idx].value = valTxt.value;
|
||||
resync();
|
||||
});
|
||||
if (delBtn) delBtn.addEventListener('click', () => {
|
||||
const nn = editor.getNodeFromId(id); if (!nn) return;
|
||||
const d = nn.data || {}; if (!Array.isArray(d.variables)) d.variables = [];
|
||||
d.variables.splice(idx, 1);
|
||||
try { editor.updateNodeDataFromId(id, d); } catch (e) {}
|
||||
resync();
|
||||
renderInspector(id, editor.getNodeFromId(id));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Поддержка select#f-provider + автоподстановка пресетов (без жесткого перезаписывания ручных правок)
|
||||
const provSel = document.getElementById('f-provider');
|
||||
@@ -498,6 +703,13 @@
|
||||
try { console.debug('[ProviderCall] provider switched to', d.provider, cfg); } catch (e) {}
|
||||
});
|
||||
}
|
||||
// Init Return selects defaults if present
|
||||
try {
|
||||
const tgtSel = document.getElementById('ret-target');
|
||||
if (tgtSel) {
|
||||
tgtSel.value = (node.data?.target_format || 'auto');
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// Кнопка сохранить параметры
|
||||
const saveBtnNode = document.getElementById('btn-save-node');
|
||||
@@ -505,9 +717,36 @@
|
||||
saveBtnNode.addEventListener('click', () => {
|
||||
const n = editor.getNodeFromId(id);
|
||||
if (!n) return;
|
||||
// Для SetVars дополнительно читаем текущие значения из DOM, чтобы гарантированно не потерять value
|
||||
if (type === 'SetVars') {
|
||||
const root = document.getElementById('vars-list');
|
||||
const varsNew = [];
|
||||
if (root) {
|
||||
root.querySelectorAll('.var-row').forEach(row => {
|
||||
const idx = parseInt(row.getAttribute('data-idx') || '-1', 10);
|
||||
const name = (row.querySelector('.v-name')?.value ?? '').trim();
|
||||
const mode = (row.querySelector('.v-mode')?.value ?? 'string');
|
||||
const value = (row.querySelector('.v-value')?.value ?? '');
|
||||
if (name) {
|
||||
// сохраняем прежний id при наличии, чтобы не мигали идентификаторы
|
||||
const prevId = (n.data?.variables && n.data.variables[idx] && n.data.variables[idx].id) ? n.data.variables[idx].id : ('v'+Date.now().toString(36)+idx);
|
||||
varsNew.push({ id: prevId, name, mode, value });
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!Array.isArray(n.data.variables)) n.data.variables = [];
|
||||
n.data.variables = varsNew;
|
||||
}
|
||||
// Синхронизируем данные узла в Drawflow и в DOM.__data — это источник правды для toPipelineJSON()
|
||||
try { editor.updateNodeDataFromId(id, n.data || {}); } catch (e) {}
|
||||
const el = document.querySelector(`#node-${id}`);
|
||||
if (el) el.__data = JSON.parse(JSON.stringify(n.data || {}));
|
||||
// Отладочный статус, чтобы видеть, что реально уйдёт в pipeline.json
|
||||
try {
|
||||
if (type === 'SetVars') {
|
||||
status('SetVars saved: ' + JSON.stringify((n.data && n.data.variables) ? n.data.variables : []));
|
||||
}
|
||||
} catch (e) {}
|
||||
try { savePipeline(); } catch (e) {}
|
||||
status("Параметры ноды сохранены в pipeline.json");
|
||||
});
|
||||
@@ -518,178 +757,17 @@
|
||||
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();
|
||||
// ensure variables explicitly kept in node data (for SetVars)
|
||||
if (ncheck && Array.isArray(ncheck.data.variables)) {
|
||||
// глубокая копия, чтобы serialization взяла актуальные значения
|
||||
ncheck.data.variables = ncheck.data.variables.map(v => ({ ...(v || {}) }));
|
||||
try { editor.updateNodeDataFromId(id, ncheck.data); } catch (e) {}
|
||||
const elN = document.querySelector(`#node-${id}`);
|
||||
if (elN) elN.__data = JSON.parse(JSON.stringify(ncheck.data || {}));
|
||||
}
|
||||
// Prompt Manager UI for ProviderCall
|
||||
if (type === 'ProviderCall') { PM.setupProviderCallPMUI(editor, id); }
|
||||
}
|
||||
|
||||
// Добавление нод из сайдбара
|
||||
document.querySelectorAll('.node-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
@@ -698,232 +776,22 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Сериализация: Drawflow -> наш pipeline JSON
|
||||
function toPipelineJSON() {
|
||||
const data = editor.export();
|
||||
const nodes = [];
|
||||
const idMap = {}; // drawflow id -> generated id like n1, n2
|
||||
// Сериализация вынесена во внешний файл /ui/js/serialization.js
|
||||
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
// Десериализация вынесена во внешний файл /ui/js/serialization.js
|
||||
|
||||
// Загрузка/сохранение
|
||||
async function loadPipeline() {
|
||||
const res = await fetch('/admin/pipeline');
|
||||
const p = await res.json();
|
||||
await fromPipelineJSON(p);
|
||||
await window.AgentUISer.fromPipelineJSON(p);
|
||||
// Не затираем логи, которые вывел fromPipelineJSON
|
||||
const st = document.getElementById('status').textContent;
|
||||
if (!st) status('Загружено');
|
||||
}
|
||||
async function savePipeline() {
|
||||
try {
|
||||
const p = toPipelineJSON();
|
||||
const p = window.AgentUISer.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));
|
||||
@@ -945,7 +813,7 @@ async function connectWithRetry(srcDfId, tgtDfId, outNum, inNum, tries = 120, de
|
||||
const name = document.getElementById('preset-name').value.trim();
|
||||
if (!name) { status('Укажите имя пресета'); return; }
|
||||
try {
|
||||
const p = toPipelineJSON();
|
||||
const p = window.AgentUISer.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));
|
||||
@@ -959,7 +827,7 @@ async function connectWithRetry(srcDfId, tgtDfId, outNum, inNum, tries = 120, de
|
||||
if (!name) { status('Выберите пресет'); return; }
|
||||
const res = await fetch('/admin/presets/' + encodeURIComponent(name));
|
||||
const p = await res.json();
|
||||
await fromPipelineJSON(p);
|
||||
await window.AgentUISer.fromPipelineJSON(p);
|
||||
// Сделаем загруженный пресет активным пайплайном (сохранение в pipeline.json)
|
||||
await fetch('/admin/pipeline', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(p) });
|
||||
// Не затираем логи соединений, если они уже выведены
|
||||
@@ -975,7 +843,96 @@ async function connectWithRetry(srcDfId, tgtDfId, outNum, inNum, tries = 120, de
|
||||
loadPipeline();
|
||||
refreshPresets();
|
||||
</script>
|
||||
<!-- SSE highlight script -->
|
||||
<script>
|
||||
(function() {
|
||||
try {
|
||||
const timers = new Map();
|
||||
|
||||
function getStatusEl() {
|
||||
return document.getElementById('status');
|
||||
}
|
||||
function setStatus(txt) {
|
||||
try { const el = getStatusEl(); if (el) el.textContent = txt; } catch (e) {}
|
||||
}
|
||||
|
||||
function findNodeElByOrigId(origId) {
|
||||
if (!origId && origId !== 0) return null;
|
||||
// 1) Прямая попытка по DOM id (Drawflow id)
|
||||
const byDfId = document.getElementById('node-' + origId);
|
||||
if (byDfId) return byDfId;
|
||||
|
||||
// 2) По _origId, хранящемуся в DOM.__data
|
||||
const nodes = document.querySelectorAll('.drawflow .drawflow-node');
|
||||
for (const el of nodes) {
|
||||
const d = el && el.__data;
|
||||
if (!d) continue;
|
||||
if (String(d._origId) === String(origId)) return el;
|
||||
// fallback: иногда id дублируется как d.id
|
||||
if (String(d.id) === String(origId)) return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearTempTimer(el) {
|
||||
const t = timers.get(el);
|
||||
if (t) {
|
||||
clearTimeout(t);
|
||||
timers.delete(el);
|
||||
}
|
||||
}
|
||||
|
||||
function addTempClass(el, cls, ms) {
|
||||
clearTempTimer(el);
|
||||
el.classList.add(cls);
|
||||
const t = setTimeout(() => {
|
||||
el.classList.remove(cls);
|
||||
timers.delete(el);
|
||||
}, ms);
|
||||
timers.set(el, t);
|
||||
}
|
||||
|
||||
function handleTraceEvent(evt) {
|
||||
if (!evt || typeof evt !== 'object') return;
|
||||
const nodeId = evt.node_id;
|
||||
const el = findNodeElByOrigId(nodeId);
|
||||
if (!el) return;
|
||||
|
||||
// Сбрасываем конфликтующие временные классы
|
||||
if (evt.event === 'node_start') {
|
||||
clearTempTimer(el);
|
||||
el.classList.add('node-running');
|
||||
el.classList.remove('node-ok', 'node-err');
|
||||
} else if (evt.event === 'node_done') {
|
||||
el.classList.remove('node-running');
|
||||
addTempClass(el, 'node-ok', 1500);
|
||||
} else if (evt.event === 'node_error') {
|
||||
el.classList.remove('node-running');
|
||||
addTempClass(el, 'node-err', 2500);
|
||||
}
|
||||
}
|
||||
|
||||
// Открываем SSE поток
|
||||
const es = new EventSource('/admin/trace/stream');
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
handleTraceEvent(data);
|
||||
} catch (_) {
|
||||
// игнорируем мусор
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
// Можно тихо игнорировать; при необходимости — вывести статус
|
||||
// setStatus('SSE: disconnected');
|
||||
};
|
||||
|
||||
// Экспорт для отладки из консоли
|
||||
window.__TraceSSE = { es, handleTraceEvent, findNodeElByOrigId };
|
||||
} catch (e) {
|
||||
try { console.error('SSE highlight init error', e); } catch (_) {}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
</html>
|
||||
191
static/js/pm-ui.js
Normal file
191
static/js/pm-ui.js
Normal file
@@ -0,0 +1,191 @@
|
||||
/* global window, document */
|
||||
// AgentUI Prompt Manager UI extracted from editor.html
|
||||
// Exposes window.PM.setupProviderCallPMUI(editor, id)
|
||||
// Depends on DOM elements rendered by editor.html inspector:
|
||||
// #pm-list, #pm-editor, #pm-name, #pm-role, #pm-prompt, #pm-save, #pm-cancel
|
||||
(function (w) {
|
||||
'use strict';
|
||||
|
||||
function setupProviderCallPMUI(editor, id) {
|
||||
try {
|
||||
const n2 = editor.getNodeFromId(id);
|
||||
if (!n2) return;
|
||||
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;
|
||||
|
||||
// Изменения блока применяются только по кнопке «Сохранить» внутри редактора блока.
|
||||
|
||||
// Drag&Drop через SortableJS (если доступен)
|
||||
if (w.Sortable && listEl && !listEl.__sortable) {
|
||||
listEl.__sortable = new w.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 renderList() {
|
||||
sortAndReindex();
|
||||
if (!listEl) return;
|
||||
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 via HTML5 fallback as well (kept for compatibility)
|
||||
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)) {
|
||||
if (editorBox) 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;
|
||||
if (editorBox) editorBox.style.display = '';
|
||||
if (nameInp) nameInp.value = b.name || '';
|
||||
if (roleSel) roleSel.value = (b.role || 'user');
|
||||
if (promptTxt) promptTxt.value = b.prompt || '';
|
||||
}
|
||||
|
||||
if (addBtn) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
if (saveBtn) {
|
||||
saveBtn.addEventListener('click', () => {
|
||||
if (!editingId) { if (editorBox) editorBox.style.display = 'none'; return; }
|
||||
const b = d2.blocks.find(x => (x.id || null) === editingId);
|
||||
if (b) {
|
||||
b.name = nameInp ? nameInp.value : b.name;
|
||||
b.role = roleSel ? roleSel.value : b.role || 'user';
|
||||
b.prompt = promptTxt ? promptTxt.value : b.prompt;
|
||||
// Пересоберём массив, чтобы избежать проблем с мутацией по ссылке
|
||||
d2.blocks = d2.blocks.map(x => (x.id === b.id ? ({ ...b }) : x));
|
||||
}
|
||||
if (editorBox) editorBox.style.display = 'none';
|
||||
editingId = null;
|
||||
renderList();
|
||||
syncNodeDataBlocks();
|
||||
// попытка автосохранения пайплайна, если доступна глобальная функция
|
||||
try { (typeof w.savePipeline === 'function') && w.savePipeline(); } catch (e) {}
|
||||
try { (typeof w.status === 'function') && w.status('Блок сохранён в pipeline.json'); } catch (e) {}
|
||||
});
|
||||
}
|
||||
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
if (editorBox) editorBox.style.display = 'none';
|
||||
editingId = null;
|
||||
});
|
||||
}
|
||||
|
||||
// Первичная отрисовка
|
||||
renderList();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
w.PM = {
|
||||
setupProviderCallPMUI
|
||||
};
|
||||
})(window);
|
||||
247
static/js/serialization.js
Normal file
247
static/js/serialization.js
Normal file
@@ -0,0 +1,247 @@
|
||||
/* global window, document, requestAnimationFrame */
|
||||
// AgentUI serialization helpers extracted from editor.html
|
||||
// Exposes window.AgentUISer.{toPipelineJSON, fromPipelineJSON}
|
||||
// Depends on globals defined by editor.html: editor, NODE_IO, addNode, applyNodeDefaults, status
|
||||
(function (w) {
|
||||
'use strict';
|
||||
|
||||
function ensureDeps() {
|
||||
if (!w || !w.editor) throw new Error('AgentUISer: global editor is not available');
|
||||
if (!w.NODE_IO) throw new Error('AgentUISer: global NODE_IO is not available');
|
||||
}
|
||||
|
||||
// Drawflow -> pipeline JSON
|
||||
function toPipelineJSON() {
|
||||
ensureDeps();
|
||||
const editor = w.editor;
|
||||
const NODE_IO = w.NODE_IO;
|
||||
|
||||
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 = typeof w.applyNodeDefaults === 'function'
|
||||
? w.applyNodeDefaults(df.name, JSON.parse(JSON.stringify(datacopySrc)))
|
||||
: (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;
|
||||
|
||||
// Собираем все связи этого входа и сохраняем строку либо массив строк (для depends поддерживаем мульти-коннекты)
|
||||
const refs = [];
|
||||
for (const conn of (input.connections || [])) {
|
||||
if (!conn) continue;
|
||||
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
|
||||
|
||||
const sourceNode = nodes.find(n => n.id === idMap[sourceDfId]);
|
||||
if (!sourceNode) continue;
|
||||
const sourceIo = NODE_IO[sourceNode.type] || { outputs: [] };
|
||||
const sourceOutName = (sourceIo.outputs && sourceIo.outputs[sourceOutIdx] != null)
|
||||
? sourceIo.outputs[sourceOutIdx]
|
||||
: `out${sourceOutIdx}`;
|
||||
refs.push(`${sourceNode.id}.${sourceOutName}`);
|
||||
}
|
||||
|
||||
// Каноничное имя входа: по 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] = (refs.length <= 1 ? refs[0] : refs);
|
||||
}
|
||||
}
|
||||
|
||||
return { id: 'pipeline_editor', name: 'Edited Pipeline', nodes };
|
||||
}
|
||||
|
||||
// pipeline JSON -> Drawflow
|
||||
async function fromPipelineJSON(p) {
|
||||
ensureDeps();
|
||||
const editor = w.editor;
|
||||
const NODE_IO = w.NODE_IO;
|
||||
|
||||
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] && 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] && 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 data = { ...(n.config || {}), _origId: n.id };
|
||||
const dfid = typeof w.addNode === 'function'
|
||||
? w.addNode(n.type, pos, data)
|
||||
: (function () { throw new Error('AgentUISer: addNode() is not defined'); })();
|
||||
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)) {
|
||||
const refs = Array.isArray(ref) ? ref : [ref];
|
||||
for (const oneRef of refs) {
|
||||
if (!oneRef || typeof oneRef !== 'string' || !oneRef.includes('.')) continue;
|
||||
const [srcId, outName] = oneRef.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 ok = await connectWithRetry(sourceDfId, targetDfId, outIdx + 1, inIdx + 1, 200, 25);
|
||||
if (ok) {
|
||||
logs.push(`connect: ${srcId}.${outName} (#${sourceDfId}.output_${outIdx + 1}) -> ${n.id}.${inName} (#${targetDfId}.input_${inIdx + 1})`);
|
||||
} else {
|
||||
logs.push(`skip connect (ports not ready after retries): ${srcId}.${outName} -> ${n.id}.${inName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Обновим линии и выведем лог
|
||||
try {
|
||||
Object.values(idMap).forEach((dfid) => {
|
||||
if (editor.updateConnectionNodes) {
|
||||
editor.updateConnectionNodes(`node-${dfid}`);
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
if (logs.length) {
|
||||
try { (typeof w.status === 'function') && w.status('Загружено (links):\n' + logs.join('\n')); } catch {}
|
||||
try { console.debug('[fromPipelineJSON]', logs); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
w.AgentUISer = {
|
||||
toPipelineJSON,
|
||||
fromPipelineJSON,
|
||||
};
|
||||
})(window);
|
||||
87
tests/test_templating.py
Normal file
87
tests/test_templating.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from agentui.pipeline.templating import (
|
||||
render_template_simple,
|
||||
_best_text_from_outputs,
|
||||
)
|
||||
|
||||
def run_checks():
|
||||
# Common context for tests
|
||||
ctx = {
|
||||
"model": "gpt-x",
|
||||
"params": {"max_tokens": 128},
|
||||
"chat": {"last_user": "Привет"},
|
||||
"OUT": {},
|
||||
}
|
||||
out_map = {}
|
||||
|
||||
# 1) [[VAR:...]]
|
||||
s1 = render_template_simple("Hello [[VAR:chat.last_user]]", ctx, out_map)
|
||||
assert s1 == "Hello Привет"
|
||||
|
||||
# 2) {{ ... |default(...) }} when missing
|
||||
s2 = render_template_simple("T={{ params.temperature|default(0.7) }}", ctx, out_map)
|
||||
assert s2 == "T=0.7"
|
||||
# present
|
||||
ctx2 = {**ctx, "params": {**ctx["params"], "temperature": 0.4}}
|
||||
s3 = render_template_simple("T={{ params.temperature|default(0.7) }}", ctx2, out_map)
|
||||
assert s3 == "T=0.4"
|
||||
|
||||
# 3) [[OUT:n1...]] exact path
|
||||
out_map = {
|
||||
"n1": {
|
||||
"result": {
|
||||
"choices": [
|
||||
{"message": {"content": "Hi from OpenAI"}}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
s4 = render_template_simple("[[OUT:n1.result.choices.0.message.content]]", ctx, out_map)
|
||||
assert s4 == "Hi from OpenAI"
|
||||
|
||||
# 4) [[OUT1]] short form -> best-effort text
|
||||
s5 = render_template_simple("[[OUT1]]", ctx, out_map)
|
||||
assert s5 == "Hi from OpenAI"
|
||||
|
||||
# 5) [[PROMPT]] raw fragment insertion
|
||||
ctx_prompt = {**ctx, "PROMPT": '"messages": [{"role":"user","content":"Hi"}]'}
|
||||
s6 = render_template_simple('{"model":"{{ model }}", [[PROMPT]] }', ctx_prompt, out_map)
|
||||
# ensure prompt fragment is inserted without extra quotes
|
||||
assert '"messages": [{"role":"user","content":"Hi"}]' in s6
|
||||
assert '"model":"gpt-x"' in s6
|
||||
|
||||
# 6) _best_text_from_outputs (Gemini)
|
||||
gemini_out = {
|
||||
"result": {
|
||||
"candidates": [
|
||||
{"content": {"role": "model", "parts": [{"text": "Gemini says"}]}}
|
||||
]
|
||||
}
|
||||
}
|
||||
assert _best_text_from_outputs(gemini_out) == "Gemini says"
|
||||
|
||||
# 7) _best_text_from_outputs (Claude)
|
||||
claude_out = {
|
||||
"result": {
|
||||
"content": [
|
||||
{"type": "text", "text": "Claude part 1"},
|
||||
{"type": "text", "text": "Claude part 2"},
|
||||
]
|
||||
}
|
||||
}
|
||||
assert _best_text_from_outputs(claude_out) == "Claude part 1\nClaude part 2"
|
||||
|
||||
# 8) _best_text_from_outputs (direct response_text)
|
||||
direct = {"response_text": "Direct text"}
|
||||
assert _best_text_from_outputs(direct) == "Direct text"
|
||||
|
||||
# 9) Mixed braces with OUT
|
||||
out_map = {
|
||||
"n2": {"result": {"obj": {"value": 42}}},
|
||||
}
|
||||
s7 = render_template_simple("V={{ OUT.n2.result.obj.value|default(0) }}", ctx, out_map)
|
||||
assert s7 == "V=42"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_checks()
|
||||
print("Templating tests: OK")
|
||||
Reference in New Issue
Block a user