Compare commits

..

12 Commits

81 changed files with 18893 additions and 1085 deletions

View File

@@ -0,0 +1,40 @@
{
"WAS_ERROR__n2": true,
"CYCLEINDEX__n2": 0,
"snapshot": {
"incoming": {
"method": "POST",
"url": "http://localhost/test",
"path": "/test",
"query": "",
"headers": {
"x": "X-HEADER"
},
"json": {}
},
"params": {
"temperature": 0.25
},
"model": "gpt-x",
"vendor_format": "openai",
"system": "",
"OUT": {
"n2": {
"result": {
"error": "Cancelled by user (abort)"
},
"response_text": "",
"vars": {
"WAS_ERROR__n2": true,
"CYCLEINDEX__n2": 0
}
}
},
"OUT_TEXT": {
"n2": "Cancelled by user (abort)"
},
"LAST_NODE": "n2",
"OUT2": "Cancelled by user (abort)",
"EXEC_TRACE": "n2(ProviderCall)"
}
}

View File

@@ -0,0 +1,40 @@
{
"WAS_ERROR__n2": false,
"CYCLEINDEX__n2": 0,
"snapshot": {
"incoming": {
"method": "POST",
"url": "http://localhost/test",
"path": "/test",
"query": "",
"headers": {
"x": "X-HEADER"
},
"json": {}
},
"params": {
"temperature": 0.25
},
"model": "gpt-x",
"vendor_format": "openai",
"system": "",
"OUT": {
"n2": {
"result": {
"echo": {}
},
"response_text": "",
"vars": {
"WAS_ERROR__n2": false,
"CYCLEINDEX__n2": 0
}
}
},
"OUT_TEXT": {
"n2": ""
},
"LAST_NODE": "n2",
"OUT2": "",
"EXEC_TRACE": "n2(ProviderCall)"
}
}

View File

@@ -0,0 +1,55 @@
{
"FLAG": "yes",
"snapshot": {
"incoming": null,
"params": {
"temperature": 0.1
},
"model": "gpt-x",
"vendor_format": "openai",
"system": "",
"OUT": {
"n1": {
"vars": {
"FLAG": "yes"
}
},
"nIf": {
"result": true,
"true": true,
"false": false
},
"nThen": {
"result": {
"id": "ret_mock_123",
"object": "chat.completion",
"model": "gpt-x",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "then-branch"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 1,
"total_tokens": 0
}
},
"response_text": "then-branch"
}
},
"OUT_TEXT": {
"n1": "yes",
"nIf": "",
"nThen": "then-branch"
},
"LAST_NODE": "nThen",
"OUT1": "yes",
"EXEC_TRACE": "n1(SetVars) -> If (#nIf) [[FLAG]] == 'yes' => true -> nThen(Return)"
}
}

View File

@@ -0,0 +1,3 @@
{
"MSG": "Hello"
}

View File

@@ -0,0 +1,55 @@
{
"MSG": "Hello",
"snapshot": {
"incoming": null,
"params": {
"temperature": 0.1
},
"model": "gpt-x",
"vendor_format": "openai",
"system": "",
"OUT": {
"n1": {
"vars": {
"MSG": "Hello"
}
},
"nIf": {
"result": true,
"true": true,
"false": false
},
"nRet": {
"result": {
"id": "ret_mock_123",
"object": "chat.completion",
"model": "gpt-x",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "ok"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 1,
"total_tokens": 0
}
},
"response_text": "ok"
}
},
"OUT_TEXT": {
"n1": "Hello",
"nIf": "",
"nRet": "ok"
},
"LAST_NODE": "nRet",
"OUT1": "Hello",
"EXEC_TRACE": "n1(SetVars) -> If (#nIf) [[MSG]] contains 'Hello' => true -> nRet(Return)"
}
}

View File

@@ -0,0 +1,75 @@
{
"STR": "строка",
"NUM": 42,
"OBJ": {
"x": 1,
"y": [
2,
3
]
},
"snapshot": {
"incoming": {
"method": "POST",
"url": "http://localhost/test",
"path": "/test",
"query": "",
"headers": {
"x": "X-HEADER"
},
"json": {}
},
"params": {
"temperature": 0.25
},
"model": "gpt-x",
"vendor_format": "openai",
"system": "",
"OUT": {
"n1": {
"vars": {
"STR": "строка",
"NUM": 42,
"OBJ": {
"x": 1,
"y": [
2,
3
]
}
}
},
"n2": {
"result": {
"id": "ret_mock_123",
"object": "chat.completion",
"model": "gpt-x",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "строка | 42 | {\"x\": 1, \"y\": [2, 3]}"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 9,
"total_tokens": 0
}
},
"response_text": "строка | 42 | {\"x\": 1, \"y\": [2, 3]}"
}
},
"OUT_TEXT": {
"n1": "строка",
"n2": "строка | 42 | {\"x\": 1, \"y\": [2, 3]}"
},
"LAST_NODE": "n2",
"OUT1": "строка",
"OUT2": "строка | 42 | {\"x\": 1, \"y\": [2, 3]}",
"EXEC_TRACE": "n1(SetVars) -> n2(Return)"
}
}

View File

@@ -0,0 +1,54 @@
{
"snapshot": {
"incoming": {
"method": "POST",
"url": "http://localhost/test?foo=bar",
"path": "/test",
"query": "foo=bar",
"headers": {
"authorization": "Bearer X",
"x-api-key": "Y"
},
"json": {
"a": null
}
},
"params": {
"temperature": 0.2
},
"model": "gpt-x",
"vendor_format": "openai",
"system": "",
"OUT": {
"n1": {
"result": {
"id": "ret_mock_123",
"object": "chat.completion",
"model": "gpt-x",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "auth=Bearer X | xkey=Y | num=123 | num2=456 | lit_list=[1, 2, 3] | lit_obj={\"k\": 10}"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 15,
"total_tokens": 0
}
},
"response_text": "auth=Bearer X | xkey=Y | num=123 | num2=456 | lit_list=[1, 2, 3] | lit_obj={\"k\": 10}"
}
},
"OUT_TEXT": {
"n1": "auth=Bearer X | xkey=Y | num=123 | num2=456 | lit_list=[1, 2, 3] | lit_obj={\"k\": 10}"
},
"LAST_NODE": "n1",
"OUT1": "auth=Bearer X | xkey=Y | num=123 | num2=456 | lit_list=[1, 2, 3] | lit_obj={\"k\": 10}",
"EXEC_TRACE": "n1(Return)"
}
}

View File

@@ -0,0 +1,59 @@
{
"MSG": "hello",
"snapshot": {
"incoming": {
"method": "POST",
"url": "http://localhost/test",
"path": "/test",
"query": "",
"headers": {
"x": "X-HEADER"
},
"json": {}
},
"params": {
"temperature": 0.25
},
"model": "gpt-x",
"vendor_format": "openai",
"system": "",
"OUT": {
"n1": {
"vars": {
"MSG": "hello"
}
},
"n2": {
"result": {
"id": "ret_mock_123",
"object": "chat.completion",
"model": "gpt-x",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "hello"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 1,
"total_tokens": 0
}
},
"response_text": "hello"
}
},
"OUT_TEXT": {
"n1": "hello",
"n2": "hello"
},
"LAST_NODE": "n2",
"OUT1": "hello",
"OUT2": "hello",
"EXEC_TRACE": "n1(SetVars) -> n2(Return)"
}
}

View File

@@ -0,0 +1,52 @@
{
"KEEP": "persist-me",
"snapshot": {
"incoming": {
"method": "POST",
"url": "http://localhost/test",
"path": "/test",
"query": "",
"headers": {
"x": "X-HEADER"
},
"json": {}
},
"params": {
"temperature": 0.25
},
"model": "gpt-x",
"vendor_format": "openai",
"system": "",
"OUT": {
"n1": {
"result": {
"id": "ret_mock_123",
"object": "chat.completion",
"model": "gpt-x",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "persist-me | persist-me"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 3,
"total_tokens": 0
}
},
"response_text": "persist-me | persist-me"
}
},
"OUT_TEXT": {
"n1": "persist-me | persist-me"
},
"LAST_NODE": "n1",
"OUT1": "persist-me | persist-me",
"EXEC_TRACE": "n1(Return)"
}
}

View File

@@ -0,0 +1,54 @@
{
"snapshot": {
"incoming": {
"method": "POST",
"url": "http://localhost/test",
"path": "/test",
"query": "",
"headers": {
"x": "X-HEADER"
},
"json": {}
},
"params": {
"temperature": 0.25
},
"model": "gpt-x",
"vendor_format": "openai",
"system": "",
"OUT": {
"n1": {
"result": {
"echo": {
"url": "https://api.openai.com/v1/chat/completions",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer TEST"
},
"payload": {
"model": "gpt-x",
"messages": [
{
"role": "system",
"content": "You are test"
},
{
"role": "user",
"content": "Say Привет"
}
],
"temperature": 0.25
}
}
},
"response_text": "https://api.openai.com/v1/chat/completions"
}
},
"OUT_TEXT": {
"n1": "https://api.openai.com/v1/chat/completions"
},
"LAST_NODE": "n1",
"OUT1": "https://api.openai.com/v1/chat/completions",
"EXEC_TRACE": "n1(ProviderCall)"
}
}

View File

@@ -0,0 +1,77 @@
{
"A": "foo",
"B": "bar",
"snapshot": {
"incoming": null,
"params": {
"temperature": 0.1
},
"model": "gpt-x",
"vendor_format": "openai",
"system": "",
"OUT": {
"n1": {
"vars": {
"A": "foo",
"B": "bar"
}
},
"n3": {
"result": {
"id": "ret_mock_123",
"object": "chat.completion",
"model": "gpt-x",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "bar"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 1,
"total_tokens": 0
}
},
"response_text": "bar"
},
"n2": {
"result": {
"id": "ret_mock_123",
"object": "chat.completion",
"model": "gpt-x",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "foo"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 1,
"total_tokens": 0
}
},
"response_text": "foo"
}
},
"OUT_TEXT": {
"n1": "foo",
"n3": "bar",
"n2": "foo"
},
"LAST_NODE": "n3",
"OUT1": "foo",
"OUT3": "bar",
"OUT2": "foo",
"EXEC_TRACE": "n1(SetVars) -> n3(Return) -> n2(Return)"
}
}

View File

@@ -0,0 +1,40 @@
{
"WAS_ERROR__n2": true,
"CYCLEINDEX__n2": 2,
"snapshot": {
"incoming": {
"method": "POST",
"url": "http://localhost/test",
"path": "/test",
"query": "",
"headers": {
"x": "X-HEADER"
},
"json": {}
},
"params": {
"temperature": 0.25
},
"model": "gpt-x",
"vendor_format": "openai",
"system": "",
"OUT": {
"n2": {
"result": {
"error": "Node n2 (ProviderCall) requires 'base_url' in config"
},
"response_text": "",
"vars": {
"WAS_ERROR__n2": true,
"CYCLEINDEX__n2": 2
}
}
},
"OUT_TEXT": {
"n2": "Node n2 (ProviderCall) requires 'base_url' in config"
},
"LAST_NODE": "n2",
"OUT2": "Node n2 (ProviderCall) requires 'base_url' in config",
"EXEC_TRACE": "n2(ProviderCall)"
}
}

View File

@@ -0,0 +1,48 @@
{
"MSG": "abc123xyz",
"WAS_ERROR__n2": true,
"CYCLEINDEX__n2": 1,
"snapshot": {
"incoming": {
"method": "POST",
"url": "http://localhost/test",
"path": "/test",
"query": "",
"headers": {
"x": "X-HEADER"
},
"json": {}
},
"params": {
"temperature": 0.25
},
"model": "gpt-x",
"vendor_format": "openai",
"system": "",
"OUT": {
"n1": {
"vars": {
"MSG": "abc123xyz"
}
},
"n2": {
"result": {
"error": "Node n2 (ProviderCall) requires 'base_url' in config"
},
"response_text": "",
"vars": {
"WAS_ERROR__n2": true,
"CYCLEINDEX__n2": 1
}
}
},
"OUT_TEXT": {
"n1": "abc123xyz",
"n2": "Node n2 (ProviderCall) requires 'base_url' in config"
},
"LAST_NODE": "n2",
"OUT1": "abc123xyz",
"OUT2": "Node n2 (ProviderCall) requires 'base_url' in config",
"EXEC_TRACE": "n1(SetVars) -> n2(ProviderCall)"
}
}

View File

@@ -0,0 +1,105 @@
{
"snapshot": {
"incoming": {
"method": "POST",
"url": "http://localhost/test",
"path": "/test",
"query": "",
"headers": {
"x": "X-HEADER"
},
"json": {
"messages": [
{
"role": "system",
"content": "Системный-тест CLAUDE"
},
{
"role": "user",
"content": "Прив"
},
{
"role": "assistant",
"content": "Привет!"
}
]
}
},
"params": {
"temperature": 0.25
},
"model": "gpt-x",
"vendor_format": "openai",
"system": "",
"OUT": {
"n1": {
"result": {
"echo": {
"url": "http://mock.local/v1/messages",
"headers": {
"Content-Type": "application/json"
},
"payload": {
"model": "gpt-x",
"system": [
{
"type": "text",
"text": "Ты — Narrator-chan."
},
{
"type": "text",
"text": "Системный-тест CLAUDE"
}
],
"messages": [
{
"role": "user",
"content": [
{
"type": "text",
"text": "Системный-тест CLAUDE"
}
]
},
{
"role": "user",
"content": [
{
"type": "text",
"text": "Прив"
}
]
},
{
"role": "assistant",
"content": [
{
"type": "text",
"text": "Привет!"
}
]
},
{
"role": "user",
"content": [
{
"type": "text",
"text": "как лела"
}
]
}
]
}
}
},
"response_text": "http://mock.local/v1/messages"
}
},
"OUT_TEXT": {
"n1": "http://mock.local/v1/messages"
},
"LAST_NODE": "n1",
"OUT1": "http://mock.local/v1/messages",
"EXEC_TRACE": "n1(ProviderCall)"
}
}

View File

@@ -0,0 +1,101 @@
{
"snapshot": {
"incoming": {
"method": "POST",
"url": "http://localhost/test",
"path": "/test",
"query": "",
"headers": {
"x": "X-HEADER"
},
"json": {
"messages": [
{
"role": "system",
"content": "Системный-тест из входящего"
},
{
"role": "user",
"content": "Its just me.."
},
{
"role": "assistant",
"content": "Reply from model"
}
]
}
},
"params": {
"temperature": 0.25
},
"model": "gpt-x",
"vendor_format": "openai",
"system": "",
"OUT": {
"n1": {
"result": {
"echo": {
"url": "http://mock.local/v1beta/models/gpt-x:generateContent",
"headers": {
"Content-Type": "application/json"
},
"payload": {
"model": "gpt-x",
"contents": [
{
"role": "user",
"parts": [
{
"text": "Системный-тест из входящего"
}
]
},
{
"role": "user",
"parts": [
{
"text": "Its just me.."
}
]
},
{
"role": "model",
"parts": [
{
"text": "Reply from model"
}
]
},
{
"role": "user",
"parts": [
{
"text": "как лела"
}
]
}
],
"systemInstruction": {
"parts": [
{
"text": "Ты — Narrator-chan."
},
{
"text": "Системный-тест из входящего"
}
]
}
}
}
},
"response_text": "http://mock.local/v1beta/models/gpt-x:generateContent"
}
},
"OUT_TEXT": {
"n1": "http://mock.local/v1beta/models/gpt-x:generateContent"
},
"LAST_NODE": "n1",
"OUT1": "http://mock.local/v1beta/models/gpt-x:generateContent",
"EXEC_TRACE": "n1(ProviderCall)"
}
}

View File

@@ -0,0 +1,79 @@
{
"snapshot": {
"incoming": {
"method": "POST",
"url": "http://localhost/test",
"path": "/test",
"query": "",
"headers": {
"x": "X-HEADER"
},
"json": {
"contents": [
{
"role": "user",
"parts": [
{
"text": "A"
}
]
},
{
"role": "model",
"parts": [
{
"text": "B"
}
]
}
]
}
},
"params": {
"temperature": 0.25
},
"model": "gpt-x",
"vendor_format": "gemini",
"system": "",
"OUT": {
"n1": {
"result": {
"echo": {
"url": "http://mock.local/v1/chat/completions",
"headers": {
"Content-Type": "application/json"
},
"payload": {
"model": "gpt-x",
"messages": [
{
"role": "system",
"content": "Ты — Narrator-chan."
},
{
"role": "user",
"content": "как лела"
},
{
"role": "user",
"content": "A"
},
{
"role": "assistant",
"content": "B"
}
]
}
}
},
"response_text": "http://mock.local/v1/chat/completions"
}
},
"OUT_TEXT": {
"n1": "http://mock.local/v1/chat/completions"
},
"LAST_NODE": "n1",
"OUT1": "http://mock.local/v1/chat/completions",
"EXEC_TRACE": "n1(ProviderCall)"
}
}

View File

@@ -0,0 +1,36 @@
{
"snapshot": {
"incoming": null,
"params": {
"temperature": 0.1
},
"model": "gpt-x",
"vendor_format": "openai",
"system": "",
"OUT": {
"n1": {
"result": {
"echo": {
"url": "https://api.openai.com/v1/chat/completions",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer TEST"
},
"payload": {
"model": "gpt-x",
"messages": [],
"temperature": 0.1
}
}
},
"response_text": "https://api.openai.com/v1/chat/completions"
}
},
"OUT_TEXT": {
"n1": "https://api.openai.com/v1/chat/completions"
},
"LAST_NODE": "n1",
"OUT1": "https://api.openai.com/v1/chat/completions",
"EXEC_TRACE": "n1(ProviderCall)"
}
}

View File

@@ -0,0 +1,40 @@
{
"WAS_ERROR__n1": true,
"CYCLEINDEX__n1": 1,
"snapshot": {
"incoming": {
"method": "POST",
"url": "http://example.local/test",
"path": "/test",
"query": "",
"headers": {
"content-type": "text/plain"
},
"json": "raw-plain-body-simulated"
},
"params": {
"temperature": 0.25
},
"model": "gpt-x",
"vendor_format": "openai",
"system": "",
"OUT": {
"n1": {
"result": {
"error": "Node n1 (RawForward): 'base_url' is not configured and vendor could not be detected."
},
"response_text": "",
"vars": {
"WAS_ERROR__n1": true,
"CYCLEINDEX__n1": 1
}
}
},
"OUT_TEXT": {
"n1": "Node n1 (RawForward): 'base_url' is not configured and vendor could not be detected."
},
"LAST_NODE": "n1",
"OUT1": "Node n1 (RawForward): 'base_url' is not configured and vendor could not be detected.",
"EXEC_TRACE": "n1(RawForward)"
}
}

View File

@@ -0,0 +1,7 @@
{
"Clod": "igrovik",
"MyOpenAiKey": "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",
"NAMETest": 192,
"WAS_ERROR__n3": true,
"CYCLEINDEX__n3": 0
}

View File

@@ -0,0 +1,48 @@
{
"MSG": "Hello",
"snapshot": {
"incoming": null,
"params": {},
"model": "gpt-x",
"vendor_format": "openai",
"system": "",
"OUT": {
"n1": {
"vars": {
"MSG": "Hello"
}
},
"n2": {
"result": {
"id": "ret_mock_123",
"object": "chat.completion",
"model": "gpt-x",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 1,
"total_tokens": 0
}
},
"response_text": "Hello"
}
},
"OUT_TEXT": {
"n1": "Hello",
"n2": "Hello"
},
"LAST_NODE": "n2",
"OUT1": "Hello",
"OUT2": "Hello",
"EXEC_TRACE": "n1(SetVars) -> n2(Return)"
}
}

View File

@@ -0,0 +1,53 @@
{
"MSG": "Hello world",
"snapshot": {
"incoming": null,
"params": {},
"model": "gpt-x",
"vendor_format": "openai",
"system": "",
"OUT": {
"n1": {
"vars": {
"MSG": "Hello world"
}
},
"nIf": {
"result": true,
"true": true,
"false": false
},
"nRet": {
"result": {
"id": "ret_mock_123",
"object": "chat.completion",
"model": "gpt-x",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello world ok"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 3,
"total_tokens": 0
}
},
"response_text": "Hello world ok"
}
},
"OUT_TEXT": {
"n1": "Hello world",
"nIf": "",
"nRet": "Hello world ok"
},
"LAST_NODE": "nRet",
"OUT1": "Hello world",
"EXEC_TRACE": "n1(SetVars) -> If (#nIf) [[MSG]] contains \"Hello\" => true -> nRet(Return)"
}
}

View File

@@ -0,0 +1,56 @@
{
"MSG": "Hello OUT",
"X_MSG": "Hello OUT",
"snapshot": {
"incoming": null,
"params": {},
"model": "gpt-x",
"vendor_format": "openai",
"system": "",
"OUT": {
"n1": {
"vars": {
"MSG": "Hello OUT"
}
},
"n2": {
"vars": {
"X_MSG": "Hello OUT"
}
},
"n3": {
"result": {
"id": "ret_mock_123",
"object": "chat.completion",
"model": "gpt-x",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello OUT"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 2,
"total_tokens": 0
}
},
"response_text": "Hello OUT"
}
},
"OUT_TEXT": {
"n1": "Hello OUT",
"n2": "Hello OUT",
"n3": "Hello OUT"
},
"LAST_NODE": "n3",
"OUT1": "Hello OUT",
"OUT2": "Hello OUT",
"OUT3": "Hello OUT",
"EXEC_TRACE": "n1(SetVars) -> n2(Probe) -> n3(Return)"
}
}

40
.gitattributes vendored Normal file
View File

@@ -0,0 +1,40 @@
# Normalize text files by default
* text=auto
# Force LF for source/config
*.sh text eol=lf
*.py text eol=lf
*.js text eol=lf
*.ts text eol=lf
*.jsx text eol=lf
*.tsx text eol=lf
*.css text eol=lf
*.scss text eol=lf
*.html text eol=lf
*.json text eol=lf
*.md text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.toml text eol=lf
*.ini text eol=lf
*.cfg text eol=lf
*.txt text eol=lf
# Force CRLF for Windows scripts
*.bat text eol=crlf
*.cmd text eol=crlf
*.ps1 text eol=crlf
# Treat binaries as binary (no EOL conversions)
*.png -text
*.jpg -text
*.jpeg -text
*.gif -text
*.webp -text
*.pdf -text
*.ico -text
*.woff -text
*.woff2 -text
*.eot -text
*.ttf -text
*.otf -text

9
.gitignore vendored
View File

@@ -41,18 +41,13 @@ Thumbs.db
*.log
agentui.log
# proxy
proxy.txt
# Local config
.env
.env.*
*.env
# Project-specific runtime files
presets/
pipeline.json
#
# Node
node_modules/

View File

@@ -1,80 +1,200 @@
# AgentUI Project Overview
# НадTavern (AgentUI): объясняем просто
## Цель проекта
AgentUI — это прокси‑сервер с визуальным редактором пайплайнов (на базе Drawflow), который нормализует запросы от различных клиентов в единый формат и выполняет их через цепочку узлов (nodes). Это позволяет гибко собирать пайплайны обработки текстовых/LLMзапросов без необходимости вручную интегрировать каждый провайдер.
Это инструмент, который помогает слать запросы к ИИ (OpenAI, Gemini, Claude) через простой конвейер из блоков (узлов). Вы кликаете мышкой, соединяете блоки — и получаете ответ в том же формате, в каком спрашивали.
Коротко: было сложно — стало просто.
---
## Основные компоненты
Что вы можете сделать
### Фронтенд: Визуальный редактор
- Построен на **Drawflow**.
- Поддерживает узлы, входы/выходы и соединения.
- Реализована надёжная сериализация/десериализация:
- `toPipelineJSON()` сохраняет структуру + все соединения.
- `fromPipelineJSON()` восстанавливает узлы и соединения с учётом времени рендера DOM (retryлогика).
- Исправлены баги исчезающих соединений.
- В инспекторе узлов отображается оригинальный ID узла, а не runtime ID от Drawflow.
- UI подсказки: макрохинты в синтаксисе `[[...]]` (например `[[VAR:system.prompt]]`, `[[OUT:node1.text]]`).
### Бэкенд: Исполнение пайплайна
- Основной код: [`agentui/pipeline/executor.py`](agentui/pipeline/executor.py).
- Выполняется **топологическая сортировка** графа для правильного порядка исполнения и предотвращения циклов.
- Узлы:
- **RawForwardNode**:
- Прямой HTTPфорвардинг с макросами в `base_url`, `override_path`, `headers`.
- Автоопределение провайдера.
- **ProviderCallNode**:
- Унифицированный вызов LLMпровайдеров.
- Преобразует внутренний формат сообщений в специфический формат для OpenAI, Gemini, Anthropic.
- Поддерживает параметры `temperature`, `max_tokens`, `top_p`, `stop` (или аналоги).
- Поддержка **макросов**:
- `{{ path }}` — Jinjaподобный.
- `[[VAR:...]]` — доступ к данным контекста (system, chat, params).
- `[[OUT:nodeId(.attr)]]` — ссылки на вывод других узлов.
- `{{ OUT.node.* }}` — альтернативная форма.
### API сервер (`agentui/api/server.py`)
- Нормализует запросы под форматы `/v1/chat/completions`, Gemini, Anthropic.
- Формирует контекст макросов (vendor, model, params, incoming).
- Принять запрос от клиента как есть (OpenAI/Gemini/Claude).
- При желании подменить его или дополнить.
- Отправить к нужному провайдеру (или прямо «пробросить» как есть).
- Склеить финальный ответ в том формате, который ждёт клиент.
---
## Текущий прогресс
- Исправлены баги сериализации соединений во фронтенде.
- Добавлены подсказки по макросам.
- Реализована топологическая сортировка исполнения.
- Создан универсальный рендер макросов `render_template_simple`.
- Интегрирован RawForward с макроподстановкой.
- ProviderCall теперь преобразует сообщения под формат конкретного провайдера.
Как это работает в 5 шагов
1) Клиент шлёт HTTPзапрос на ваш сервер.
2) Сервер понимает формат (OpenAI/Gemini/Claude) и делает «унифицированный» вид.
3) Загружается ваш пайплайн (схема из узлов) из файла pipeline.json.
4) Узлы запускаются по очереди «волнами» и обмениваются результатами.
5) Последний узел отдаёт ответ клиенту в нужном формате.
Если страшно — не бойтесь: всё это уже настроено из коробки.
---
## Текущая задача (для нового разработчика)
Быстрый старт
В проекте мы начинаем реализацию **Prompt Manager**, который станет частью узла `ProviderCall`.
Вариант A (Windows, авто‑настройка .venv):
- Запустите [run_agentui.bat](run_agentui.bat) двойным кликом или из консоли.
- Скрипт сам:
- создаст локальное окружение .venv в каталоге проекта;
- обновит pip;
- установит зависимости из [requirements.txt](requirements.txt);
- поднимет сервер и откроет редактор в браузере.
- Переменные окружения (опционально перед запуском): HOST=127.0.0.1 PORT=7860
**Что уже решено:**
- Архитектура пайплайна, сериализация/десериализация, макросная система, базовые конвертеры форматов.
Вариант B (Linux/macOS, авто‑настройка .venv):
- Сделайте исполняемым и запустите:
- chmod +x [run_agentui.sh](run_agentui.sh)
- ./run_agentui.sh
- Скрипт сделает то же самое: .venv + установка зависимостей + старт сервера.
**Что нужно сделать:**
- [ ] Спроектировать структуру promptменеджера: массив блоков `{ name, role, prompt, enabled, order }`.
- [ ] Добавить универсальный рендер макросов, который применяется ко всем блокам перед конвертацией.
- [ ] Доработать конвертеры форматов под OpenAI, Gemini, Anthropic, чтобы они учитывали эти блоки.
- [ ] Интегрировать promptменеджер в `ProviderCallNode`:
- Сборка последовательности сообщений.
- Подстановка макросов.
- Конвертация в провайдерский формат.
- [ ] Реализовать UI promptменеджера во фронтенде:
- CRUD операций над блоками.
- Drag&Drop сортировку.
- Возможность включать/выключать блок.
- Выбор роли (`user`, `system`, `assistant`, `tool`).
Вариант C (ручной запуск, если хотите контролировать шаги):
- Установите Python 3.10+.
- Создайте и активируйте .venv:
- Windows (cmd): py -m venv .venv && .\.venv\Scripts\activate
- Linux/macOS (bash): python3 -m venv .venv && source .venv/bin/activate
- Установите зависимости и стартуйте сервер:
- pip install -r [requirements.txt](requirements.txt)
- python -m uvicorn agentui.api.server:app --host 127.0.0.1 --port 7860
Откройте в браузере:
- http://127.0.0.1:7860/ui/editor.html — визуальный редактор узлов
- http://127.0.0.1:7860/ui/pipeline.html — редактор «сырых» JSON настроек пайплайна
- http://127.0.0.1:7860/ — простая страница с примером запроса
---
## Важные файлы
- [`static/editor.html`](static/editor.html) — визуальный редактор пайплайнов.
- [`agentui/pipeline/executor.py`](agentui/pipeline/executor.py) — логика исполнения пайплайнов, макросов и узлов.
- [`agentui/api/server.py`](agentui/api/server.py) — REST API для внешних клиентов.
- [`pipeline.json`](pipeline.json) — сохранённый пайплайн по умолчанию.
Где лежат важные файлы
- API сервер: [`agentui/api/server.py`](agentui/api/server.py)
- Исполнитель узлов: [`agentui/pipeline/executor.py`](agentui/pipeline/executor.py)
- Шаблоны и макросы: [`agentui/pipeline/templating.py`](agentui/pipeline/templating.py)
- Определение провайдера (OpenAI/Gemini/Claude): [`agentui/common/vendors.py`](agentui/common/vendors.py)
- Активный пайплайн: [`pipeline.json`](pipeline.json)
- Пресеты (готовые пайплайны): папка [`presets`](presets/)
- Визуальный редактор (страница): [`static/editor.html`](static/editor.html)
- Логика сериализации/соединений: [`static/js/serialization.js`](static/js/serialization.js)
- Настройка Prompt Manager UI: [`static/js/pm-ui.js`](static/js/pm-ui.js)
- Справка по переменным: [`docs/VARIABLES.md`](docs/VARIABLES.md)
---
Что такое «узлы»
Представьте конструктор Лего. Узел — это кубик. Соединяете кубики — получаете конвейер (пайплайн).
В проекте есть 4 базовых узла:
1) SetVars — завести свои переменные.
- Пример: MY_KEY, REGION, MAX_TOKENS.
- Потом в шаблонах вы можете писать [[MY_KEY]] или {{ MAX_TOKENS }}.
2) RawForward — «пробросить» входящий запрос как есть дальше (reverseproxy).
- Полезно, когда вы не хотите ничего менять.
3) ProviderCall — аккуратно собрать JSON для провайдера (OpenAI/Gemini/Claude) из «кусочков текста» (Prompt Blocks) и отправить.
- Это удобно, если вы хотите дописать системное сообщение, переписать текст пользователя и т.п.
4) Return — оформить финальный ответ под тот формат, который ждёт клиент.
- Если клиент прислал в стиле OpenAI, вернём в стиле OpenAI; можно принудительно выбрать формат.
Узлы соединяются линиями «из выхода в вход». Так вы задаёте порядок.
---
Простые «заклинания» (макросы), которые работают в шаблонах
- [[VAR:путь]] — взять значение из входящего запроса.
- Например: [[VAR:incoming.headers.authorization]]
- [[OUT:n1.что‑то]] — взять кусочек результата из узла n1.
- [[OUT1]] — взять «самый понятный текст» из узла n1 (короткая форма).
- [[PROMPT]] — умный фрагмент JSON, который автоматически соберётся из ваших Prompt Blocks для выбранного провайдера.
- {{ путь }} — вставка без кавычек (подходит для чисел/массивов/объектов).
- {{ путь|default(значение) }} — вставка с безопасным дефолтом.
Пример 1 (OpenAI, фрагмент шаблона тела запроса):
{
"model": "{{ model }}",
[[PROMPT]],
"temperature": {{ incoming.json.temperature|default(0.7) }}
}
Пример 2 (вставим токен из заголовка в заголовки запроса):
{"Authorization":"Bearer [[VAR:incoming.headers.authorization]]"}
Подробная шпаргалка — в файле [`docs/VARIABLES.md`](docs/VARIABLES.md).
---
Первый рабочий пайплайн за 3 минуты
Цель: взять сообщение пользователя, немного «пригладить» его и вернуть ответ в стиле OpenAI.
1) Добавьте узел SetVars (можно пропустить).
2) Добавьте узел ProviderCall.
- В инспекторе выберите провайдера (например, openai).
- В Prompt Blocks создайте блоки:
- system: «Ты — помощник. Отвечай коротко.»
- user: «[[VAR:chat.last_user]] — перепиши текст красивее.»
- В шаблоне тела (template) не трогайте структуру — там уже есть [[PROMPT]].
3) Добавьте узел Return.
- Оставьте «auto», чтобы формат ответа совпал с входящим.
4) Соедините ProviderCall → Return.
5) Нажмите «Сохранить пайплайн».
6) Отправьте запрос на POST /v1/chat/completions (можно со страницы `/`).
Если что‑то не работает — смотрите журнал (консоль) и всплывающие подсказки в UI.
---
Полезные адреса (админка)
- GET /admin/pipeline — получить текущий пайплайн.
- POST /admin/pipeline — сохранить пайплайн.
- GET /admin/presets — список пресетов.
- GET/POST /admin/presets/{name} — загрузить/сохранить пресет.
- GET /admin/trace/stream — «живой» поток событий исполнения. Редактор подсвечивает узлы (начал/успешно/ошибка).
Это уже настроено в [`agentui/api/server.py`](agentui/api/server.py).
---
Где смотреть логи
- Сервер пишет в консоль шаги узлов и ответы провайдеров.
- В UI редакторе видно подсветку состояний узлов (события из /admin/trace/stream).
- При необходимости включите/отключите подробность логов в коде узлов [`agentui/pipeline/executor.py`](agentui/pipeline/executor.py).
---
Важная безопасность (прочитайте!)
- Никогда не храните реальные ключи в файлах в репозитории (pipeline.json, пресеты).
- Передавайте ключи через заголовки запроса клиента:
- OpenAI: Authorization: Bearer XXXXXX
- Anthropic: x-api-key: XXXXXX
- Gemini: ?key=XXXXXX в URL
- В шаблонах используйте подстановки из входящего запроса: [[VAR:incoming.headers.authorization]], [[VAR:incoming.headers.x-api-key]], [[VAR:incoming.api_keys.key]].
- В логах при разработке печатаются заголовки и тело. Для продакшена выключайте или маскируйте их.
---
Частые ошибки и быстрые решения
- «Соединения между узлами пропадают»
- Нажмите «Загрузить пайплайн» ещё раз: редактор терпеливо дождётся DOM и восстановит линии.
- «Шаблон JSON ругается на запятые/скобки»
- Убедитесь, что [[PROMPT]] стоит без лишних запятых вокруг; числа/массивы вставляйте через {{ ... }}.
- «Нет ответа от провайдера»
- Проверьте ключ и URL/endpoint в конфигурации узла ProviderCall (инспектор справа).
- «Нужен просто прокси без изменений»
- Используйте узел RawForward первым, а потом Return.
---
Для любопытных (необязательно)
Код устроен так:
- Сервер создаётся здесь: [`agentui/api/server.py`](agentui/api/server.py)
- Исполнение узлов и «волны» — здесь: [`PipelineExecutor.run()`](agentui/pipeline/executor.py:136)
- Провайдерный вызов и Prompt Blocks — здесь: [`ProviderCallNode.run()`](agentui/pipeline/executor.py:650)
- Простой шаблонизатор (две скобки): [`render_template_simple()`](agentui/pipeline/templating.py:187)
Этого достаточно, чтобы понимать, куда заглянуть, если захотите кое‑что подкрутить.
Удачи! Запускайте редактор, соединяйте узлы и получайте ответы без боли.

File diff suppressed because it is too large Load Diff

50
agentui/common/cancel.py Normal file
View File

@@ -0,0 +1,50 @@
from __future__ import annotations
from typing import Dict
import threading
# Simple in-process cancel flags storage (per pipeline_id)
# Thread-safe for FastAPI workers in same process
_cancel_flags: Dict[str, bool] = {}
# Mode of cancellation per pipeline: "graceful" (default) or "abort"
_cancel_modes: Dict[str, str] = {}
_lock = threading.Lock()
def request_cancel(pipeline_id: str, mode: str = "graceful") -> None:
"""Set cancel flag for given pipeline id with an optional mode.
mode:
- "graceful": do not interrupt in-flight operations, stop before next step
- "abort": attempt to cancel in-flight operations immediately
"""
pid = str(pipeline_id or "pipeline_editor")
m = str(mode or "graceful").lower().strip()
if m not in {"graceful", "abort"}:
m = "graceful"
with _lock:
_cancel_flags[pid] = True
_cancel_modes[pid] = m
def clear_cancel(pipeline_id: str) -> None:
"""Clear cancel flag for given pipeline id."""
pid = str(pipeline_id or "pipeline_editor")
with _lock:
_cancel_flags.pop(pid, None)
_cancel_modes.pop(pid, None)
def is_cancelled(pipeline_id: str) -> bool:
"""Check cancel flag for given pipeline id."""
pid = str(pipeline_id or "pipeline_editor")
with _lock:
return bool(_cancel_flags.get(pid, False))
def get_cancel_mode(pipeline_id: str) -> str:
"""Return current cancel mode for given pipeline id: 'graceful' or 'abort' (default graceful)."""
pid = str(pipeline_id or "pipeline_editor")
with _lock:
m = _cancel_modes.get(pid)
return m if m in {"graceful", "abort"} else "graceful"

View File

@@ -0,0 +1,415 @@
from __future__ import annotations
import json
import re
from typing import Any, Dict, Optional, Tuple
# Reuse executor's registry for original (untrimmed) requests
try:
from agentui.pipeline.executor import register_http_request as _reg_http_req # type: ignore
except Exception: # pragma: no cover
_reg_http_req = None # type: ignore
# -------- HTTP editable text parser (safe) --------
def parse_editable_http(s: str) -> Tuple[str, str, Dict[str, str], str]:
"""
Parse text pasted from Request area into (method, url, headers, body_text).
Stops header parsing when a line is not a valid HTTP header key (prevents treating JSON like '"contents": ...' as header).
"""
method, url = "POST", ""
headers: Dict[str, str] = {}
body = ""
try:
if not isinstance(s, str) or not s.strip():
return method, url, headers, body
txt = s.replace("\r\n", "\n")
lines = txt.split("\n")
if not lines:
return method, url, headers, body
first = (lines[0] or "").strip()
m = re.match(r"^([A-Z]+)\s+(\S+)(?:\s+HTTP/\d+(?:\.\d+)?)?$", first)
i = 1
if m:
method = (m.group(1) or "POST").strip().upper()
url = (m.group(2) or "").strip()
else:
i = 0 # no start-line -> treat as headers/body only
def _is_header_line(ln: str) -> bool:
if ":" not in ln:
return False
name = ln.split(":", 1)[0].strip()
# HTTP token: only letters/digits/hyphen. Prevents JSON keys like "contents": from being treated as headers.
return bool(re.fullmatch(r"[A-Za-z0-9\-]+", name))
# Read headers until blank line OR until line not looking like header (start of body)
while i < len(lines):
ln = lines[i]
if ln.strip() == "":
i += 1
break
if not _is_header_line(ln):
break
k, v = ln.split(":", 1)
headers[str(k).strip()] = str(v).strip()
i += 1
# Remainder is body (JSON or text)
body = "\n".join(lines[i:]) if i < len(lines) else ""
except Exception:
pass
return method, url, headers, body
# -------- Headers helpers --------
def dedupe_headers(h: Dict[str, Any]) -> Dict[str, Any]:
"""
Case-insensitive dedupe; drop Host/Content-Length (httpx will set proper).
Last value wins.
"""
try:
dedup: Dict[str, Tuple[str, Any]] = {}
for k, v in (h or {}).items():
lk = str(k).strip().lower()
if lk in {"host", "content-length"}:
continue
dedup[lk] = (k, v)
return {orig_k: val for (_, (orig_k, val)) in dedup.items()}
except Exception:
return dict(h or {})
def content_type_is_json(h: Dict[str, Any]) -> bool:
try:
return any(str(k).lower() == "content-type" and "json" in str(v).lower() for k, v in (h or {}).items())
except Exception:
return False
# -------- JSON parsing & normalization helpers --------
def try_parse_json(s: Any) -> Optional[Any]:
try:
if isinstance(s, (dict, list)):
return s
if isinstance(s, str) and s.strip():
return json.loads(s)
except Exception:
return None
return None
def normalize_jsonish_text(s: Any) -> str:
"""
Normalize JSON-looking text safely:
- If whole text is a quoted JSON string, decode via json.loads to inner string.
- Replace visible \\n/\\r/\\t outside JSON string literals with real control chars.
- Escape raw CR/LF/TAB inside JSON string literals as \\n/\\r/\\t to keep JSON valid.
"""
try:
txt = str(s if s is not None else "")
except Exception:
return ""
# If whole text looks like a quoted JSON string: decode to inner string
try:
if len(txt) >= 2 and txt[0] == '"' and txt[-1] == '"':
v = json.loads(txt)
if isinstance(v, str):
txt = v
except Exception:
pass
out_chars = []
i = 0
n = len(txt)
in_str = False
esc = False
while i < n:
ch = txt[i]
if in_str:
# escape raw control chars within JSON string literal
if ch == "\r":
# CRLF -> \n
if (i + 1) < n and txt[i + 1] == "\n":
out_chars.append("\\n")
i += 2
esc = False
continue
out_chars.append("\\r")
i += 1
esc = False
continue
if ch == "\n":
out_chars.append("\\n")
i += 1
esc = False
continue
if ch == "\t":
out_chars.append("\\t")
i += 1
esc = False
continue
out_chars.append(ch)
if esc:
esc = False
else:
if ch == "\\":
esc = True
elif ch == '"':
in_str = False
i += 1
continue
# not in string literal
if ch == '"':
in_str = True
out_chars.append(ch)
i += 1
continue
if ch == "\\" and (i + 1) < n:
nx = txt[i + 1]
if nx == "n":
out_chars.append("\n")
i += 2
continue
if nx == "r":
out_chars.append("\r")
i += 2
continue
if nx == "t":
out_chars.append("\t")
i += 2
continue
out_chars.append(ch)
i += 1
return "".join(out_chars)
def extract_json_trailing(s: str) -> Optional[Any]:
"""
Pull trailing JSON object/array from mixed text:
- Try whole text first
- Then scan from last '{' or '[' backward.
"""
try:
if not isinstance(s, str):
return None
txt = s.strip()
try:
return json.loads(txt)
except Exception:
pass
idx = txt.rfind("{")
while idx >= 0:
seg = txt[idx:]
try:
return json.loads(seg)
except Exception:
idx = txt.rfind("{", 0, idx)
idx = txt.rfind("[")
while idx >= 0:
seg = txt[idx:]
try:
return json.loads(seg)
except Exception:
idx = txt.rfind("[", 0, idx)
return None
except Exception:
return None
def global_unescape_jsonish(s: str) -> str:
"""
Last-resort: unicode_escape decode to convert \\n -> \n, \\" -> ", \\\\ -> \, \\uXXXX -> char, etc.
"""
try:
import codecs as _codecs
return _codecs.decode(s, "unicode_escape")
except Exception:
try:
return (
s.replace("\\n", "\n")
.replace("\\r", "\r")
.replace("\\t", "\t")
.replace('\\"', '"')
.replace("\\\\", "\\")
)
except Exception:
return s
def looks_jsonish(txt: Any) -> bool:
try:
s = str(txt or "")
if "{" in s or "[" in s:
return True
# also patterns like key:
return bool(re.search(r'\s["\']?[A-Za-z0-9_\-]+["\']?\s*:', s))
except Exception:
return False
def deep_merge_dicts(a: Any, b: Any) -> Any:
"""
Merge dicts (b over a, recursively). Lists or non-dicts are replaced by b.
"""
if isinstance(a, dict) and isinstance(b, dict):
out = dict(a)
for k, v in b.items():
if (k in a) and isinstance(a.get(k), dict) and isinstance(v, dict):
out[k] = deep_merge_dicts(a.get(k), v)
else:
out[k] = v
return out
return b
# ---- Trim-aware merge that preserves original binary/base64 fields ----
def is_trimmed_b64_string(s: Any) -> bool:
try:
if not isinstance(s, str):
return False
return "(trimmed " in s
except Exception:
return False
def looks_base64ish(s: Any) -> bool:
try:
if not isinstance(s, str) or len(s) < 64:
return False
return bool(re.fullmatch(r"[A-Za-z0-9+/=\r\n]+", s))
except Exception:
return False
def merge_lists_preserving_b64(orig_list: Any, edited_list: Any) -> Any:
"""
Merge lists with base64-trimmed preservation but DO NOT pad from original:
- Result length equals edited_list length (indices beyond edited are dropped).
- At each index:
* If edited value is a trimmed placeholder string and original has a string → keep original.
* If both dicts → recurse via deep_merge_preserving_b64.
* If both lists → recurse via merge_lists_preserving_b64.
* Else → take edited value as-is.
"""
if not isinstance(edited_list, list):
return edited_list
if not isinstance(orig_list, list):
orig_list = []
out = []
for i, ev in enumerate(edited_list):
ov = orig_list[i] if i < len(orig_list) else None
if isinstance(ev, str) and is_trimmed_b64_string(ev) and isinstance(ov, str):
out.append(ov)
elif isinstance(ev, dict) and isinstance(ov, dict):
out.append(deep_merge_preserving_b64(ov, ev))
elif isinstance(ev, list) and isinstance(ov, list):
out.append(merge_lists_preserving_b64(ov, ev))
else:
out.append(ev)
return out
def deep_merge_preserving_b64(orig: Any, edited: Any) -> Any:
"""
Merge preserving original base64/data_url only for trimmed placeholders, with strict edited-shape:
- If edited is a trimmed placeholder string and orig is a string → keep orig.
- Dicts: RESULT CONTAINS ONLY KEYS FROM EDITED. Keys missing in edited are treated as deleted.
For each present key: recurse (dict/list) or take edited value; for trimmed strings keep orig.
- Lists: delegate to merge_lists_preserving_b64 (result length = edited length).
- Other types: replace with edited.
"""
if isinstance(edited, str) and is_trimmed_b64_string(edited) and isinstance(orig, str):
return orig
if isinstance(orig, dict) and isinstance(edited, dict):
out: Dict[str, Any] = {}
for k, ev in edited.items():
ov = orig.get(k)
if isinstance(ev, str) and is_trimmed_b64_string(ev) and isinstance(ov, str):
out[k] = ov
elif isinstance(ev, dict) and isinstance(ov, dict):
out[k] = deep_merge_preserving_b64(ov, ev)
elif isinstance(ev, list) and isinstance(ov, list):
out[k] = merge_lists_preserving_b64(ov, ev)
else:
out[k] = ev
return out
if isinstance(orig, list) and isinstance(edited, list):
return merge_lists_preserving_b64(orig, edited)
return edited
def salvage_json_for_send(
edited_body_text: Any,
headers: Dict[str, Any],
orig_json: Optional[Any],
prefer_registry_original: bool = True,
) -> Tuple[Optional[Any], Optional[str]]:
"""
Build (final_json, final_text) for outgoing request body.
Strategy:
- Normalize text for JSON.
- Try parse; then try trailing extract; then unicode_escape unescape and retry.
- If prefer_registry_original=True and orig_json present:
* If edited_json present: deep-merge with base64 preservation, but ONLY keep keys present in edited;
lists are limited to the edited length (no padding from original).
* If not: DO NOT resurrect original. Empty/whitespace → send empty text; otherwise send raw text as-is.
- Else:
* If edited_json present => final_json = edited_json
* Else: if content-type is json and orig_json present => final_json = orig_json
else send raw text.
"""
# Normalize and attempt parse
norm = normalize_jsonish_text(edited_body_text)
edited_json = try_parse_json(norm)
if edited_json is None:
edited_json = extract_json_trailing(norm)
if edited_json is None:
ue = global_unescape_jsonish(str(edited_body_text or ""))
if isinstance(ue, str) and ue != edited_body_text:
ue_norm = normalize_jsonish_text(ue)
edited_json = try_parse_json(ue_norm) or extract_json_trailing(ue_norm)
json_ct = content_type_is_json(headers)
# Prefer original registry JSON where applicable
if prefer_registry_original and orig_json is not None:
if edited_json is None:
# Respect full manual control: do NOT resurrect original JSON.
# Empty/whitespace → send empty text; otherwise send raw text as-is.
if isinstance(norm, str) and not norm.strip():
return None, ""
else:
return None, str(edited_body_text or "")
else:
# Merge edits over original with trimmed-b64 preservation, but keep only keys present in edited
# and limit lists to the edited length.
return deep_merge_preserving_b64(orig_json, edited_json), None
# No prefer or no orig_json
if edited_json is not None:
return edited_json, None
if json_ct and orig_json is not None:
# Hard salvage for declared JSON payloads
maybe = try_parse_json(norm) or extract_json_trailing(norm)
return (maybe if maybe is not None else orig_json), None
# Plain text fallback
return None, str(edited_body_text or "")
# -------- Registry wrapper --------
def register_manual_request(req_id: str, info: Dict[str, Any]) -> None:
try:
if _reg_http_req:
_reg_http_req(req_id, info)
except Exception:
pass

View File

@@ -1,6 +1,7 @@
from typing import Dict, Optional
from typing import Dict, Optional, Union
from pathlib import Path
from urllib.parse import quote
import os
def _parse_proxy_line(line: str) -> Optional[str]:
@@ -39,6 +40,9 @@ def _read_proxy_from_file() -> Optional[str]:
line = raw.strip()
if not line or line.startswith("#"):
continue
# поддержим дополнительные ключи вида key=value в этом же файле (разберём ниже)
if "=" in line:
continue
url = _parse_proxy_line(line)
if url:
return url
@@ -59,3 +63,136 @@ def build_httpx_proxies() -> Optional[Dict[str, str]]:
}
def _read_kv_from_proxy_file() -> Dict[str, str]:
"""
Поддержка дополнительных опций в proxy.txt:
ca=/полный/путь/к/burp-ca.pem
verify=false # отключить проверку сертификатов (для отладки)
"""
out: Dict[str, str] = {}
p = Path("proxy.txt")
if not p.exists():
return out
try:
for raw in p.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
k, v = line.split("=", 1)
out[k.strip().lower()] = v.strip()
except Exception:
return out
return out
def _read_second_bare_flag_from_proxy() -> Optional[bool]:
"""
Читает «вторую голую строку» после URL в proxy.txt и интерпретирует как флаг verify:
true/1/yes/on -> True
false/0/no/off -> False
Возвращает None, если строка отсутствует или не распознана.
"""
try:
p = Path("proxy.txt")
if not p.exists():
return None
lines = [ln.strip() for ln in p.read_text(encoding="utf-8").splitlines()]
# найдём первую «URL» строку (без '=' и не пустую/коммент)
idx_url = -1
for i, ln in enumerate(lines):
if not ln or ln.startswith("#") or "=" in ln:
continue
idx_url = i
break
if idx_url >= 0:
# ищем следующую «голую» строку
for j in range(idx_url + 1, len(lines)):
ln = lines[j].strip()
if not ln or ln.startswith("#") or "=" in ln:
continue
low = ln.lower()
if low in ("1", "true", "yes", "on"):
return True
if low in ("0", "false", "no", "off"):
return False
# если это не похожее на флаг — считаем отсутствующим
break
except Exception:
return None
return None
def get_tls_verify() -> Union[bool, str]:
"""
Возвращает значение для параметра httpx.AsyncClient(verify=...):
- путь к PEM-бандлу (строка), если нашли ca=... или файл proxy-ca.pem в корне
- False, если verify=false/insecure=1/AGENTUI_VERIFY=false
- True по умолчанию
- Новое: можно задать флаг второй «голой» строкой в proxy.txt (после URL прокси):
пример:
http:127.0.0.1:8888
false
или
http:127.0.0.1:8888
true
"""
# 1) Переменные окружения имеют приоритет
env_verify = os.getenv("AGENTUI_VERIFY")
if env_verify is not None and env_verify.strip().lower() in ("0", "false", "no", "off"):
return False
env_ca = os.getenv("AGENTUI_CA")
if env_ca:
path = Path(env_ca).expanduser()
if path.exists():
return str(path)
# 2) proxy.txt ключи
kv = _read_kv_from_proxy_file()
if kv.get("verify", "").lower() in ("0", "false", "no", "off"):
return False
if "ca" in kv:
path = Path(kv["ca"]).expanduser()
if path.exists():
return str(path)
# 2.1) Дополнительно: поддержка второй строки без ключа — true/false
second = _read_second_bare_flag_from_proxy()
if second is True:
return True
if second is False:
return False
# 3) Файл по умолчанию в корне проекта
default_ca = Path("proxy-ca.pem")
if default_ca.exists():
return str(default_ca)
# 4) По умолчанию строгая проверка
return True
def is_verify_explicit() -> bool:
"""
Возвращает True, если пользователь ЯВНО задал политику проверки TLS,
чтобы клиент не переопределял её значением по умолчанию.
Учитываются:
- переменные окружения: AGENTUI_VERIFY, AGENTUI_CA
- ключи в proxy.txt: verify=..., ca=...
- файл proxy-ca.pem в корне проекта
- Новое: «вторая голая строка» после URL в proxy.txt со значением true/false
"""
if os.getenv("AGENTUI_VERIFY") is not None:
return True
if os.getenv("AGENTUI_CA"):
return True
kv = _read_kv_from_proxy_file()
if "verify" in kv or "ca" in kv:
return True
# Вторая «голая» строка как явный флаг
second = _read_second_bare_flag_from_proxy()
if second is not None:
return True
if Path("proxy-ca.pem").exists():
return True
return False

View File

@@ -4,10 +4,18 @@ from typing import Any, Dict
def default_pipeline() -> Dict[str, Any]:
# Минимальный дефолт без устаревших нод.
# Если пайплайн пустой, сервер вернёт echo-ответ (см. server.execute_pipeline_echo).
# Добавлены поля управления режимом исполнения:
# - loop_mode: "dag" | "iterative" (по умолчанию "dag")
# - loop_max_iters: максимальное число запусков задач (safety)
# - loop_time_budget_ms: ограничение по времени (safety)
return {
"id": "pipeline_default",
"name": "Default Chat Pipeline",
"parallel_limit": 8,
"loop_mode": "dag",
"loop_max_iters": 1000,
"loop_time_budget_ms": 10000,
"clear_var_store": True,
"nodes": []
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,19 +8,96 @@ from agentui.pipeline.defaults import default_pipeline
PIPELINE_FILE = Path("pipeline.json")
PRESETS_DIR = Path("presets")
VARS_DIR = Path(".agentui") / "vars"
# DRY нормализация meta/пайплайна: единый источник дефолтов и типов
def normalize_pipeline(pipeline: Dict[str, Any]) -> Dict[str, Any]:
"""
Приводит верхнеуровневые ключи пайплайна к согласованному виду, заполняет дефолты.
Безопасно к отсутствующим ключам и неверным типам.
"""
if not isinstance(pipeline, dict):
pipeline = {}
out: Dict[str, Any] = dict(pipeline)
def _to_int(v, d):
try:
n = int(v)
return n if n > 0 else d
except Exception:
return d
def _to_float(v, d):
try:
n = float(v)
return n if n > 0 else d
except Exception:
return d
# Базовые поля
out["id"] = str(out.get("id") or "pipeline_editor")
out["name"] = str(out.get("name") or "Edited Pipeline")
out["parallel_limit"] = _to_int(out.get("parallel_limit"), 8)
out["loop_mode"] = str(out.get("loop_mode") or "dag")
out["loop_max_iters"] = _to_int(out.get("loop_max_iters"), 1000)
out["loop_time_budget_ms"] = _to_int(out.get("loop_time_budget_ms"), 10000)
out["clear_var_store"] = bool(out.get("clear_var_store", True))
out["http_timeout_sec"] = _to_float(out.get("http_timeout_sec"), 60)
# Глобальные опции извлечения текста для [[OUTx]]
out["text_extract_strategy"] = str(out.get("text_extract_strategy") or "auto")
out["text_extract_json_path"] = str(out.get("text_extract_json_path") or "")
# Поддержка разных написаний text_join_sep
join_sep = out.get("text_join_sep")
if join_sep is None:
for k in list(out.keys()):
if isinstance(k, str) and k.lower() == "text_join_sep":
join_sep = out.get(k)
break
out["text_join_sep"] = str(join_sep or "\n")
# Пресеты парсинга
presets = out.get("text_extract_presets")
norm_presets: List[Dict[str, Any]] = []
if isinstance(presets, list):
for i, it in enumerate(presets):
if not isinstance(it, dict):
continue
norm_presets.append({
"id": str(it.get("id") or f"p{i}"),
"name": str(it.get("name") or it.get("json_path") or "Preset"),
"strategy": str(it.get("strategy") or "auto"),
"json_path": str(it.get("json_path") or ""),
"join_sep": str(it.get("join_sep") or "\n"),
})
out["text_extract_presets"] = norm_presets
# Узлы — список
try:
nodes = out.get("nodes") or []
if not isinstance(nodes, list):
nodes = []
out["nodes"] = nodes
except Exception:
out["nodes"] = []
return out
def load_pipeline() -> Dict[str, Any]:
if PIPELINE_FILE.exists():
try:
return json.loads(PIPELINE_FILE.read_text(encoding="utf-8"))
except Exception:
pass
return default_pipeline()
if PIPELINE_FILE.exists():
try:
data = json.loads(PIPELINE_FILE.read_text(encoding="utf-8"))
return normalize_pipeline(data)
except Exception:
pass
return normalize_pipeline(default_pipeline())
def save_pipeline(pipeline: Dict[str, Any]) -> None:
PIPELINE_FILE.write_text(json.dumps(pipeline, ensure_ascii=False, indent=2), encoding="utf-8")
norm = normalize_pipeline(pipeline or {})
PIPELINE_FILE.write_text(json.dumps(norm, ensure_ascii=False, indent=2), encoding="utf-8")
def list_presets() -> List[str]:
@@ -42,3 +119,51 @@ def save_preset(name: str, pipeline: Dict[str, Any]) -> None:
path.write_text(json.dumps(pipeline, ensure_ascii=False, indent=2), encoding="utf-8")
# ---------------- Variable Store (per-pipeline) ----------------
def _var_store_path(pipeline_id: str) -> Path:
pid = pipeline_id or "pipeline_editor"
VARS_DIR.mkdir(parents=True, exist_ok=True)
# normalize to safe filename
safe = "".join(ch if ch.isalnum() or ch in ("-", "_", ".") else "_" for ch in str(pid))
return VARS_DIR / f"{safe}.json"
def load_var_store(pipeline_id: str) -> Dict[str, Any]:
"""
Load variable store dictionary for given pipeline id.
Returns {} if not exists or invalid.
"""
path = _var_store_path(pipeline_id)
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
def save_var_store(pipeline_id: str, data: Dict[str, Any]) -> None:
"""
Save variable store dictionary for given pipeline id.
"""
path = _var_store_path(pipeline_id)
try:
VARS_DIR.mkdir(parents=True, exist_ok=True)
except Exception:
pass
path.write_text(json.dumps(data or {}, ensure_ascii=False, indent=2), encoding="utf-8")
def clear_var_store(pipeline_id: str) -> None:
"""
Delete/reset variable store for given pipeline id.
"""
path = _var_store_path(pipeline_id)
try:
if path.exists():
path.unlink()
except Exception:
# ignore failures
pass

View File

@@ -17,11 +17,14 @@ __all__ = [
"_deep_find_text",
"_best_text_from_outputs",
"render_template_simple",
"eval_condition_expr",
]
# 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)
# STORE: постоянное хранилище переменных (пер-пайплайн)
_STORE_MACRO_RE = re.compile(r"\[\[\s*STORE\s*[:\s]\s*([^\]]+?)\s*\]\]", re.IGNORECASE)
# Единый фрагмент PROMPT (провайдеро-специфичный JSON-фрагмент)
_PROMPT_MACRO_RE = re.compile(r"\[\[\s*PROMPT\s*\]\]", re.IGNORECASE)
# Короткая форма: [[OUT1]] — best-effort текст из ноды n1
@@ -29,7 +32,13 @@ _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*\}\}")
# Разбираем выражение до ближайшего '}}', допускаем '}' внутри (например в JSON-литералах)
_BRACES_RE = re.compile(r"\{\{\s*(.*?)\s*\}\}", re.DOTALL)
# Сокращённый синтаксис: img(mime?)[[...]] → data:<mime>;base64,<resolved_inner_macro>
# Пример: img()[[OUT1]] → data:image/png;base64,{{resolved OUT1}}
# img(jpeg)[[OUT:n1.result...]] → data:image/jpeg;base64,{{resolved}}
_IMG_WRAPPER_RE = re.compile(r"(?is)img\(\s*([^)]+?)?\s*\)\s*\[\[\s*(.+?)\s*\]\]")
def _split_path(path: str) -> List[str]:
@@ -160,12 +169,21 @@ def _best_text_from_outputs(node_out: Any) -> str:
# 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
cands = base.get("candidates") or []
texts: List[str] = []
for cand in cands:
try:
content = cand.get("content") or {}
parts = content.get("parts") or []
for p in parts:
if isinstance(p, dict):
t = p.get("text")
if isinstance(t, str) and t.strip():
texts.append(t.strip())
except Exception:
continue
if texts:
return "\n".join(texts)
except Exception:
pass
@@ -192,13 +210,55 @@ def render_template_simple(template: str, context: Dict[str, Any], out_map: Dict
value может быть числом, строкой ('..'/".."), массивом/объектом в виде литерала.
- [[VAR:path]] — берёт из context
- [[OUT:nodeId(.path)*]] — берёт из out_map
- [[STORE:path]] — берёт из постоянного хранилища (context.store)
Возвращает строку.
"""
if template is None:
return ""
s = str(template)
# 1) Макросы [[VAR:...]] и [[OUT:...]]
# 0) Сокращённый синтаксис: img(mime?)[[...]] → data:<mime>;base64,<resolved>
# Выполняем до развёртки обычных [[...]] макросов, чтобы внутри можно было использовать любой квадратный макрос.
def _normalize_mime(m: str) -> str:
mm = (m or "").strip().lower()
if not mm:
return "image/png"
if "/" in mm:
return mm
return {
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"webp": "image/webp",
"gif": "image/gif",
"svg": "image/svg+xml",
"bmp": "image/bmp",
"tif": "image/tiff",
"tiff": "image/tiff",
}.get(mm, mm)
def _repl_imgwrap(m: re.Match) -> str:
mime_raw = m.group(1) or ""
inner = m.group(2) or ""
mime = _normalize_mime(mime_raw)
try:
val = _resolve_square_macro_value(inner, context, out_map)
except Exception:
val = ""
if isinstance(val, (dict, list, bool)) or val is None:
val = _stringify_for_template(val)
else:
val = str(val)
return f"data:{mime};base64,{val}"
# Поддерживаем много вхождений — повторяем до исчерпания (на случай каскадных макросов)
while True:
ns, cnt = _IMG_WRAPPER_RE.subn(_repl_imgwrap, s)
s = ns
if cnt == 0:
break
# 1) Макросы [[VAR:...]] / [[OUT:...]] / [[STORE:...]]
def repl_var(m: re.Match) -> str:
path = m.group(1).strip()
val = _get_by_path(context, path)
@@ -214,8 +274,15 @@ def render_template_simple(template: str, context: Dict[str, Any], out_map: Dict
val = out_map.get(body)
return _stringify_for_template(val)
def repl_store(m: re.Match) -> str:
path = m.group(1).strip()
store = context.get("store") or {}
val = _get_by_path(store, path)
return _stringify_for_template(val)
s = _VAR_MACRO_RE.sub(repl_var, s)
s = _OUT_MACRO_RE.sub(repl_out, s)
s = _STORE_MACRO_RE.sub(repl_store, s)
# [[OUT1]] → текст из ноды n1 (best-effort)
def repl_out_short(m: re.Match) -> str:
@@ -250,7 +317,7 @@ def render_template_simple(template: str, context: Dict[str, Any], out_map: Dict
# 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 }}
@@ -264,8 +331,13 @@ def render_template_simple(template: str, context: Dict[str, Any], out_map: Dict
node_val = out_map.get(node_id)
return _get_by_path(node_val, rest)
return out_map.get(body)
# STORE.* — из постоянного хранилища
if p.startswith("STORE.") or p.startswith("store."):
body = p.split(".", 1)[1] if "." in p else ""
store = context.get("store") or {}
return _get_by_path(store, 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()
@@ -305,4 +377,431 @@ def render_template_simple(template: str, context: Dict[str, Any], out_map: Dict
return _stringify_for_template(val)
s = _BRACES_RE.sub(repl_braces, s)
return s
return s
# --- Boolean condition evaluator for If-node ---------------------------------
# Поддерживает:
# - Операторы: &&, ||, !, ==, !=, <, <=, >, >=, contains
# - Скобки (...)
# - Токены-литералы: числа (int/float), строки "..." (без escape-сложностей)
# - Макросы: [[VAR:...]], [[OUT:...]], [[OUT1]], [[NAME]] (vars/context),
# {{ path }} и {{ path|default(...) }} — типобезопасно (числа остаются числами)
# Возвращает bool. Бросает ValueError при синтаксической/семантической ошибке.
def eval_condition_expr(expr: str, context: Dict[str, Any], out_map: Dict[str, Any]) -> bool:
import ast
if expr is None:
return False
s = str(expr)
# Tokenize into a flat list of tokens and build value bindings for macros/braces.
tokens, bindings = _tokenize_condition_expr(s, context, out_map)
# Transform infix "contains" into function form contains(a,b)
tokens = _transform_contains(tokens)
# Join into python-like boolean expression and map logical ops.
py_expr = _tokens_to_python_expr(tokens)
# Evaluate safely via AST with strict whitelist
result = _safe_eval_bool(py_expr, bindings)
return bool(result)
def _tokens_to_python_expr(tokens: List[str]) -> str:
# Уже нормализовано на этапе токенизации, просто склеиваем с пробелами
return " ".join(tokens)
def _transform_contains(tokens: List[str]) -> List[str]:
# Заменяет "... A contains B ..." на "contains(A, B)" с учётом скобок.
i = 0
out: List[str] = tokens[:] # копия
# Итерируем, пока встречается 'contains'
while True:
try:
idx = out.index("contains")
except ValueError:
break
# Левая часть
lstart = idx - 1
if lstart >= 0 and out[lstart] == ")":
# найти соответствующую открывающую "("
bal = 0
j = lstart
while j >= 0:
if out[j] == ")":
bal += 1
elif out[j] == "(":
bal -= 1
if bal == 0:
lstart = j
break
j -= 1
if bal != 0:
# несбалансированные скобки
raise ValueError("Unbalanced parentheses around left operand of contains")
# Правая часть
rend = idx + 1
if rend < len(out) and out[rend] == "(":
bal = 0
j = rend
while j < len(out):
if out[j] == "(":
bal += 1
elif out[j] == ")":
bal -= 1
if bal == 0:
rend = j
break
j += 1
if bal != 0:
raise ValueError("Unbalanced parentheses around right operand of contains")
# Если нет скобок — однотокенный операнд
left_tokens = out[lstart:idx]
right_tokens = out[idx + 1:rend + 1] if (idx + 1 < len(out) and out[idx + 1] == "(") else out[idx + 1:idx + 2]
if not left_tokens or not right_tokens:
raise ValueError("contains requires two operands")
left_str = " ".join(left_tokens)
right_str = " ".join(right_tokens)
# Синтезируем вызов и заменяем диапазон
new_tok = f"contains({left_str}, {right_str})"
out = out[:lstart] + [new_tok] + out[(rend + 1) if (idx + 1 < len(out) and out[idx + 1] == "(") else (idx + 2):]
return out
def _tokenize_condition_expr(expr: str, context: Dict[str, Any], out_map: Dict[str, Any]) -> tuple[List[str], Dict[str, Any]]:
tokens: List[str] = []
bindings: Dict[str, Any] = {}
i = 0
n = len(expr)
vcount = 0
def add_binding(val: Any) -> str:
nonlocal vcount
name = f"__v{vcount}"
vcount += 1
bindings[name] = val
return name
while i < n:
ch = expr[i]
# Пробелы
if ch.isspace():
i += 1
continue
# Операторы двойные
if expr.startswith("&&", i):
tokens.append("and")
i += 2
continue
if expr.startswith("||", i):
tokens.append("or")
i += 2
continue
if expr.startswith(">=", i) or expr.startswith("<=", i) or expr.startswith("==", i) or expr.startswith("!=", i):
tokens.append(expr[i:i+2])
i += 2
continue
# Одинарные операторы
if ch in "()<>":
tokens.append(ch)
i += 1
continue
if ch == "!":
# уже обработали "!=" как двойной
tokens.append("not")
i += 1
continue
# Строковые литералы "...." и '....' (простая версия: без экранирования)
if ch == '"':
j = i + 1
while j < n and expr[j] != '"':
# простая версия: без экранирования
j += 1
if j >= n:
raise ValueError('Unterminated string literal')
content = expr[i+1:j]
# Конвертируем в безопасный Python-литерал
tokens.append(repr(content))
i = j + 1
continue
# Одинарные кавычки
if ch == "'":
j = i + 1
while j < n and expr[j] != "'":
# простая версия: без экранирования
j += 1
if j >= n:
raise ValueError('Unterminated string literal')
content = expr[i+1:j]
tokens.append(repr(content))
i = j + 1
continue
# Макросы [[...]]
if expr.startswith("[[", i):
j = expr.find("]]", i + 2)
if j < 0:
raise ValueError("Unterminated [[...]] macro")
body = expr[i+2:j]
val = _resolve_square_macro_value(body, context, out_map)
name = add_binding(val)
tokens.append(name)
i = j + 2
continue
# Скобки {{ ... }}
if expr.startswith("{{", i):
j = expr.find("}}", i + 2)
if j < 0:
raise ValueError("Unterminated {{ ... }} expression")
body = expr[i+2:j]
val = _resolve_braces_value(body, context, out_map)
name = add_binding(val)
tokens.append(name)
i = j + 2
continue
# Ключевое слово contains
if expr[i:i+8].lower() == "contains":
tokens.append("contains")
i += 8
continue
# Число
if ch.isdigit():
j = i + 1
dot_seen = False
while j < n and (expr[j].isdigit() or (expr[j] == "." and not dot_seen)):
if expr[j] == ".":
dot_seen = True
j += 1
tokens.append(expr[i:j])
i = j
continue
# Идентификатор (на всякий — пропускаем последовательность букв/подчёрк/цифр)
if ch.isalpha() or ch == "_":
j = i + 1
while j < n and (expr[j].isalnum() or expr[j] in "._"):
j += 1
word = expr[i:j]
lw = word.lower()
# Литералы: true/false/null (любая раскладка) → Python-константы
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", word) and lw in {"true", "false", "null"}:
tokens.append("True" if lw == "true" else ("False" if lw == "false" else "None"))
i = j
continue
# Поддержка «голых» идентификаторов из vars: cycleindex, WAS_ERROR и т.п.
# Если это простой идентификатор (без точек) и он есть в context.vars — биндим его значением.
try:
vmap = context.get("vars") or {}
except Exception:
vmap = {}
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", word) and isinstance(vmap, dict) and word in vmap:
name = add_binding(vmap.get(word))
tokens.append(name)
else:
# Логические в словах не поддерживаем (используйте &&, ||, !)
tokens.append(word)
i = j
continue
# Иное — ошибка
raise ValueError(f"Unexpected character in expression: {ch!r}")
return tokens, bindings
def _resolve_square_macro_value(body: str, context: Dict[str, Any], out_map: Dict[str, Any]) -> Any:
# Тело без [[...]]
b = str(body or "").strip()
# [[OUT1]]
m = re.fullmatch(r"(?is)OUT\s*(\d+)", b)
if m:
try:
num = int(m.group(1))
node_id = f"n{num}"
node_out = out_map.get(node_id)
return _best_text_from_outputs(node_out)
except Exception:
return ""
# [[VAR: ...]]
m = re.fullmatch(r"(?is)VAR\s*[:]\s*(.+)", b)
if m:
path = m.group(1).strip()
return _get_by_path(context, path)
# [[OUT: node.path]]
m = re.fullmatch(r"(?is)OUT\s*[:]\s*(.+)", b)
if m:
body2 = m.group(1).strip()
if "." in body2:
node_id, rest = body2.split(".", 1)
node_val = out_map.get(node_id.strip())
return _get_by_path(node_val, rest.strip())
return out_map.get(body2)
# [[STORE: path]]
m = re.fullmatch(r"(?is)STORE\s*[:]\s*(.+)", b)
if m:
path = m.group(1).strip()
store = context.get("store") or {}
return _get_by_path(store, path)
# [[NAME]] — «голая» переменная: сначала vars, потом context по пути/ключу
name = b
vmap = context.get("vars") or {}
if isinstance(vmap, dict) and (name in vmap):
return vmap.get(name)
return _get_by_path(context, name)
def _resolve_braces_value(body: str, context: Dict[str, Any], out_map: Dict[str, Any]) -> Any:
# Логика совместима с {{ path|default(value) }}, возврат — типобезопасный
expr = str(body or "").strip()
def eval_path(p: str) -> Any:
p = p.strip()
vmap = context.get("vars") or {}
# Простой идентификатор — сначала в vars
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."):
body2 = p[4:].strip()
if "." in body2:
node_id, rest = body2.split(".", 1)
node_val = out_map.get(node_id.strip())
return _get_by_path(node_val, rest.strip())
return out_map.get(body2)
return _get_by_path(context, p)
m = re.match(r"([^|]+)\|\s*default\((.*)\)\s*$", expr)
if m:
base_path = m.group(1).strip()
fallback_raw = m.group(2).strip()
def eval_default(raw: str) -> Any:
raw = raw.strip()
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 литерал
try:
return json.loads(raw)
except Exception:
return raw
raw_val = eval_path(base_path)
return raw_val if raw_val not in (None, "") else eval_default(fallback_raw)
else:
return eval_path(expr)
def _stringify_for_contains(val: Any) -> str:
# Для contains на строках — совместим со строкификацией шаблона
return _stringify_for_template(val)
def _safe_eval_bool(py_expr: str, bindings: Dict[str, Any]) -> bool:
import ast
import operator as op
def contains_fn(a: Any, b: Any) -> bool:
# Семантика: список/кортеж/множество — membership, иначе — подстрока по строковому представлению
if isinstance(a, (list, tuple, set)):
return b in a
sa = _stringify_for_contains(a)
sb = _stringify_for_contains(b)
return sb in sa
allowed_boolops = {ast.And, ast.Or}
allowed_unary = {ast.Not}
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.Name):
if node.id == "contains":
# Возврат специальной метки, реально обрабатывается в Call
return ("__fn__", "contains")
if node.id in bindings:
return bindings[node.id]
# Неизвестные имена запрещены
raise ValueError(f"Unknown name: {node.id}")
if isinstance(node, ast.UnaryOp) and isinstance(node.op, tuple(allowed_unary)):
val = bool(eval_node(node.operand))
if isinstance(node.op, ast.Not):
return (not val)
if isinstance(node, ast.BoolOp) and isinstance(node.op, tuple(allowed_boolops)):
# Короткое замыкание:
# AND — при первом False прекращаем и возвращаем False; иначе True
# OR — при первом True прекращаем и возвращаем True; иначе False
if isinstance(node.op, ast.And):
for v in node.values:
if not bool(eval_node(v)):
return False
return True
if isinstance(node.op, ast.Or):
for v in node.values:
if bool(eval_node(v)):
return True
return False
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 ValueError("Unsupported comparison operator")
right = eval_node(comparator)
if not allowed_cmp[type(opnode)](left, right):
return False
left = right
return True
if isinstance(node, ast.Call):
# Разрешаем только contains(a,b)
if node.keywords or len(node.args) != 2:
raise ValueError("Only contains(a,b) call is allowed")
fn = node.func
# Форма contains(...) может прийти как Name('contains') или как ("__fn__","contains")
if isinstance(fn, ast.Name) and fn.id == "contains":
a = eval_node(node.args[0])
b = eval_node(node.args[1])
return contains_fn(a, b)
# Дополнительно: если парсер вернул константу-маркер
if isinstance(fn, ast.Constant) and fn.value == ("__fn__", "contains"):
a = eval_node(node.args[0])
b = eval_node(node.args[1])
return contains_fn(a, b)
raise ValueError("Function calls are not allowed")
# Запрещаем имена, атрибуты, индексации и прочее
raise ValueError("Expression construct not allowed")
try:
tree = ast.parse(py_expr, mode="eval")
except Exception as exc:
raise ValueError(f"Condition parse error: {exc}") from exc
return bool(eval_node(tree))

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
"""
Пакет адаптеров провайдеров для ProviderCall.
Экспортируем:
- ProviderAdapter базовый класс
- Реализации: OpenAIAdapter, GeminiAdapter, GeminiImageAdapter, ClaudeAdapter
- Утилиты: default_base_url_for, insert_items, split_pos_spec
"""
from .base import ( # [ProviderAdapter](agentui/providers/adapters/base.py:10)
ProviderAdapter,
default_base_url_for,
insert_items,
split_pos_spec,
)
from .openai import OpenAIAdapter # [OpenAIAdapter](agentui/providers/adapters/openai.py:39)
from .gemini import ( # [GeminiAdapter](agentui/providers/adapters/gemini.py:56)
GeminiAdapter,
GeminiImageAdapter, # [GeminiImageAdapter](agentui/providers/adapters/gemini.py:332)
)
from .claude import ClaudeAdapter # [ClaudeAdapter](agentui/providers/adapters/claude.py:56)
__all__ = [
"ProviderAdapter",
"OpenAIAdapter",
"GeminiAdapter",
"GeminiImageAdapter",
"ClaudeAdapter",
"default_base_url_for",
"insert_items",
"split_pos_spec",
]

View File

@@ -0,0 +1,148 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional, Tuple
class ProviderAdapter(ABC): # [ProviderAdapter.__init__()](agentui/providers/adapters/base.py:10)
"""
Базовый интерфейс адаптера провайдера для ProviderCall.
Задачи адаптера:
- blocks_struct_for_template: собрать pm_struct из унифицированных сообщений (Prompt Blocks)
- normalize_segment/filter_items: привести произвольный сегмент к целевой провайдерной структуре и отфильтровать пустое
- extract_system_text_from_obj: вытащить системный текст из произвольного сегмента (если он там есть)
- combine_segments: слить pre_segments (prompt_preprocess) и prompt_combine с blocks_struct → итоговый pm_struct
- prompt_fragment: собрать строку JSON-фрагмента для подстановки в [[PROMPT]]
- default_endpoint/default_base_url: дефолты путей и базовых URL
"""
name: str = "base"
# --- Дефолты HTTP ---
@abstractmethod
def default_base_url(self) -> str:
...
@abstractmethod
def default_endpoint(self, model: str) -> str:
...
# --- PROMPT: построение провайдерных структур ---
@abstractmethod
def blocks_struct_for_template(
self,
unified_messages: List[Dict[str, Any]],
context: Dict[str, Any],
node_config: Dict[str, Any],
) -> Dict[str, Any]:
"""
Из унифицированных сообщений [{role, content}] (включая text+image) собрать pm_struct
для целевого провайдера. Результат должен быть совместим с текущей логикой [[PROMPT]].
"""
...
@abstractmethod
def normalize_segment(self, obj: Any) -> List[Dict[str, Any]]:
"""
Привести произвольный сегмент (dict/list/str/числа) к целевому массиву элементов
(например, messages для openai/claude или contents для gemini).
"""
...
@abstractmethod
def filter_items(self, items: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Отфильтровать пустые элементы (пустые тексты и т.п.) согласно правилам провайдера.
"""
...
@abstractmethod
def extract_system_text_from_obj(self, obj: Any, render_ctx: Dict[str, Any]) -> Optional[str]:
"""
Вытащить системный текст из произвольного объекта фрагмента:
- OpenAI: messages[*] role=system
- Gemini: systemInstruction.parts[].text
- Claude: top-level system (string/blocks)
Возвращает строку или None.
"""
...
@abstractmethod
def combine_segments(
self,
blocks_struct: Dict[str, Any],
pre_segments_raw: List[Dict[str, Any]],
raw_segs: List[str],
render_ctx: Dict[str, Any],
pre_var_paths: set[str],
render_template_simple_fn, # (s, ctx, out_map) -> str
var_macro_fullmatch_re, # _VAR_MACRO_RE.fullmatch
detect_vendor_fn, # detect_vendor
) -> Dict[str, Any]:
"""
Слить blocks_struct c массивами pre_segments_raw и строковыми raw_segs (prompt_combine)
и вернуть итоговый pm_struct. Поведение должно повторять текущее (позиционирование, фильтр пустых,
сбор системного текста).
"""
...
@abstractmethod
def prompt_fragment(self, pm_struct: Dict[str, Any], node_config: Dict[str, Any]) -> str:
"""
Сформировать строку JSON-фрагмента для [[PROMPT]] по итоговому pm_struct.
"""
...
# --- Общие утилиты для позиционирования и парсинга директив ---------------------
def insert_items(base: List[Any], items: List[Any], pos_spec: Optional[str]) -> List[Any]: # [insert_items()](agentui/providers/adapters/base.py:114)
if not items:
return base
if not pos_spec or str(pos_spec).lower() == "append":
base.extend(items)
return base
p = str(pos_spec).lower()
if p == "prepend":
return list(items) + base
try:
idx = int(pos_spec) # type: ignore[arg-type]
if idx < 0:
idx = len(base) + idx
if idx < 0:
idx = 0
if idx > len(base):
idx = len(base)
return base[:idx] + list(items) + base[idx:]
except Exception:
base.extend(items)
return base
def split_pos_spec(s: str) -> Tuple[str, Optional[str]]: # [split_pos_spec()](agentui/providers/adapters/base.py:135)
"""
Отделить директиву @pos=... от тела сегмента.
Возвращает (body, pos_spec | None).
"""
import re as _re
m = _re.search(r"@pos\s*=\s*(prepend|append|-?\d+)\s*$", str(s or ""), flags=_re.IGNORECASE)
if not m:
return (str(s or "").strip(), None)
body = str(s[: m.start()]).strip()
return (body, str(m.group(1)).strip().lower())
# --- Дефолтные base_url по "вендору" (используется RawForward) ------------------
def default_base_url_for(vendor: str) -> Optional[str]: # [default_base_url_for()](agentui/providers/adapters/base.py:149)
v = (vendor or "").strip().lower()
if v == "openai":
return "https://api.openai.com"
if v == "claude" or v == "anthropic":
return "https://api.anthropic.com"
if v == "gemini" or v == "gemini_image":
return "https://generativelanguage.googleapis.com"
return None

View File

@@ -0,0 +1,475 @@
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional
from agentui.providers.adapters.base import ( # [ProviderAdapter](agentui/providers/adapters/base.py:10)
ProviderAdapter,
insert_items,
split_pos_spec,
)
def _is_data_url(u: str) -> bool: # [_is_data_url()](agentui/providers/adapters/claude.py:14)
return isinstance(u, str) and u.strip().lower().startswith("data:")
def _split_data_url(u: str) -> tuple[str, str]: # [_split_data_url()](agentui/providers/adapters/claude.py:18)
"""
Возвращает (mime, b64) для data URL.
Поддерживаем форму: data:<mime>;base64,<b64>
"""
try:
header, b64 = u.split(",", 1)
mime = "application/octet-stream"
if header.startswith("data:"):
header2 = header[5:]
if ";base64" in header2:
mime = header2.split(";base64", 1)[0] or mime
elif ";" in header2:
mime = header2.split(";", 1)[0] or mime
elif header2:
mime = header2
return mime, b64
except Exception:
return "application/octet-stream", ""
def _try_json(s: str) -> Any: # [_try_json()](agentui/providers/adapters/claude.py:38)
try:
obj = json.loads(s)
except Exception:
try:
obj = json.loads(s, strict=False) # type: ignore[call-arg]
except Exception:
return None
for _ in range(2):
if isinstance(obj, str):
st = obj.strip()
if (st.startswith("{") and st.endswith("}")) or (st.startswith("[") and st.endswith("]")):
try:
obj = json.loads(st)
continue
except Exception:
break
break
return obj
class ClaudeAdapter(ProviderAdapter): # [ClaudeAdapter.__init__()](agentui/providers/adapters/claude.py:56)
name = "claude"
# --- Дефолты HTTP ---
def default_base_url(self) -> str:
return "https://api.anthropic.com"
def default_endpoint(self, model: str) -> str:
return "/v1/messages"
# --- PROMPT: построение провайдерных структур ---
def blocks_struct_for_template(
self,
unified_messages: List[Dict[str, Any]],
context: Dict[str, Any],
node_config: Dict[str, Any],
) -> Dict[str, Any]:
"""
Совместимо с веткой provider=='claude' из
[ProviderCallNode._blocks_struct_for_template()](agentui/pipeline/executor.py:2022).
"""
# Системные сообщения как текст
sys_msgs = []
for m in (unified_messages or []):
if m.get("role") == "system":
c = m.get("content")
if isinstance(c, list):
sys_msgs.append("\n".join([str(p.get("text") or "") for p in c if isinstance(p, dict) and p.get("type") == "text"]))
else:
sys_msgs.append(str(c or ""))
sys_text = "\n\n".join([s for s in sys_msgs if s]).strip()
out_msgs = []
for m in (unified_messages or []):
if m.get("role") == "system":
continue
role = m.get("role")
role = role if role in {"user", "assistant"} else "user"
c = m.get("content")
blocks: List[Dict[str, Any]] = []
if isinstance(c, list):
for p in c:
if not isinstance(p, dict):
continue
if p.get("type") == "text":
blocks.append({"type": "text", "text": str(p.get("text") or "")})
elif p.get("type") in {"image_url", "image"}:
url = str(p.get("url") or "")
if _is_data_url(url):
mime, b64 = _split_data_url(url)
blocks.append({"type": "image", "source": {"type": "base64", "media_type": mime, "data": b64}})
else:
blocks.append({"type": "image", "source": {"type": "url", "url": url}})
else:
blocks.append({"type": "text", "text": str(c or "")})
out_msgs.append({"role": role, "content": blocks})
claude_no_system = False
try:
claude_no_system = bool((node_config or {}).get("claude_no_system", False))
except Exception:
claude_no_system = False
if claude_no_system:
if sys_text:
out_msgs = [{"role": "user", "content": [{"type": "text", "text": sys_text}]}] + out_msgs
return {
"messages": out_msgs,
"system_text": sys_text,
}
d = {
"system_text": sys_text,
"messages": out_msgs,
}
if sys_text:
# Prefer system as a plain string (proxy compatibility)
d["system"] = sys_text
return d
def normalize_segment(self, x: Any) -> List[Dict[str, Any]]:
"""
Совместимо с [_as_claude_messages()](agentui/pipeline/executor.py:2602).
"""
msgs: List[Dict[str, Any]] = []
try:
if isinstance(x, dict):
# Dict with messages (OpenAI-like)
if isinstance(x.get("messages"), list):
x = x.get("messages") or []
# fallthrough to list mapping below
elif isinstance(x.get("contents"), list):
# Gemini -> Claude
for c in (x.get("contents") or []):
if not isinstance(c, dict):
continue
role_raw = str(c.get("role") or "user")
role = "assistant" if role_raw == "model" else ("user" if role_raw not in {"user", "assistant"} else role_raw)
parts = c.get("parts") or []
text = "\n".join([str(p.get("text")) for p in parts if isinstance(p, dict) and isinstance(p.get("text"), str)]).strip()
msgs.append({"role": role, "content": [{"type": "text", "text": text}]})
return msgs
if isinstance(x, list):
# Gemini contents list -> Claude messages
if all(isinstance(c, dict) and "parts" in c for c in x):
for c in x:
role_raw = str(c.get("role") or "user")
role = "assistant" if role_raw == "model" else ("user" if role_raw not in {"user", "assistant"} else role_raw)
blocks: List[Dict[str, Any]] = []
for p in (c.get("parts") or []):
if isinstance(p, dict) and isinstance(p.get("text"), str):
txt = p.get("text").strip()
if txt:
blocks.append({"type": "text", "text": txt})
msgs.append({"role": role, "content": blocks or [{"type": "text", "text": ""}]})
return msgs
# OpenAI messages list -> Claude
if all(isinstance(m, dict) and "content" in m for m in x):
out: List[Dict[str, Any]] = []
for m in x:
role = m.get("role", "user")
cont = m.get("content")
blocks: List[Dict[str, Any]] = []
if isinstance(cont, str):
blocks.append({"type": "text", "text": cont})
elif isinstance(cont, list):
for p in cont:
if not isinstance(p, dict):
continue
if p.get("type") == "text":
blocks.append({"type": "text", "text": str(p.get("text") or "")})
elif p.get("type") in {"image_url", "image"}:
url = ""
if isinstance(p.get("image_url"), dict):
url = str((p.get("image_url") or {}).get("url") or "")
elif "url" in p:
url = str(p.get("url") or "")
if url:
blocks.append({"type": "image", "source": {"type": "url", "url": url}})
else:
blocks.append({"type": "text", "text": json.dumps(cont, ensure_ascii=False)})
out.append({"role": role if role in {"user", "assistant"} else "user", "content": blocks})
return out
# Fallback
return [{"role": "user", "content": [{"type": "text", "text": json.dumps(x, ensure_ascii=False)}]}]
if isinstance(x, str):
try_obj = _try_json(x)
if try_obj is not None:
return self.normalize_segment(try_obj)
return [{"role": "user", "content": [{"type": "text", "text": x}]}]
return [{"role": "user", "content": [{"type": "text", "text": json.dumps(x, ensure_ascii=False)}]}]
except Exception:
return [{"role": "user", "content": [{"type": "text", "text": str(x)}]}]
def filter_items(self, arr: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Совместимо с [_filter_claude()](agentui/pipeline/executor.py:2820).
"""
out: List[Dict[str, Any]] = []
for m in (arr or []):
if not isinstance(m, dict):
continue
blocks = m.get("content")
if isinstance(blocks, list):
norm = []
for b in blocks:
if isinstance(b, dict) and b.get("type") == "text":
txt = str(b.get("text") or "")
if txt.strip():
norm.append({"type": "text", "text": txt})
if norm:
out.append({"role": m.get("role", "user"), "content": norm})
return out
def extract_system_text_from_obj(self, x: Any, render_ctx: Dict[str, Any]) -> Optional[str]:
"""
Поведение совместимо с [_extract_sys_text_from_obj()](agentui/pipeline/executor.py:2676).
"""
try:
# Dict objects
if isinstance(x, dict):
# Gemini systemInstruction
if "systemInstruction" in x:
si = x.get("systemInstruction")
def _parts_to_text(siobj: Any) -> str:
try:
parts = siobj.get("parts") or []
texts = [
str(p.get("text") or "")
for p in parts
if isinstance(p, dict) and isinstance(p.get("text"), str) and p.get("text").strip()
]
return "\n".join([t for t in texts if t]).strip()
except Exception:
return ""
if isinstance(si, dict):
t = _parts_to_text(si)
if t:
return t
if isinstance(si, list):
texts = []
for p in si:
if isinstance(p, dict) and isinstance(p.get("text"), str) and p.get("text").strip():
texts.append(p.get("text").strip())
t = "\n".join(texts).strip()
if t:
return t
if isinstance(si, str) and si.strip():
return si.strip()
# Claude system (string or blocks)
if "system" in x and not ("messages" in x and isinstance(x.get("messages"), list)):
sysv = x.get("system")
if isinstance(sysv, str) and sysv.strip():
return sysv.strip()
if isinstance(sysv, list):
texts = [
str(b.get("text") or "")
for b in sysv
if isinstance(b, dict)
and (b.get("type") == "text")
and isinstance(b.get("text"), str)
and b.get("text").strip()
]
t = "\n".join([t for t in texts if t]).strip()
if t:
return t
# OpenAI messages with role=system
if isinstance(x.get("messages"), list):
sys_msgs = []
for m in (x.get("messages") or []):
try:
if (str(m.get("role") or "").lower().strip() == "system"):
cont = m.get("content")
if isinstance(cont, str) and cont.strip():
sys_msgs.append(cont.strip())
elif isinstance(cont, list):
for p in cont:
if isinstance(p, dict) and p.get("type") == "text" and isinstance(p.get("text"), str) and p.get("text").strip():
sys_msgs.append(p.get("text").strip())
except Exception:
continue
if sys_msgs:
return "\n\n".join(sys_msgs).strip()
# List objects
if isinstance(x, list):
# OpenAI messages list with role=system
if all(isinstance(m, dict) and "role" in m for m in x):
sys_msgs = []
for m in x:
try:
if (str(m.get("role") or "").lower().strip() == "system"):
cont = m.get("content")
if isinstance(cont, str) and cont.strip():
sys_msgs.append(cont.strip())
elif isinstance(cont, list):
for p in cont:
if isinstance(p, dict) and p.get("type") == "text" and isinstance(p.get("text"), str) and p.get("text").strip():
sys_msgs.append(p.get("text").strip())
except Exception:
continue
if sys_msgs:
return "\n\n".join(sys_msgs).strip()
# Gemini 'contents' list: попробуем прочитать systemInstruction из входящего snapshot
if all(isinstance(c, dict) and "parts" in c for c in x):
try:
inc = (render_ctx.get("incoming") or {}).get("json") or {}
si = inc.get("systemInstruction")
if si is not None:
return self.extract_system_text_from_obj({"systemInstruction": si}, render_ctx)
except Exception:
pass
return None
except Exception:
return None
def combine_segments(
self,
blocks_struct: Dict[str, Any],
pre_segments_raw: List[Dict[str, Any]],
raw_segs: List[str],
render_ctx: Dict[str, Any],
pre_var_paths: set[str],
render_template_simple_fn,
var_macro_fullmatch_re,
detect_vendor_fn,
) -> Dict[str, Any]:
"""
Повторяет ветку provider=='claude' из prompt_combine
([ProviderCallNode.run()](agentui/pipeline/executor.py:2998)).
"""
built3: List[Dict[str, Any]] = []
sys_texts: List[str] = []
# Нода-конфиг (для claude_no_system) передан через render_ctx['_node_config'], см. интеграцию
node_cfg = {}
try:
nc = render_ctx.get("_node_config")
if isinstance(nc, dict):
node_cfg = nc
except Exception:
node_cfg = {}
claude_no_system = False
try:
claude_no_system = bool(node_cfg.get("claude_no_system", False))
except Exception:
claude_no_system = False
# Пред‑сегменты
for _pre in (pre_segments_raw or []):
try:
_obj = _pre.get("obj")
items = self.normalize_segment(_obj)
items = self.filter_items(items)
built3 = insert_items(built3, items, _pre.get("pos"))
try:
sx = self.extract_system_text_from_obj(_obj, render_ctx)
if isinstance(sx, str) and sx.strip():
sys_texts.append(sx.strip())
except Exception:
pass
except Exception:
pass
# Основные сегменты
for raw_seg in (raw_segs or []):
body_seg, pos_spec = split_pos_spec(raw_seg)
if body_seg == "[[PROMPT]]":
items = self.filter_items(list(blocks_struct.get("messages", []) or []))
built3 = insert_items(built3, items, pos_spec)
continue
m_pre = var_macro_fullmatch_re.fullmatch(body_seg)
if m_pre:
_p = (m_pre.group(1) or "").strip()
try:
if _p in pre_var_paths:
# Skip duplicate var segment - already inserted via prompt_preprocess (filtered)
continue
except Exception:
pass
resolved = render_template_simple_fn(body_seg, render_ctx, render_ctx.get("OUT") or {})
obj = _try_json(resolved)
try:
pg = detect_vendor_fn(obj if isinstance(obj, dict) else {})
print(f"DEBUG: prompt_combine seg provider_guess={pg} -> target=claude pos={pos_spec}")
except Exception:
pass
items = self.normalize_segment(obj if obj is not None else resolved)
items = self.filter_items(items)
built3 = insert_items(built3, items, pos_spec)
try:
sx = self.extract_system_text_from_obj(obj, render_ctx) if obj is not None else None
if isinstance(sx, str) and sx.strip():
sys_texts.append(sx.strip())
except Exception:
pass
if not built3:
built3 = self.filter_items(list(blocks_struct.get("messages", []) or []))
# Merge system blocks from PROMPT blocks + gathered sys_texts
existing_sys = blocks_struct.get("system") or []
sys_blocks: List[Dict[str, Any]] = []
if isinstance(existing_sys, list):
sys_blocks.extend(existing_sys)
st0 = blocks_struct.get("system_text") or ""
# Ensure PROMPT system_text from blocks is included as a Claude system block
if isinstance(st0, str) and st0.strip():
sys_blocks.append({"type": "text", "text": st0})
for s in sys_texts:
sys_blocks.append({"type": "text", "text": s})
st = "\n\n".join([t for t in [st0] + sys_texts if isinstance(t, str) and t.strip()])
if claude_no_system:
# Prepend system text as a user message instead of top-level system
if st:
built3 = [{"role": "user", "content": [{"type": "text", "text": st}]}] + built3
return {"messages": built3, "system_text": st}
pm_struct = {"messages": built3, "system_text": st}
# Prefer array of system blocks when possible; fallback to single text block
if sys_blocks:
pm_struct["system"] = sys_blocks
elif st:
pm_struct["system"] = [{"type": "text", "text": st}]
return pm_struct
def prompt_fragment(self, pm_struct: Dict[str, Any], node_config: Dict[str, Any]) -> str:
"""
Совместимо с веткой provider=='claude' в построении [[PROMPT]]
([ProviderCallNode.run()](agentui/pipeline/executor.py:3125)).
"""
parts: List[str] = []
# Учитываем флаг совместимости: при claude_no_system не добавляем top-level "system"
claude_no_system = False
try:
claude_no_system = bool((node_config or {}).get("claude_no_system", False))
except Exception:
claude_no_system = False
if not claude_no_system:
# Предпочитаем массив блоков system, если он есть; иначе строковый system_text
sys_val = pm_struct.get("system", None)
if sys_val is None:
sys_val = pm_struct.get("system_text")
if sys_val:
parts.append('"system": ' + json.dumps(sys_val, ensure_ascii=False))
msgs = pm_struct.get("messages")
if msgs is not None:
parts.append('"messages": ' + json.dumps(msgs, ensure_ascii=False))
return ", ".join(parts)

View File

@@ -0,0 +1,419 @@
from __future__ import annotations
import json
from typing import Any, Dict, List, Optional, Tuple
from agentui.providers.adapters.base import ( # [ProviderAdapter](agentui/providers/adapters/base.py:10)
ProviderAdapter,
insert_items,
split_pos_spec,
)
def _is_data_url(u: str) -> bool: # [_is_data_url()](agentui/providers/adapters/gemini.py:14)
return isinstance(u, str) and u.strip().lower().startswith("data:")
def _split_data_url(u: str) -> tuple[str, str]: # [_split_data_url()](agentui/providers/adapters/gemini.py:18)
"""
Возвращает (mime, b64) для data URL.
Поддерживаем форму: data:<mime>;base64,<b64>
"""
try:
header, b64 = u.split(",", 1)
mime = "application/octet-stream"
if header.startswith("data:"):
header2 = header[5:]
if ";base64" in header2:
mime = header2.split(";base64", 1)[0] or mime
elif ";" in header2:
mime = header2.split(";", 1)[0] or mime
elif header2:
mime = header2
return mime, b64
except Exception:
return "application/octet-stream", ""
def _try_json(s: str) -> Any: # [_try_json()](agentui/providers/adapters/gemini.py:38)
try:
obj = json.loads(s)
except Exception:
try:
obj = json.loads(s, strict=False) # type: ignore[call-arg]
except Exception:
return None
for _ in range(2):
if isinstance(obj, str):
st = obj.strip()
if (st.startswith("{") and st.endswith("}")) or (st.startswith("[") and st.endswith("]")):
try:
obj = json.loads(st)
continue
except Exception:
break
break
return obj
class GeminiAdapter(ProviderAdapter): # [GeminiAdapter.__init__()](agentui/providers/adapters/gemini.py:56)
name = "gemini"
# --- Дефолты HTTP ---
def default_base_url(self) -> str:
return "https://generativelanguage.googleapis.com"
def default_endpoint(self, model: str) -> str:
# endpoint с шаблоном model (как в исходном коде)
return "/v1beta/models/{{ model }}:generateContent"
# --- PROMPT: построение провайдерных структур ---
def blocks_struct_for_template(
self,
unified_messages: List[Dict[str, Any]],
context: Dict[str, Any],
node_config: Dict[str, Any],
) -> Dict[str, Any]:
"""
Совместимо с веткой provider in {'gemini','gemini_image'} из
[ProviderCallNode._blocks_struct_for_template()](agentui/pipeline/executor.py:1981).
"""
def _text_from_msg(m: Dict[str, Any]) -> str:
c = m.get("content")
if isinstance(c, list):
texts = [str(p.get("text") or "") for p in c if isinstance(p, dict) and p.get("type") == "text"]
return "\n".join([t for t in texts if t])
return str(c or "")
sys_text = "\n\n".join([_text_from_msg(m) for m in (unified_messages or []) if m.get("role") == "system"]).strip()
contents: List[Dict[str, Any]] = []
for m in (unified_messages or []):
if m.get("role") == "system":
continue
role = "model" if m.get("role") == "assistant" else "user"
c = m.get("content")
parts: List[Dict[str, Any]] = []
if isinstance(c, list):
for p in c:
if not isinstance(p, dict):
continue
if p.get("type") == "text":
parts.append({"text": str(p.get("text") or "")})
elif p.get("type") in {"image_url", "image"}:
url = str(p.get("url") or "")
if _is_data_url(url):
mime, b64 = _split_data_url(url)
parts.append({"inline_data": {"mime_type": mime, "data": b64}})
else:
parts.append({"text": url})
else:
parts.append({"text": str(c or "")})
contents.append({"role": role, "parts": parts})
d: Dict[str, Any] = {
"contents": contents,
"system_text": sys_text,
}
if sys_text:
d["systemInstruction"] = {"parts": [{"text": sys_text}]}
return d
def normalize_segment(self, x: Any) -> List[Dict[str, Any]]:
"""
Совместимо с [_as_gemini_contents()](agentui/pipeline/executor.py:2521).
"""
cnts: List[Dict[str, Any]] = []
try:
if isinstance(x, dict):
if isinstance(x.get("contents"), list):
return list(x.get("contents") or [])
if isinstance(x.get("messages"), list):
# OpenAI → Gemini
for m in (x.get("messages") or []):
if not isinstance(m, dict):
continue
role_raw = str(m.get("role") or "user")
role = "model" if role_raw == "assistant" else "user"
cont = m.get("content")
parts: List[Dict[str, Any]] = []
if isinstance(cont, str):
parts = [{"text": cont}]
elif isinstance(cont, list):
for p in cont:
if not isinstance(p, dict):
continue
if p.get("type") == "text":
parts.append({"text": str(p.get("text") or "")})
elif p.get("type") in {"image_url", "image"}:
# Gemini не принимает внешние URL картинок как image — оставим как текстовую ссылку
url = ""
if isinstance(p.get("image_url"), dict):
url = str((p.get("image_url") or {}).get("url") or "")
elif "url" in p:
url = str(p.get("url") or "")
if url:
parts.append({"text": url})
else:
parts = [{"text": json.dumps(cont, ensure_ascii=False)}]
cnts.append({"role": role, "parts": parts})
return cnts
if isinstance(x, list):
# Gemini contents list already
if all(isinstance(c, dict) and "parts" in c for c in x):
return list(x)
# OpenAI messages list -> Gemini
if all(isinstance(m, dict) and "content" in m for m in x):
out: List[Dict[str, Any]] = []
for m in x:
role_raw = str(m.get("role") or "user")
role = "model" if role_raw == "assistant" else "user"
cont = m.get("content")
parts: List[Dict[str, Any]] = []
if isinstance(cont, str):
parts = [{"text": cont}]
elif isinstance(cont, list):
for p in cont:
if not isinstance(p, dict):
continue
if p.get("type") == "text":
parts.append({"text": str(p.get("text") or "")})
elif p.get("type") in {"image_url", "image"}:
url = ""
if isinstance(p.get("image_url"), dict):
url = str((p.get("image_url") or {}).get("url") or "")
elif "url" in p:
url = str(p.get("url") or "")
if url:
parts.append({"text": url})
else:
parts = [{"text": json.dumps(cont, ensure_ascii=False)}]
out.append({"role": role, "parts": parts})
return out
# Fallback
return [{"role": "user", "parts": [{"text": json.dumps(x, ensure_ascii=False)}]}]
if isinstance(x, str):
try_obj = _try_json(x)
if try_obj is not None:
return self.normalize_segment(try_obj)
return [{"role": "user", "parts": [{"text": x}]}]
return [{"role": "user", "parts": [{"text": json.dumps(x, ensure_ascii=False)}]}]
except Exception:
return [{"role": "user", "parts": [{"text": str(x)}]}]
def filter_items(self, arr: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Совместимо с [_filter_gemini()](agentui/pipeline/executor.py:2782).
Сохраняем inline_data/inlineData как есть; текстовые части — только непустые.
"""
out: List[Dict[str, Any]] = []
for it in (arr or []):
if not isinstance(it, dict):
continue
parts = it.get("parts") or []
norm_parts = []
for p in parts:
if isinstance(p, dict):
t = p.get("text")
if isinstance(t, str) and t.strip():
norm_parts.append({"text": t})
elif "inline_data" in p or "inlineData" in p:
norm_parts.append(p) # изображения пропускаем как есть
if norm_parts:
out.append({"role": it.get("role", "user"), "parts": norm_parts})
return out
def extract_system_text_from_obj(self, x: Any, render_ctx: Dict[str, Any]) -> Optional[str]:
"""
Совместимо с [_extract_sys_text_from_obj()](agentui/pipeline/executor.py:2676) для Gemini.
"""
try:
# Dict
if isinstance(x, dict):
if "systemInstruction" in x:
si = x.get("systemInstruction")
def _parts_to_text(siobj: Any) -> str:
try:
parts = siobj.get("parts") or []
texts = [
str(p.get("text") or "")
for p in parts
if isinstance(p, dict) and isinstance(p.get("text"), str) and p.get("text").strip()
]
return "\n".join([t for t in texts if t]).strip()
except Exception:
return ""
if isinstance(si, dict):
t = _parts_to_text(si)
if t:
return t
if isinstance(si, list):
texts = []
for p in si:
if isinstance(p, dict) and isinstance(p.get("text"), str) and p.get("text").strip():
texts.append(p.get("text").strip())
t = "\n".join(texts).strip()
if t:
return t
if isinstance(si, str) and si.strip():
return si.strip()
# OpenAI system внутри messages
if isinstance(x.get("messages"), list):
sys_msgs = []
for m in (x.get("messages") or []):
try:
if (str(m.get("role") or "").lower().strip() == "system"):
cont = m.get("content")
if isinstance(cont, str) and cont.strip():
sys_msgs.append(cont.strip())
elif isinstance(cont, list):
for p in cont:
if (
isinstance(p, dict)
and p.get("type") == "text"
and isinstance(p.get("text"), str)
and p.get("text").strip()
):
sys_msgs.append(p.get("text").strip())
except Exception:
continue
if sys_msgs:
return "\n\n".join(sys_msgs).strip()
# List
if isinstance(x, list):
if all(isinstance(m, dict) and "role" in m for m in x):
sys_msgs = []
for m in x:
try:
if (str(m.get("role") or "").lower().strip() == "system"):
cont = m.get("content")
if isinstance(cont, str) and cont.strip():
sys_msgs.append(cont.strip())
elif isinstance(cont, list):
for p in cont:
if (
isinstance(p, dict)
and p.get("type") == "text"
and isinstance(p.get("text"), str)
and p.get("text").strip()
):
sys_msgs.append(p.get("text").strip())
except Exception:
continue
if sys_msgs:
return "\n\n".join(sys_msgs).strip()
# Gemini contents list -> попробуем взять из входящего snapshot
if all(isinstance(c, dict) and "parts" in c for c in x):
try:
inc = (render_ctx.get("incoming") or {}).get("json") or {}
si = inc.get("systemInstruction")
if si is not None:
return self.extract_system_text_from_obj({"systemInstruction": si}, render_ctx)
except Exception:
pass
return None
except Exception:
return None
def combine_segments(
self,
blocks_struct: Dict[str, Any],
pre_segments_raw: List[Dict[str, Any]],
raw_segs: List[str],
render_ctx: Dict[str, Any],
pre_var_paths: set[str],
render_template_simple_fn,
var_macro_fullmatch_re,
detect_vendor_fn,
) -> Dict[str, Any]:
"""
Повторяет ветку provider in {'gemini','gemini_image'} из prompt_combine
([ProviderCallNode.run()](agentui/pipeline/executor.py:2874)).
"""
built: List[Dict[str, Any]] = []
sys_texts: List[str] = []
# 1) Пред‑сегменты
for _pre in (pre_segments_raw or []):
try:
_obj = _pre.get("obj")
items = self.normalize_segment(_obj)
items = self.filter_items(items)
built = insert_items(built, items, _pre.get("pos"))
try:
sx = self.extract_system_text_from_obj(_obj, render_ctx)
if isinstance(sx, str) and sx.strip():
sys_texts.append(sx.strip())
except Exception:
pass
except Exception:
pass
# 2) Основные сегменты
for raw_seg in (raw_segs or []):
body_seg, pos_spec = split_pos_spec(raw_seg)
if body_seg == "[[PROMPT]]":
items = self.filter_items(list(blocks_struct.get("contents", []) or []))
built = insert_items(built, items, pos_spec)
continue
m_pre = var_macro_fullmatch_re.fullmatch(body_seg)
if m_pre:
_p = (m_pre.group(1) or "").strip()
try:
if _p in pre_var_paths:
continue
except Exception:
pass
resolved = render_template_simple_fn(body_seg, render_ctx, render_ctx.get("OUT") or {})
obj = _try_json(resolved)
# debug provider guess
try:
pg = detect_vendor_fn(obj if isinstance(obj, dict) else {})
print(f"DEBUG: prompt_combine seg provider_guess={pg} -> target=gemini pos={pos_spec}")
except Exception:
pass
items = self.normalize_segment(obj if obj is not None else resolved)
items = self.filter_items(items)
built = insert_items(built, items, pos_spec)
try:
sx = self.extract_system_text_from_obj(obj, render_ctx) if obj is not None else None
if isinstance(sx, str) and sx.strip():
sys_texts.append(sx.strip())
except Exception:
pass
if not built:
built = self.filter_items(list(blocks_struct.get("contents", []) or []))
# Merge systemInstruction: PROMPT blocks + gathered sys_texts
existing_si = blocks_struct.get("systemInstruction")
parts = []
if isinstance(existing_si, dict) and isinstance(existing_si.get("parts"), list):
parts = list(existing_si.get("parts") or [])
for s in sys_texts:
parts.append({"text": s})
new_si = {"parts": parts} if parts else existing_si
return {"contents": built, "systemInstruction": new_si, "system_text": blocks_struct.get("system_text")}
def prompt_fragment(self, pm_struct: Dict[str, Any], node_config: Dict[str, Any]) -> str:
"""
Совместимо с веткой provider in {'gemini','gemini_image'} в построении [[PROMPT]]
([ProviderCallNode.run()](agentui/pipeline/executor.py:3103)).
"""
parts = []
contents = pm_struct.get("contents")
if contents is not None:
parts.append('"contents": ' + json.dumps(contents, ensure_ascii=False))
sysi = pm_struct.get("systemInstruction")
if sysi is not None:
parts.append('"systemInstruction": ' + json.dumps(sysi, ensure_ascii=False))
return ", ".join(parts)
class GeminiImageAdapter(GeminiAdapter): # [GeminiImageAdapter.__init__()](agentui/providers/adapters/gemini.py:332)
name = "gemini_image"
# Вся логика такая же, как у Gemini (generateContent), включая defaults.

View File

@@ -0,0 +1,398 @@
from __future__ import annotations
import json
import re
from typing import Any, Dict, List, Optional, Tuple
from agentui.providers.adapters.base import ( # [ProviderAdapter](agentui/providers/adapters/base.py:10)
ProviderAdapter,
insert_items,
split_pos_spec,
)
def _try_json(s: str) -> Any: # [_try_json()](agentui/providers/adapters/openai.py:16)
"""
Парсит JSON из строки. Пермиссивный режим и двукратная распаковка строк, как в старой логике.
Возвращает dict/list/примитив или None при неудаче.
"""
try:
obj = json.loads(s)
except Exception:
try:
obj = json.loads(s, strict=False) # type: ignore[call-arg]
except Exception:
return None
# Если это строка, которая сама похожа на JSON — пробуем распаковать до 2 раз
for _ in range(2):
if isinstance(obj, str):
st = obj.strip()
if (st.startswith("{") and st.endswith("}")) or (st.startswith("[") and st.endswith("]")):
try:
obj = json.loads(st)
continue
except Exception:
break
break
return obj
class OpenAIAdapter(ProviderAdapter): # [OpenAIAdapter.__init__()](agentui/providers/adapters/openai.py:39)
name = "openai"
# --- Дефолты HTTP ---
def default_base_url(self) -> str:
return "https://api.openai.com"
def default_endpoint(self, model: str) -> str:
return "/v1/chat/completions"
# --- PROMPT: построение провайдерных структур ---
def blocks_struct_for_template(
self,
unified_messages: List[Dict[str, Any]],
context: Dict[str, Any],
node_config: Dict[str, Any],
) -> Dict[str, Any]:
"""
Совместимо с веткой provider=='openai' из [ProviderCallNode._blocks_struct_for_template()](agentui/pipeline/executor.py:1958).
"""
def _map(m: Dict[str, Any]) -> Dict[str, Any]:
c = m.get("content")
if isinstance(c, list):
parts = []
for p in c:
if isinstance(p, dict) and p.get("type") == "text":
parts.append({"type": "text", "text": str(p.get("text") or "")})
elif isinstance(p, dict) and p.get("type") in {"image_url", "image"}:
url = str(p.get("url") or "")
parts.append({"type": "image_url", "image_url": {"url": url}})
return {"role": m.get("role", "user"), "content": parts}
return {"role": m.get("role", "user"), "content": str(c or "")}
# system_text — склейка всех system-блоков (только текст, без картинок)
sys_text = "\n\n".join(
[
str(m.get("content") or "")
if not isinstance(m.get("content"), list)
else "\n".join(
[str(p.get("text") or "") for p in m.get("content") if isinstance(p, dict) and p.get("type") == "text"]
)
for m in (unified_messages or [])
if m.get("role") == "system"
]
).strip()
return {
"messages": [_map(m) for m in (unified_messages or [])],
"system_text": sys_text,
}
def normalize_segment(self, x: Any) -> List[Dict[str, Any]]:
"""
Совместимо с [_as_openai_messages()](agentui/pipeline/executor.py:2451).
- Поддерживает dict with messages (openai)
- Поддерживает dict/list в стиле Gemini.contents (склейка текстов частей)
- Поддерживает list openai-like messages (нормализация parts)
- Строки/прочее упаковываются как один user message
"""
msgs: List[Dict[str, Any]] = []
try:
# Dict inputs
if isinstance(x, dict):
if isinstance(x.get("messages"), list):
return list(x.get("messages") or [])
if isinstance(x.get("contents"), list):
# Gemini -> OpenAI (text-only join)
for c in (x.get("contents") or []):
if not isinstance(c, dict):
continue
role_raw = str(c.get("role") or "user")
role = "assistant" if role_raw == "model" else ("user" if role_raw not in {"user", "assistant"} else role_raw)
parts = c.get("parts") or []
text = "\n".join(
[str(p.get("text")) for p in parts if isinstance(p, dict) and isinstance(p.get("text"), str)]
).strip()
msgs.append({"role": role, "content": text})
return msgs
# List inputs
if isinstance(x, list):
# Gemini contents list -> OpenAI messages
if all(isinstance(c, dict) and "parts" in c for c in x):
for c in x:
role_raw = str(c.get("role") or "user")
role = "assistant" if role_raw == "model" else ("user" if role_raw not in {"user", "assistant"} else role_raw)
parts = c.get("parts") or []
text = "\n".join(
[str(p.get("text")) for p in parts if isinstance(p, dict) and isinstance(p.get("text"), str)]
).strip()
msgs.append({"role": role, "content": text})
return msgs
# OpenAI messages list already — normalize parts if needed
if all(isinstance(m, dict) and "content" in m for m in x):
out: List[Dict[str, Any]] = []
for m in x:
role = m.get("role", "user")
cont = m.get("content")
if isinstance(cont, str):
out.append({"role": role, "content": cont})
elif isinstance(cont, list):
parts2: List[Dict[str, Any]] = []
for p in cont:
if not isinstance(p, dict):
continue
if p.get("type") == "text":
parts2.append({"type": "text", "text": str(p.get("text") or "")})
elif p.get("type") in {"image_url", "image"}:
url = ""
if isinstance(p.get("image_url"), dict):
url = str((p.get("image_url") or {}).get("url") or "")
elif "url" in p:
url = str(p.get("url") or "")
if url:
parts2.append({"type": "image_url", "image_url": {"url": url}})
out.append({"role": role, "content": parts2 if parts2 else ""})
return out
# Fallback: dump JSON as a single user message
return [{"role": "user", "content": json.dumps(x, ensure_ascii=False)}]
# Primitive inputs or embedded JSON string
if isinstance(x, str):
try_obj = _try_json(x)
if try_obj is not None:
return self.normalize_segment(try_obj)
return [{"role": "user", "content": x}]
return [{"role": "user", "content": json.dumps(x, ensure_ascii=False)}]
except Exception:
return [{"role": "user", "content": str(x)}]
def filter_items(self, arr: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Совместимо с [_filter_openai()](agentui/pipeline/executor.py:2801).
"""
out: List[Dict[str, Any]] = []
for m in (arr or []):
if not isinstance(m, dict):
continue
c = m.get("content")
if isinstance(c, str) and c.strip():
out.append({"role": m.get("role", "user"), "content": c})
elif isinstance(c, list):
parts = []
for p in c:
if isinstance(p, dict) and p.get("type") == "text":
txt = str(p.get("text") or "")
if txt.strip():
parts.append({"type": "text", "text": txt})
if parts:
out.append({"role": m.get("role", "user"), "content": parts})
return out
def extract_system_text_from_obj(self, x: Any, render_ctx: Dict[str, Any]) -> Optional[str]:
"""
Совместимо с [_extract_sys_text_from_obj()](agentui/pipeline/executor.py:2676).
Умеет читать:
- Gemini: systemInstruction.parts[].text
- Claude: top-level system (string/list of blocks)
- OpenAI: messages[*] with role=system (string content or parts[].text)
- List форматы: openai messages list и gemini contents list (в последнем случае смотрит incoming.json.systemInstruction)
"""
try:
# Dict objects
if isinstance(x, dict):
# Gemini systemInstruction
if "systemInstruction" in x:
si = x.get("systemInstruction")
def _parts_to_text(siobj: Any) -> str:
try:
parts = siobj.get("parts") or []
texts = [
str(p.get("text") or "")
for p in parts
if isinstance(p, dict) and isinstance(p.get("text"), str) and p.get("text").strip()
]
return "\n".join([t for t in texts if t]).strip()
except Exception:
return ""
if isinstance(si, dict):
t = _parts_to_text(si)
if t:
return t
if isinstance(si, list):
texts = []
for p in si:
if isinstance(p, dict) and isinstance(p.get("text"), str) and p.get("text").strip():
texts.append(p.get("text").strip())
t = "\n".join(texts).strip()
if t:
return t
if isinstance(si, str) and si.strip():
return si.strip()
# Claude system (string or blocks)
if "system" in x and not ("messages" in x and isinstance(x.get("messages"), list)):
sysv = x.get("system")
if isinstance(sysv, str) and sysv.strip():
return sysv.strip()
if isinstance(sysv, list):
texts = [
str(b.get("text") or "")
for b in sysv
if isinstance(b, dict) and (b.get("type") == "text") and isinstance(b.get("text"), str) and b.get("text").strip()
]
t = "\n".join([t for t in texts if t]).strip()
if t:
return t
# OpenAI messages with role=system
if isinstance(x.get("messages"), list):
sys_msgs = []
for m in (x.get("messages") or []):
try:
if (str(m.get("role") or "").lower().strip() == "system"):
cont = m.get("content")
if isinstance(cont, str) and cont.strip():
sys_msgs.append(cont.strip())
elif isinstance(cont, list):
for p in cont:
if (
isinstance(p, dict)
and p.get("type") == "text"
and isinstance(p.get("text"), str)
and p.get("text").strip()
):
sys_msgs.append(p.get("text").strip())
except Exception:
continue
if sys_msgs:
return "\n\n".join(sys_msgs).strip()
# List objects
if isinstance(x, list):
# OpenAI messages list with role=system
if all(isinstance(m, dict) and "role" in m for m in x):
sys_msgs = []
for m in x:
try:
if (str(m.get("role") or "").lower().strip() == "system"):
cont = m.get("content")
if isinstance(cont, str) and cont.strip():
sys_msgs.append(cont.strip())
elif isinstance(cont, list):
for p in cont:
if (
isinstance(p, dict)
and p.get("type") == "text"
and isinstance(p.get("text"), str)
and p.get("text").strip()
):
sys_msgs.append(p.get("text").strip())
except Exception:
continue
if sys_msgs:
return "\n\n".join(sys_msgs).strip()
# Gemini 'contents' list: try to read systemInstruction from incoming JSON snapshot
if all(isinstance(c, dict) and "parts" in c for c in x):
try:
inc = (render_ctx.get("incoming") or {}).get("json") or {}
si = inc.get("systemInstruction")
if si is not None:
# Рекурсивно используем себя
return self.extract_system_text_from_obj({"systemInstruction": si}, render_ctx)
except Exception:
pass
return None
except Exception:
return None
def combine_segments(
self,
blocks_struct: Dict[str, Any],
pre_segments_raw: List[Dict[str, Any]],
raw_segs: List[str],
render_ctx: Dict[str, Any],
pre_var_paths: set[str],
render_template_simple_fn,
var_macro_fullmatch_re,
detect_vendor_fn,
) -> Dict[str, Any]:
"""
Повторяет ветку provider=='openai' из prompt_combine в [ProviderCallNode.run()](agentui/pipeline/executor.py:2936).
"""
built: List[Dict[str, Any]] = []
sys_texts: List[str] = []
# 1) Пред‑сегменты (prompt_preprocess)
for _pre in (pre_segments_raw or []):
try:
_obj = _pre.get("obj")
items = self.normalize_segment(_obj)
items = self.filter_items(items)
built = insert_items(built, items, _pre.get("pos"))
try:
sx = self.extract_system_text_from_obj(_obj, render_ctx)
if isinstance(sx, str) and sx.strip():
sys_texts.append(sx.strip())
except Exception:
pass
except Exception:
pass
# 2) Основные сегменты (prompt_combine)
for raw_seg in (raw_segs or []):
body_seg, pos_spec = split_pos_spec(raw_seg)
if body_seg == "[[PROMPT]]":
items = self.filter_items(list(blocks_struct.get("messages", []) or []))
built = insert_items(built, items, pos_spec)
continue
# Спрятать дубли plain [[VAR:path]] если уже вставляли этим путём в pre_var_overrides
m_pre = var_macro_fullmatch_re.fullmatch(body_seg)
if m_pre:
_p = (m_pre.group(1) or "").strip()
try:
if _p in pre_var_paths:
# Уже вставлено через prompt_preprocess с фильтрацией — пропускаем
continue
except Exception:
pass
resolved = render_template_simple_fn(body_seg, render_ctx, render_ctx.get("OUT") or {})
obj = _try_json(resolved)
# debug provider guess
try:
pg = detect_vendor_fn(obj if isinstance(obj, dict) else {})
print(f"DEBUG: prompt_combine seg provider_guess={pg} -> target=openai pos={pos_spec}")
except Exception:
pass
items = self.normalize_segment(obj if obj is not None else resolved)
items = self.filter_items(items)
built = insert_items(built, items, pos_spec)
try:
sx = self.extract_system_text_from_obj(obj, render_ctx) if obj is not None else None
if isinstance(sx, str) and sx.strip():
sys_texts.append(sx.strip())
except Exception:
pass
# Если ничего не собрали — берём исходные blocks
if not built:
built = self.filter_items(list(blocks_struct.get("messages", []) or []))
# Препендинг системных сообщений из sys_texts
if sys_texts:
sys_msgs = [{"role": "system", "content": s} for s in sys_texts if s]
if sys_msgs:
built = sys_msgs + built
# keep system_text for UI/debug
st0 = blocks_struct.get("system_text") or ""
st = "\n\n".join([t for t in [st0] + sys_texts if isinstance(t, str) and t.strip()])
return {"messages": built, "system_text": st}
def prompt_fragment(self, pm_struct: Dict[str, Any], node_config: Dict[str, Any]) -> str:
"""
Совместимо с веткой provider=='openai' в построении [[PROMPT]] из [ProviderCallNode.run()](agentui/pipeline/executor.py:3103).
"""
return '"messages": ' + json.dumps(pm_struct.get("messages", []), ensure_ascii=False)

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from typing import Optional
from agentui.providers.adapters.base import ProviderAdapter, default_base_url_for as _default_base_url_for
from agentui.providers.adapters.openai import OpenAIAdapter
try:
from agentui.providers.adapters.gemini import GeminiAdapter, GeminiImageAdapter
except Exception:
GeminiAdapter = None # type: ignore
GeminiImageAdapter = None # type: ignore
try:
from agentui.providers.adapters.claude import ClaudeAdapter
except Exception:
ClaudeAdapter = None # type: ignore
def get_adapter(provider: str) -> Optional[ProviderAdapter]:
p = (provider or "").strip().lower()
if p == "openai":
return OpenAIAdapter()
if p == "gemini" and GeminiAdapter:
return GeminiAdapter() # type: ignore[operator]
if p == "gemini_image" and GeminiImageAdapter:
return GeminiImageAdapter() # type: ignore[operator]
if p == "claude" and ClaudeAdapter:
return ClaudeAdapter() # type: ignore[operator]
return None
def default_base_url_for(vendor: str) -> Optional[str]:
return _default_base_url_for(vendor)

View File

@@ -1,14 +1,57 @@
from __future__ import annotations
import httpx
from typing import Optional, Dict
from agentui.config import build_httpx_proxies
from typing import Optional, Dict, Union
import os
from agentui.config import build_httpx_proxies, get_tls_verify, is_verify_explicit
def _mask_proxy(url: str) -> str:
"""Маскируем часть с логином/паролем в URL прокси, чтобы не утекла в логи."""
try:
if "://" in url and "@" in url:
prefix, rest = url.split("://", 1)
auth, host = rest.split("@", 1)
return f"{prefix}://***@{host}"
return url
except Exception:
return "<masked>"
def build_client(timeout: float = 60.0) -> httpx.AsyncClient:
proxies: Optional[Dict[str, str]] = build_httpx_proxies()
verify: Union[bool, str] = get_tls_verify()
explicit = is_verify_explicit()
# По умолчанию при наличии прокси отключаем проверку сертификатов,
# но не трогаем, если пользователь явно задал verify или CA.
if proxies and (verify is True) and (not explicit):
verify = False
if os.getenv("AGENTUI_DEBUG", "").lower() in ("1", "true", "on", "yes"):
masked = {k: _mask_proxy(v) for k, v in (proxies or {}).items()}
print("[agentui.http_client] proxies=", masked, " verify=", verify)
# httpx сам понимает схемы socks://, socks5:// при установленном extras [socks]
client = httpx.AsyncClient(timeout=timeout, proxies=proxies, follow_redirects=True)
try:
client = httpx.AsyncClient(
timeout=timeout,
proxies=proxies,
follow_redirects=True,
verify=verify,
)
except TypeError:
if proxies:
try:
masked = {k: _mask_proxy(v) for k, v in proxies.items()}
except Exception:
masked = proxies
print(f"[agentui.http_client] WARNING: proxies not supported in httpx.AsyncClient, skipping proxies={masked}")
client = httpx.AsyncClient(
timeout=timeout,
follow_redirects=True,
verify=verify,
)
return client

View File

@@ -1,328 +1,665 @@
# Переменные и макросы НадTavern
Краткая, человеко‑понятная шпаргалка по тому, какие переменные и макросы доступны в шаблонах (в том числе в Prompt Blocks), как они устроены и как их правильно использовать. Док ниже соответствует текущему коду.
НАДTAVERN VARIABLES — ГАЙД ДЛЯ ТЕХ, КТО СЕГОДНЯ «НА МИНИМАЛКАХ» И ВСЁ РАВНО ХОЧЕТ, ЧТОБЫ РАБОТАЛО
Реализация формирует единый «контекст» переменных для всех нод пайплайна, дополняет его выходами уже выполненных нод, а узел ProviderCall добавляет свои служебные структуры для удобной сборки промпта.
Смотри сюда, слабак. Я — твой наидобрейший цун-энциклопедист, и сейчас я очень терпеливо (фрр) объясню так, чтобы даже ты не накосячил. Прочитаешь до конца — и у тебя получится. Может быть. Если постараешься. М-м… не думай, что я делаю это ради тебя!
Ссылки на код:
- Формирование контекста запроса: [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)
- Источники истины (это значит «код, который реально решает», а не чаты):
- Исполнение пайплайна: [PipelineExecutor.run()](agentui/pipeline/executor.py:402)
- Нода SetVars — выражения и функции: [SetVarsNode._safe_eval_expr()](agentui/pipeline/executor.py:1290)
- Нода ProviderCall — вызов провайдера и PROMPT: [ProviderCallNode.run()](agentui/pipeline/executor.py:2084)
- Нода RawForward — прямой прокси: [RawForwardNode.run()](agentui/pipeline/executor.py:3547)
- Нода Return — формат финального ответа: [ReturnNode.run()](agentui/pipeline/executor.py:3930)
- Нода If — парсер условий: [IfNode.run()](agentui/pipeline/executor.py:4024)
- While-обёртка для ProviderCall: [_providercall_run_with_while()](agentui/pipeline/executor.py:4075)
- While-обёртка для RawForward: [_rawforward_run_with_while()](agentui/pipeline/executor.py:4243)
- Шаблоны: [[...]] и {{ ... }} здесь: [render_template_simple()](agentui/pipeline/templating.py:205)
- Условия if/while (&&, ||, contains, скобочки): [eval_condition_expr()](agentui/pipeline/templating.py:391)
- JSONPath (упрощённый, но хватит): [_json_path_extract()](agentui/pipeline/executor.py:1569)
- Склейка текста при JSONPath: [_stringify_join()](agentui/pipeline/executor.py:1610)
- UI инспектор Prompt Blocks: [PM.setupProviderCallPMUI()](static/js/pm-ui.js:9)
- Экспорт/импорт пайплайна в редакторе: [AgentUISer.toPipelineJSON()](static/js/serialization.js:104), [AgentUISer.fromPipelineJSON()](static/js/serialization.js:286)
- Веб-редактор: [editor.html](static/editor.html)
---
Перед началом (термины в скобках — это определение, не морщи нос):
- «Пайплайн» (pipeline — схема исполнения из «узлов»).
- «Узел» (node — прямоугольный блок на канвасе).
- «Порт» (port — кружок входа/выхода узла).
- «Гейт» (gate — ветка выхода If.true/If.false; влияет на порядок выполнения ребёнка).
- «STORE» (перманентное хранилище переменных на диск, одна запись на каждый pipeline.id).
- «PROMPT» (специальный JSON-фрагмент сообщений, который подставляется в шаблон запроса провайдера).
- «OUTx» (короткая ссылка на текст из выхода ноды nX).
- «incoming» (снимок входящего HTTP-запроса: метод, URL, заголовки, JSON и т.д.).
## 1) Общие переменные контекста (для всех нод)
Эти переменные доступны в шаблонах любой ноды. Они добавляются на стороне сервера при обработке входящего HTTPзапроса.
РАЗДЕЛ 1 — НОДЫ: КТО ЕСТЬ КТО (КРАТКО, ШУТКИ В СТОРОНУ)
- model — строка с именем модели.
Пример: "gpt-4o-mini"
- vendor_format — вендор/протокол запроса: "openai" | "gemini" | "claude" | "unknown"
- system — «системный» текст, если он был во входящем запросе; иначе пустая строка.
1) SetVars (заводит твои переменные)
- Входы: нет (только depends).
- Выходы: vars — словарь новых переменных.
- Поведение: для каждой переменной задаёшь name и mode (string или expr). В режиме string значение обрабатывается шаблоном ([[...]] и {{ ... }}), в режиме expr — безопасным мини-диалектом выражений.
- Где смотреть реализацию: [SetVarsNode._safe_eval_expr()](agentui/pipeline/executor.py:1197).
- params — стандартные параметры генерации (можно использовать как дефолты)
- params.temperature — число с плавающей точкой (по умолчанию 0.7)
- params.max_tokens — целое или null
- params.top_p — число (по умолчанию 1.0)
- params.stop — массив строк или null
2) If (ветвление по условию)
- Входы: depends.
- Выходы: true, false (гейты для «детей» по условию).
- Поведение: expr парсится как булево выражение (&&, ||, !, ==, !=, <, <=, >, >=, contains, скобочки). Внутри можно использовать [[...]] и {{ ... }}.
- Реализация парсера: [eval_condition_expr()](agentui/pipeline/templating.py:391), обёртка ноды: [IfNode.run()](agentui/pipeline/executor.py:3538).
- chat — сведения о чате во входящем запросе
- chat.last_user — последнее сообщение пользователя (строка)
- chat.messages — массив сообщений в унифицированной форме:
- role — "system" | "user" | "assistant" | "tool"
- content — содержимое (обычно строка)
- name — опционально, строка
- tool_call_id — опционально
3) ProviderCall (отправка к провайдеру OpenAI/Gemini/Claude)
- Входы: depends.
- Выходы: result (сырой JSON ответа), response_text (извлечённый «текст»).
- Ключи: provider, provider_configs (base_url, endpoint, headers, template), blocks (Prompt Blocks), prompt_combine (DSL &), while_expr/while_max_iters/ignore_errors, text_extract_*.
- Реализация: [ProviderCallNode.run()](agentui/pipeline/executor.py:1991).
- 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
4) RawForward (прямой прокси)
- Входы: depends.
- Выходы: result, response_text.
- Ключи: base_url (может автоопределяться по входящему JSON-вендору), override_path, passthrough_headers, extra_headers, while_expr.
- Реализация: [RawForwardNode.run()](agentui/pipeline/executor.py:3105).
Пример использования в шаблоне:
- [[VAR:incoming.api_keys.key]] — возьмёт ключ из строки запроса (?key=...).
- [[VAR:incoming.headers.x-api-key]] — возьмёт ключ из заголовка x-api-key (типично для Anthropic).
- {{ params.temperature|default(0.7) }} — безопасно подставит число, если не задано во входящих данных.
5) Return (оформление финального ответа для клиента)
- Входы: depends.
- Выходы: result (в формате openai/gemini/claude/auto), response_text (то, что вставили).
- Ключи: target_format (auto/openai/gemini/claude), text_template (по умолчанию [[OUT1]]).
- Реализация: [ReturnNode.run()](agentui/pipeline/executor.py:3444).
---
Под капотом все узлы гоняет исполнитель «волнами» или итеративно:
- Главная точка входа: [PipelineExecutor.run()](agentui/pipeline/executor.py:316).
- И есть режим retry/циклов в узлах ProviderCall/RawForward — см. while в Разделе 5.
## 2) Выходы нод (OUT) и ссылки на них
Во время исполнения пайплайна результаты предыдущих нод собираются в снапшот OUT и доступны при рендере шаблонов следующих нод:
РАЗДЕЛ 2 — ПЕРЕМЕННЫЕ И МАКРОСЫ ([[...]] ПРОТИВ {{ ... }}) С 12 ПРИМЕРАМИ
- OUT — словарь выходов нод, ключи — id нод в пайплайне (например, "n1", "n2").
- OUT.n1, OUT.n2, ... — объект результата соответствующей ноды.
Смысл (запомни, ладно?):
- [[...]] (квадратные макросы) — текстовая подстановка со строкификацией (всегда превращается в строку, объекты — в JSON-строку).
- {{ ... }} (фигурные вставки) — типобезопасная подстановка «как есть» (числа остаются числами, объекты — объектами), а ещё есть фильтр |default(...).
Формы доступа:
- Полная форма: [[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)).
Доступные макросы (см. [render_template_simple()](agentui/pipeline/templating.py:205)):
- [[VAR:путь]] — берёт значение по пути из контекста (context/incoming/params/...).
- [[OUT:nodeId(.path)*]] — берёт из выходов ноды (сырой JSON).
- [[OUTx]] — короткая форма текста из ноды nX (best-effort).
- [[STORE:путь]] — читает из стойкого хранилища (store.*).
- [[NAME]] — «голая» переменная: сперва ищется в пользовательских переменных (SetVars), иначе в контексте по пути.
- [[PROMPT]] — провайдерный JSON-фрагмент сообщений (см. Раздел 6).
- Доп. сахар: img(mime)[[...]] → «data:mime;base64,ЗНАЧЕНИЕ» (см. [templating._IMG_WRAPPER_RE](agentui/pipeline/templating.py:41)).
Что возвращают встроенные ноды:
- ProviderCall:
- OUT.nX.result — сырой JSON ответа провайдера
- OUT.nX.response_text — уже извлечённый «лучший текст» (строка)
- RawForward:
- OUT.nX.result — JSON, как пришёл от апстрима (или {"error": "...", "text": "..."} при неJSON ответе)
Фигурные {{ ... }}:
- {{ OUT.n2.result.choices.0.message.content }} — доступ к JSON как к полям.
- {{ путь|default(значение) }} — цепочки дефолтов, поддерживает вложенность и JSON-литералы в default(...).
Подсказка по короткой форме [[OUTx]]:
- OpenAI: вернёт choices[0].message.content
- Gemini: вернёт candidates[0].content.parts[0].text
- Claude: склеит content[].text
- Если явных полей нет — выполнит «глубокий поиск» по ключам "text"/"content"
12 примеров (пониже пояса — для тех, кто любит копипасту):
1) Заголовок авторизации в JSON-строке:
{"Authorization":"Bearer [[VAR:incoming.headers.authorization]]"}
Объяснение: [[VAR:...]] берёт заголовок из входа (incoming.headers.authorization).
---
2) Провайдерная модель «как пришла» (фигурные):
"model": "{{ model }}"
Объяснение: {{ ... }} вставляет строку без кавычек лишний раз.
## 3) Макросы подстановки и синтаксис
3) Число по умолчанию:
"temperature": {{ incoming.json.temperature|default(0.7) }}
Объяснение: default(0.7) сработает, если температуры нет.
В шаблонах доступны обе формы подстановки:
4) Лист по умолчанию:
"stop": {{ incoming.json.stop|default([]) }}
Объяснение: вставляет [] как настоящий массив.
1) Квадратные скобки [[ ... ]] — простая подстановка
- [[VAR:путь]] — взять значение из контекста по точечному пути
Пример: [[VAR:incoming.json.max_tokens]]
- [[OUT:путь]] — взять значение из OUT (см. раздел выше)
Пример: [[OUT:n1.result.choices.0.message.content]]
- [[OUT1]] / [[OUT2]] — короткая форма «просто текст»
- [[PROMPT]] — специальный JSONфрагмент из Prompt Blocks (см. ниже)
5) Короткая вытяжка текста из ноды n2:
"note": "[[OUT2]]"
Объяснение: [[OUT2]] — best-effort текст из ответа.
2) Фигурные скобки {{ ... }} — «джинджа‑лайт»
- {{ путь }} — взять значение по пути из контекста (или из OUT.* если начать с OUT.)
Пример: {{ OUT.n1.result }}
- Фильтр по умолчанию: {{ что-то|default(значение) }}
Примеры:
- {{ params.temperature|default(0.7) }}
- {{ incoming.json.stop|default([]) }}
- {{ anthropic_version|default('2023-06-01') }} — см. «Опциональные поля» ниже
- Фигурные скобки удобны там, где нужно вставить внутрь JSON не строку, а ЧИСЛО/ОБЪЕКТ/МАССИВ без кавычек и/или задать дефолт.
6) Точное поле из результата:
"[[OUT:n2.result.choices.0.message.content]]"
Объяснение: берёт конкретную ветку JSON из OUT ноды n2.
---
7) «Голая» переменная SetVars:
"key": "[[MyOpenAiKey]]"
Объяснение: имя без VAR/OUT — сперва ищется среди переменных.
## 4) ProviderCall: Prompt Blocks, pm.* и [[PROMPT]]
8) STORE (между прогонами):
"{{ STORE.KEEP|default('miss') }}"
Объяснение: из стойкого хранилища (если clear_var_store=False).
Узел ProviderCall собирает ваши Prompt Blocks (блоки вида: роль/текст/вкл‑выкл/порядок) в стандартные «сообщения» и превращает их в структуру для конкретного провайдера.
9) Прокинуть запрос как есть в Gemini:
[[VAR:incoming.json.contents]]
Объяснение: квадратные дадут строку (для template это ок: JSON-строка без лишних кавычек — см. PROMPT).
Внутри шаблонов этого узла доступны:
- 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:"..."}] }
10) JSON-путь с фигурными:
{{ OUT.n1.result.obj.value|default(0) }}
Объяснение: берёт число или 0.
- [[PROMPT]] — готовый JSONфрагмент на основе pm, безопасный для вставки внутрь шаблона:
- OpenAI → подставит: "messages": [...]
- Gemini → подставит: "contents": [...], "systemInstruction": {...}
- Claude → подставит: "system": "...", "messages": [...]
11) Картинка из base64 переменной (img()):
"image": "![pic](img(jpeg)[[IMG_B64]])"
Объяснение: заменится на data:image/jpeg;base64,....
Зачем это нужно?
- Чтобы 1) удобно собирать промпт из визуальных блоков, 2) не «сломать» JSON руками.
Вы можете вручную использовать {{ pm.* }}, но [[PROMPT]] — рекомендуемый и самый безопасный вариант.
12) Сложная строка с несколькими макросами:
"msg": "User=[[VAR:chat.last_user]] | Echo=[[OUT1]]"
Объяснение: комбинируй сколько хочешь, лишь бы JSON остался валидным.
---
## 5) Частые сценарии и примеры
РАЗДЕЛ 3 — SETVARS: ВЫРАЖЕНИЯ, РАЗРЕШЁННЫЕ ФУНКЦИИ, ОПАСНО НЕ БУДЕМ (10+ ПРИМЕРОВ)
Примеры ниже можно вклеивать в поле «template» ноды ProviderCall. Они уже используют [[PROMPT]] и аккуратные дефолты.
Где код: [SetVarsNode._safe_eval_expr()](agentui/pipeline/executor.py:1197). Он парсит мини-язык через AST, ничего небезопасного не позволит.
OpenAI (POST /v1/chat/completions):
```
{
"model": "{{ model }}",
[[PROMPT]],
"temperature": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},
"top_p": {{ incoming.json.top_p|default(params.top_p|default(1)) }},
"max_tokens": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},
"stop": {{ incoming.json.stop|default(params.stop|default([])) }}
}
```
Разрешено:
- Литералы: числа, строки, true/false/null (JSON-стиль), списки [...], объекты {...}.
- Операции: + - * / // % и унарные + -, сравнения == != < <= > >=, логика and/or.
- Вызовы ТОЛЬКО упомянутых функций (без kwargs, без *args):
- rand() → float [0,1)
- randint(a,b) → int в [a,b]
- choice(list) → элемент списка/кортежа
- from_json(x) → распарсить строку JSON
- jp(value, path, join_sep="\n") → извлечь по JSONPath (см. Раздел 7)
- jp_text(value, path, join_sep="\n") → JSONPath + склейка строк
- file_b64(path) → прочитать файл и вернуть base64-строку
- data_url(b64, mime) → "data:mime;base64,b64"
- file_data_url(path, mime?) → прочитать файл и собрать data URL
Подсказка: аргументы функций прогоняются через шаблон рендера, так что внутрь jp/… можно передавать строки с [[...]]/{{...}} — они сначала развернутся.
Gemini (POST /v1beta/models/{model}:generateContent):
```
{
"model": "{{ model }}",
[[PROMPT]],
"safetySettings": {{ incoming.json.safetySettings|default([]) }},
"generationConfig": {
"temperature": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},
"topP": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},
"maxOutputTokens": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},
"stopSequences": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }}
}
}
```
Подсказка: ключ Gemini удобно брать из строки запроса:
в endpoint используйте …?key=[[VAR:incoming.api_keys.key]]
Нельзя:
- Любые имена/доступы к атрибутам/индексации вне списка/словаря литералом.
- Любые другие функции, чем перечисленные.
- kwargs/starargs.
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)) }},
"system": {{ pm.system|default("") }}
}
```
Подсказка: ключ Anthropic обычно передают в заголовке x-api-key.
В UIпресете это поле уже есть в headers.
10+ примеров SetVars (mode=expr):
1) Чистая математика:
128 + 64
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') }}"}`
2) Случайное число:
rand()
---
3) Случайное из списка:
choice(["red","green","blue"])
## 6) Опциональные/редкие поля, о которых стоит знать
4) Безопасный int-диапазон:
randint(10, 20)
- anthropic_version — используется как HTTPзаголовок для Claude ("anthropic-version"). В тело запроса не вставляется.
Если нужен дефолт, задавайте его в headers (например, в конфиге ноды/шаблоне заголовков). В шаблонах тела используйте [[PROMPT]]/pm.* без anthropic_version.
5) from_json + доступ через jp:
jp(from_json("{\"a\":{\"b\":[{\"x\":1},{\"x\":2}]}}"), "a.b.1.x") → 2
- stream — в MVP стриминг отключён, сервер принудительно не стримит ответ.
В шаблонах можно встретить поля stream, но по умолчанию они не включены.
6) jp_text (склейка строк через « | »):
jp_text(from_json("{\"items\":[{\"t\":\"A\"},{\"t\":\"B\"},{\"t\":\"C\"}]}"), "items.*.t", " | ") → "A | B | C"
---
7) Вытянуть из OUT (с шаблонной подстановкой):
jp({{ OUT.n2.result }}, "choices.0.message.content") → текст первого ответа
## 7) Когда использовать [[...]] и когда {{ ... }}
8) Собрать data URL из файла:
file_data_url("./img/cat.png", "image/png")
- Внутрь JSON как ОБЪЕКТ/МАССИВ/ЧИСЛО: используйте {{ ... }}
(фигурные скобки вставляют «как есть», без кавычек, и умеют |default(...))
- Для строк/URL/заголовков/простых значений: можно использовать [[...]]
(квадратные скобки удобны и короче писать)
9) Ручная сборка data URL из base64:
data_url([[IMG_B64]], "image/jpeg")
10) Преобразование строки JSON:
from_json("[1,2,3]") → список [1,2,3]
11) Комбо с логикой:
(rand() > 0.5) and "HEADS" or "TAILS"
12) Вложенные вызовы:
choice(jp(from_json("[{\"v\":10},{\"v\":20}]"), "*.v")) → 10 или 20
Результат SetVars попадает в:
- Текущие «user vars» (сразу доступны как [[NAME]] и {{ NAME }}).
- STORE (персистентно) — см. Раздел 8, если clear_var_store=False.
РАЗДЕЛ 4 — IF: ВЫРАЖЕНИЯ, ОПЕРАТОРЫ, 12 ГРОМКИХ ПРИМЕРОВ
Парсер условий: [eval_condition_expr()](agentui/pipeline/templating.py:391). Он превращает видимые тобой токены в безопасное AST и вычисляет.
Операторы:
- Логика: && (and), || (or), ! (not)
- Сравнение: ==, !=, <, <=, >, >=
- Специальный contains (как функция contains(a,b)): для строк — подстрока; для списков — membership.
- Скобки ( ... )
- Литералы: числа, "строки" или 'строки' (без экранирования внутри), true/false/null (через макросы из контекста).
- Макросы: [[...]] и {{ ... }} допустимы прямо внутри выражения (они сначала раскрываются в значения).
12 примеров (да-да, трижды проверено, хватит ныть):
1) Проверить, что [[OUT1]] содержит «ok»:
[[OUT1]] contains "ok"
2) Проверка статуса:
{{ OUT.n2.result.status|default(0) }} >= 200 && {{ OUT.n2.result.status|default(0) }} < 300
3) Инверсия:
!([[OUT3]] contains "error")
4) Сравнить переменную:
[[LANG]] == "ru"
5) Двойная логика:
([[MSG]] contains "Hello") || ([[MSG]] contains "Привет")
6) Цепочка со скобками:
( [[CITY]] == "Moscow" && {{ params.max_tokens|default(0) }} > 0 ) || [[FALLBACK]] == "yes"
7) Списки и contains:
contains(["a","b","c"], "b")
8) Числа и сравнения:
{{ OUT.n1.result.value|default(0) }} >= 10
9) Пустые значения:
{{ missing|default("") }} == ""
10) Комбо macOS:
contains([[VAR:incoming.url]], "/v1/") && ([[VAR:incoming.method]] == "POST")
11) Несколько слоёв default():
{{ incoming.json.limit|default(params.limit|default(100)) }} > 50
12) Сложное условие с OUT пути:
[[OUT:n2.result.choices.0.message.content]] contains "done"
Помни: If только выставляет флаги true/false на выходах. «Дети» с входом depends="nIf.true" запустятся только если условие истинно.
РАЗДЕЛ 4.1 — СПРАВОЧНИК ОПЕРАТОРОВ IF/WHILE (ПРОСТЫМИ СЛОВАМИ)
- !A — «не A» (инверсия). Пример: !( [[OUT3]] contains "err" ) → «строка из [[OUT3]] НЕ содержит "err"».
- A != B — «A не равно B». Пример: [[MODEL]] != "gemini-2.5-pro".
- A && B — «A и B одновременно».
- A || B — «A или B» (достаточно одного истинного).
- contains(A, B) — специальный оператор:
- если A — список/множество, это membership: contains(["a","b"], "a") → true
- иначе — проверка подстроки: contains("abc", "b") → true
- Запись "X contains Y" эквивалентна contains(X, Y).
- Скобки управляют приоритетами: !(A || B) отличается от (!A || B).
Где какой «язык» используется:
- Строковые поля (template, headers/extra_headers, base_url/override_path, Return.text_template, строки prompt_preprocess, сегменты prompt_combine) — это шаблоны с подстановками [[...]] и {{ ... }}.
- If.expr и while_expr — булевы выражения (&&, ||, !, ==, !=, <, <=, >, >=, contains, скобки) и допускают макросы [[...]] / {{ ... }} внутри.
- SetVars (mode=expr) — отдельный безопасный мини-язык (арифметика + - * / // %, and/or, сравнения) и whitelisted-функции: rand, randint, choice, from_json, jp, jp_text, file_b64, data_url, file_data_url.
Диагностика:
- В логах If/While печатается expanded — строковое раскрытие макросов — и result (true/false).
- Ошибка парсера (например, несбалансированные скобки) выводится как if_error/while_error и приводит к result=false.
РАЗДЕЛ 5 — WHILE В НОДАХ PROVIDERCALL/RAWFORWARD (РЕТРАЙ, ЦИКЛЫ). МОЖНО ЛОМАЕТЬ IF В БОЛЬШИНСТВЕ СЛУЧАЕВ (12 ПАТТЕРНОВ)
Где логика:
- Для ProviderCall: [_providercall_run_with_while()](agentui/pipeline/executor.py:3589)
- Для RawForward: [_rawforward_run_with_while()](agentui/pipeline/executor.py:3741)
- Обёртка делает «do-while»: первая итерация выполняется всегда, потом условие проверяется перед следующей.
Ключи конфигурации у ноды:
- while_expr (строка условие как в If)
- while_max_iters (safety, по умолчанию 50)
- ignore_errors (True — не падать на исключениях, а возвращать result={"error":"..."} и продолжать цикл)
Добавочные локальные переменные и семантика внутри цикла:
- [[cycleindex]] (int, 0..N) — индекс текущей итерации.
- [[WAS_ERROR]] (bool) — при проверке while_expr на i>0 равен «была ли ошибка на предыдущей итерации». Внутри самой итерации на старте содержит то же значение и обновляется для следующей проверки по факту результата.
- Подсказка: для ретраев по ошибкам используйте «WAS_ERROR» (а не «!WAS_ERROR»); включайте ignore_errors:true, чтобы исключения не прерывали цикл.
Глобальные переменные, которые нода выставит после цикла для «детей»:
- [[WAS_ERROR__nX]] — была ли ошибка на последней итерации
- [[CYCLEINDEX__nX]] — последний индекс итерации (например 2 если были 0,1,2)
12 паттернов:
1) Повтори до 3 раз:
while_expr: "cycleindex < 3"
2) Повтори, пока OUT3 не содержит «ok»:
while_expr: "!([[OUT3]] contains \"ok\") && cycleindex < 10"
3) Ретраи на ошибках сети:
ignore_errors: true
while_expr: "WAS_ERROR || ({{ OUT.n4.result.status|default(0) }} >= 500)"
4) Комбо с внешним If — заменяем If:
Вместо If.true/false делай while_expr, который набивает нужный результат (например, пока не получишь 2xx от апстрима).
5) Изменение запроса по итерации:
Используй [[cycleindex]] внутри template (например, «page»: {{ vars.page_start|default(1) }} + cycleindex).
6) Дожидаться готовности ресурса:
while_expr: "!([[OUT4]] contains \"READY\") && cycleindex < 30"
7) Прерывание на плохих данных:
while_expr: "!([[OUT7]] contains \"fatal\") && cycleindex < 5"
8) Backoff вручную (временную задержку делай sleep_ms):
sleep_ms: {{ cycleindex }} * 500
9) Прокси-ретрай RawForward по тексту ответа:
ignore_errors: true
while_expr: "([[OUT:n1.result.text]] contains \"try again\") && cycleindex < 4"
10) Gemini «Stream failed to …» из коробки:
while_expr: "([[OUT3]] contains "Stream failed to") || ([[OUT3]] contains "gemini-2.5-pro")"
(ровно как в твоих пресетах)
Добавь " || WAS_ERROR" если хочешь ретраить также на исключениях (при ignore_errors: true).
11) Проверка флага из STORE:
while_expr: "{{ STORE.SHALL_CONTINUE|default(false) }} && cycleindex < 10"
12) Сложный сценарий: first success wins
while_expr: "!([[OUT7]] contains \"success\") && cycleindex < 5"
Пояснение: крути пока не словишь success, но не более 5.
Эти while позволяют чаще не городить отдельный If-гейт ты просто делаешь один узел, который сам повторяет себя, пока условие не «устаканится». Ну и не забудь выставить ignore_errors там, где ретраи оправдано.
РАЗДЕЛ 5.1 WAS_ERROR В WHILE ПОСЛЕ ОБНОВЛЕНИЯ (ПОВЕДЕНИЕ И РЕЦЕПТЫ)
- Семантика do-while:
- Итерация i=0 выполняется всегда.
- Начиная с i>0, перед проверкой while_expr двигатель подставляет в [[WAS_ERROR]] значение «была ли ошибка (исключение) на предыдущей итерации».
- Как учитываются исключения:
- При ignore_errors: true исключения внутри итерации не прерывают ноду; результат оформляется как result={"error":"..."}.
- Такое событие считается ошибкой и установит [[WAS_ERROR]]=true для следующей проверки условия.
- Рецепты:
- Ретраить только при ошибке (до 5 раз): while_expr: "WAS_ERROR && (cycleindex < 5)"
- Ретраить при ошибке ИЛИ по признаку в ответе: while_expr: "WAS_ERROR || ([[OUT3]] contains "Stream failed to") || ({{ OUT.n3.result.status|default(0) }} >= 500)"
- NB: "!WAS_ERROR" означает «продолжать, если ошибки НЕ было» — это обратное «ретраю при ошибке».
- Диагностика:
- В логах видны строки вида TRACE while: ... expr='...' expanded='...' index=i result=true/false.
- Ошибка парсера (например, несбалансированные скобки) логируется как while_error и приводит к result=false.
РАЗДЕЛ 6 — PROMPT_COMBINE (DSL «&»): ВЫ ТАМ ЛЮБИТЕ МАГИЮ? ВОТ ОНА, ЧТОБЫ НЕ ЛЕПИТЬ РУКАМИ (12 ПРИМЕРОВ)
Где реализовано: в [ProviderCallNode.run()](agentui/pipeline/executor.py:1991) — см. кусок обработки combine_raw.
Идея:
- Поле prompt_combine — строка вида «СЕГМЕНТ1 & СЕГМЕНТ2 & ...».
- СЕГМЕНТ — это либо [[PROMPT]] (спец сегмент текущих Prompt Blocks), либо любая строка/JSON/список сообщений, либо [[VAR:incoming.*]] и т.п.
- Для каждой цели (provider) всё приводится к нужным структурам:
- openai → messages: [...]
- gemini → contents: [...] (+ systemInstruction)
- claude → messages: [...] (+ system)
- Системный текст (из openai.system / claude.system / gemini.systemInstruction) автоматически извлекается и объединяется.
Позиционирование:
- Можно добавить директиву @pos=prepend | append | N | -1
- Она управляет тем, куда вставить элементы из сегмента внутри собираемого массива сообщений/контента. -1 — вставить перед последним.
Фильтрация:
- Пустые сообщения выкидываются (без пустых текстов).
- Изображения (inlineData и т.п.) сохраняются.
12 примеров (разные таргеты и трюки):
1) Классика: входящие Gemini contents + твой PROMPT (OpenAI target)
"[[VAR:incoming.json.contents]] & [[PROMPT]]"
Результат: messages содержит и конвертированные входящие (model→assistant), и твои blocks.
2) PROMPT первым (OpenAI):
"[[PROMPT]]@pos=prepend & [[VAR:incoming.json.contents]]"
Результат: system из PROMPT — в самом начале messages.
3) Вставка в конкретный индекс (OpenAI):
"[[VAR:incoming.json.messages]] & [[PROMPT]]@pos=1"
Результат: вторым элементом окажутся твои блоки.
4) Негативный индекс (OpenAI):
"[[VAR:incoming.json.messages]] & [[PROMPT]]@pos=-1"
Результат: перед самым последним.
5) Для Gemini: openai.messages + PROMPT
"[[VAR:incoming.json.messages]] & [[PROMPT]]"
Результат: contents и systemInstruction соберутся; system из incoming и PROMPT сольются.
6) Для Claude: openai.messages + PROMPT
"[[VAR:incoming.json.messages]] & [[PROMPT]]"
Результат: messages + top-level system (как строка/блоки).
7) Сырый JSON-строковый сегмент:
"{\"messages\": [{\"role\":\"user\",\"content\":\"Hi\"}] } & [[PROMPT]]"
Результат: корректно распарсится и слепится.
8) Списковая форма сегмента:
"[{\"role\":\"user\",\"content\":\"A\"},{\"role\":\"assistant\",\"content\":\"B\"}] & [[PROMPT]]"
Результат: нормализуется под целевой провайдер.
9) Системные тексты из разных форматов — сольются:
"[{\"messages\":[{\"role\":\"system\",\"content\":\"SYS IN\"}]}] & [[PROMPT]]"
Результат: system_text включает обе части.
10) Подмешать внешнюю систему в Claude без top-level system (claude_no_system):
В конфиге ноды поставь claude_no_system=true — тогда system из PROMPT положим первым user-сообщением.
11) Очистка пустых:
Если твой сегмент даёт пустые тексты — они выкинутся и JSON не сломается. Не плачь.
12) Микс строк + JSON:
"Просто строка & [[PROMPT]]"
Результат: «Просто строка» упакуется корректно (как user/text) в нужную структуру провайдера.
И да, это позволяет не писать руками половину «склейки» в template — ты описываешь, откуда что привнести, а движок доведёт до провайдерного формата.
РАЗДЕЛ 6.1 — PROMPT_PREPROCESS (premerge DSL: фильтрация/позиционирование сегментов ДО prompt_combine)
Где реализовано: в [ProviderCallNode.run()](agentui/pipeline/executor.py:2083). Это выполняется перед сборкой [[PROMPT]]/prompt_combine. Поле конфигурации ноды: prompt_preprocess (многострочное).
Идея:
- Каждая строка prompt_preprocess задаёт «пред‑сегмент», который будет вставлен в будущий массив сообщений/контента до обработки [prompt_combine (DSL &)](agentui/pipeline/executor.py:2230).
- Эти пред‑сегменты конвертируются под целевого провайдера (openai/gemini/claude) так же, как и сегменты prompt_combine, и «вплетаются» первыми.
- Если prompt_combine пуст — используются только пред‑сегменты (и при отсутствии пред‑сегментов — исходные Prompt Blocks как раньше).
Синтаксис строки:
SEGMENT [delKeyContains "needle"] [delpos=prepend|append|N|-1] [case=ci|cs] [pruneEmpty]
- SEGMENT — строка/JSON/список, допускаются макросы [[...]] и {{ ... }}.
- delKeyContains "needle" — удалить ключи в любом месте объекта, если строковое представление их значения содержит needle (поддерживаются несколько delKeyContains).
- case=ci|cs — управление регистром для contains; по умолчанию case=ci (без учёта регистра).
- pruneEmpty — удалять опустевшие {} / [] после чистки (кроме корня); по умолчанию выключено.
- delpos=... — позиция вставки элементов пред‑сегмента в массив (как @pos у prompt_combine): prepend | append | N | -1; по умолчанию append.
Поведение:
- Для каждого SEGMENT рендерятся макросы, затем выполняется попытка json.loads() (в т.ч. для двойной JSONстроки).
- После этого применяется фильтрация delKeyContains (если задана), с учётом case и pruneEmpty.
- Итог вставляется в текущий собираемый массив сообщений/контента в позицию delpos (prepend/append/индекс/отрицательный индекс).
- Системный текст, присутствующий внутри сегмента (Gemini systemInstruction / OpenAI role=system / Claude system), автоматически извлекается и сольётся, как в prompt_combine.
Примеры:
- {{ pm.contents }} — вставит массив как настоящий массив (без кавычек)
- {{ params.temperature|default(0.7) }} — безопасный дефолт для числа
- [[VAR:incoming.api_keys.authorization]] — быстро подставить строку Authorization
1) Удалить поля, где значение содержит «Текст», и вставить перед последним:
[[VAR:incoming.json.contents]] delKeyContains "Текст" delpos=-1
---
2) Удалить «debug» с учётом регистра и подчистить пустые контейнеры:
[[VAR:incoming.json.messages]] delKeyContains "debug" case=cs pruneEmpty
## 8) Отладка и рекомендации
3) Несколько подстрок + вставка в начало:
[[VAR:incoming.json]] delKeyContains "кеш" delKeyContains "cache" delpos=prepend
- ProviderCall печатает в консоль DEBUG сведения: выбранный провайдер, конечный URL, первые символы тела запроса — удобно для проверки корректности шаблона.
- Если «ничего не подставилось»:
1) Проверьте, что вы НЕ передаёте сырое входное тело напрямую в ProviderCall (узел строит тело из шаблона и Prompt Blocks).
2) Убедитесь, что итоговый JSON валиден (закрывающие скобки, запятые).
3) Проверьте точность путей в макросах (OUT vs OUTx, правильные id нод n1/n2/...).
- Для ссылок на выходы предыдущих нод используйте [[OUT1]] как «просто текст», либо полные пути [[OUT:n1...]] для точного фрагмента.
4) Смешанный пайплайн: сначала пред‑сегменты, затем:
prompt_combine: "[[VAR:incoming.json.messages]] & [[PROMPT]]@pos=1"
---
Диагностика:
- В логи (SSE) слать событие "prompt_preprocess" с полями lines/used/removed_keys. Смотри [ProviderCallNode.run()](agentui/pipeline/executor.py:2211).
## 9) Быстрая памятка по ключам доступа
Ограничения и заметки:
- Это локальная предобработка именно сегментов для промпта (не глобальная фильтрация всего тела запроса).
- Если пред‑сегменты и prompt_combine пусты — результат совпадает с классическим [[PROMPT]] (Prompt Blocks).
РАЗДЕЛ 7 — JSON PATH (НАШ ПРОСТОЙ ДИАЛЕКТ) + 12 ПРИМЕРОВ
- 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]] — ключ в заголовке.
Где реализовано: [_json_path_extract()](agentui/pipeline/executor.py:1475).
---
Синтаксис (очень простой):
- Путь вида a.b.c — точки для полей объектов.
- Числовой индекс для массивов: items.0.title
- Шаг «*» разворачивает все значения словаря или все элементы списка: items.*.title
- Если на каком-то шаге ничего не найдено — вернёт None.
- jp(...) → отдаёт найденное значение или список значений, jp_text(...) → склеит строки через join_sep (см. [_stringify_join()](agentui/pipeline/executor.py:1517)).
## 10) Ссылки на реализацию (для интересующихся деталями)
12 примеров:
1) Обычный путь:
"a.b.c" на {"a":{"b":{"c":10}}} → 10
- Контекст (переменные): [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)
2) Индекс массива:
"items.1" на {"items":[10,20,30]} → 20
Удачного редактирования!
---
## Пользовательские переменные (SetVars) — «для людей»
3) Вложено:
"items.1.title" на items=[{title:"A"},{title:"B"}] → "B"
Задача: в начале пайплайна положить свои значения и потом использовать их в шаблонах одной строкой — например [[MY_KEY]] или {{ MAX_TOKENS }}.
4) Звёздочка по массиву:
"items.*.title" на items=[{title:"A"},{title:"B"}] → ["A","B"]
Где это в UI
- В левой панели добавьте ноду SetVars и откройте её в инспекторе.
- Жмите «Добавить переменную», у каждой переменной есть три поля:
- name — имя переменной (латинские буквы/цифры/подчёркивание, не с цифры): MY_KEY, REGION, MAX_TOKENS
- mode — режим обработки значения:
- string — строка, в которой работают макросы ([[...]] и {{ ... }})
- expr — «мини‑формула» без макросов (подробнее ниже)
- value — собственно значение
5) Звёздочка по объекту:
"*.*.name" на {"x":{"name":"X"}, "y":{"name":"Y"}} → ["X","Y"]
Как потом вставлять переменные
- Для строк (URL/заголовки/текст) — квадратные скобки: [[MY_KEY]]
- Для чисел/массивов/объектов — фигурные скобки: {{ MAX_TOKENS }}, {{ GEN_CFG }}
6) Смешанный:
"candidates.0.content.parts.*.text" (Gemini) → все тексты
Примеры «как надо»
- Переменная-строка (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 }}
7) Несуществующее поле:
"obj.miss" → None
Важно про два режима
- string — это «шаблон». Внутри работают все макросы ([[VAR:...]], [[OUT:...]], [[PROMPT]], {{ ... }}). Значение прогоняется через рендер [render_template_simple()](agentui/pipeline/templating.py:184).
- expr — это «мини‑формула». Внутри НЕТ макросов и НЕТ доступа к контексту; только литералы и операции (см. ниже). Вычисляет значение безопасно — без eval, на белом списке AST (реализация: [SetVarsNode._safe_eval_expr()](agentui/pipeline/executor.py:291)).
8) Склейка текстов (jp_text):
jp_text(value, "items.*.desc", " | ") → "a | b | c"
Что умеет 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, оно разбирается напрямую (без макросов), т.е. trueTrue, nullNone и т.п.
- Запрещено: функции (кроме специально разрешённых ниже), доступ к переменным/контексту, атрибуты/индексация/условные выражения.
9) Взять base64 из inlineData:
"candidates.0.content.parts.1.inlineData.data"
Рандом в 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 нет каждый запуск выдаёт новое значение.
10) Несколько уровней массивов:
"a.*.b.*.c"
«Почему в expr нельзя подставлять переменные/макросы
- Для безопасности и предсказуемости: expr это закрытый миниязык без окружения.
- Если нужно использовать другие переменные/макросы делайте это в режиме string (там всё рендерится шаблонизатором).
- Технические детали: защита реализована в [SetVarsNode._safe_eval_expr()](agentui/pipeline/executor.py:291), а вставка stringзначений через [render_template_simple()](agentui/pipeline/templating.py:184).
11) Индекс вне границ:
"items.99" → None
Как это работает внутри (если интересно)
- SetVars исполняется как обычная нода пайплайна и отдаёт {"vars": {...}}.
- Исполнитель добавляет эти значения в контекст для последующих нод как context.vars (см. [PipelineExecutor.run()](agentui/pipeline/executor.py:131)).
- При рендере шаблонов:
- [[NAME]] и {{ NAME }} подставляются с приоритетом из пользовательских переменных (см. обработку в [render_template_simple()](agentui/pipeline/templating.py:184)).
- Сам SetVars считает переменные в порядке списка и возвращает их одним пакетом (внутри одной ноды значения не зависят друг от друга).
12) Список/объект → строка (jp_text сам постарается найти текст глубже):
jp_text(value, "response", "\n")
Частые вопросы
- «Хочу собрать строку с частями из внешнего запроса»: делайте 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 — OUTx, ИЗВЛЕЧЕНИЕ ТЕКСТА, ПРЕСЕТЫ, ГЛОБАЛЬНЫЕ ОПЦИИ
Откуда [[OUTx]] берёт текст:
- Универсальный алгоритм (см. [templating._best_text_from_outputs()](agentui/pipeline/templating.py:133)) ищет:
- OpenAI: choices[0].message.content
- Gemini: candidates[].content.parts[].text
- Claude: content[].text
- Иначе — глубокий поиск текстовых полей.
- Для ProviderCall/RawForward нода сама пишет response_text и отдаёт в OUT.
Настройка, если тебе нужно «не обобщённо, а вот отсюда»:
- Глобальная meta (в «Запуск»): text_extract_strategy (auto|deep|jsonpath|openai|gemini|claude), text_extract_json_path, text_join_sep.
- Пресеты в «Запуск → Пресеты парсинга OUTx»: создаёшь набор id/json_path, затем в ноде выбираешь preset по id (text_extract_preset_id).
- На уровне ноды можно переопределить: text_extract_strategy, text_extract_json_path, text_join_sep.
Пример (пер-нодовый пресет):
- В «Запуск» добавь JSONPath: candidates.0.content.parts.*.text
- В ноде ProviderCall выбери этот preset — и [[OUTn]] станет строго вытягивать по нему.
Пример (жёсткий путь в ноде):
- text_extract_strategy: "jsonpath"
- text_extract_json_path: "result.echo.payload.parts.*.text"
- text_join_sep: " | "
РАЗДЕЛ 9 — ПАНЕЛЬ «ПЕРЕМЕННЫЕ» (STORE), КОПИРОВАНИЕ МАКРОСОВ
Где посмотреть в UI: [editor.html](static/editor.html) — кнопка «ПЕРЕМЕННЫЕ».
- Там список ключей:
- vars (текущие пользовательские переменные из SetVars)
- snapshot (снимок последнего запуска: incoming, params, model, vendor_format, system, OUT, OUT_TEXT, LAST_NODE, алиасы OUT1/OUT2/…)
- По клику копируется готовый макрос:
- Для vars → [[NAME]] или {{ NAME }}
- Для snapshot.OUT_TEXT.nX → [[OUTx]] или {{ OUT.nX.response_text }}
- Для snapshot.OUT.nX.something → [[OUT:nX.something]] или {{ OUT.nX.something }}
- Для прочего контекста → [[VAR:path]] или {{ path }}
- Переключатель «фигурные» — управляет, в какой форме скопируется (квадратные или фигурные).
STORE (персистентность между прогонами):
- Если pipeline.clear_var_store=false, содержимое не очищается между запуском.
- Примеры макросов:
- [[STORE:KEEP]]
- {{ STORE.KEEP|default('none') }}
РАЗДЕЛ 10 — ЦВЕТА КОННЕКТОВ И КТО О ЧЁМ ШЕПОЧЕТ
Выглядит мило, да. Это не просто так, это сигнализация (см. [editor.css](static/editor.css)):
- If.true (зелёный, пунктир): ветка истинности — класс .conn-if-true
- If.false (сланцево-серый, пунктир): ветка ложности — .conn-if-false
- ProviderCall (приглушённый синий): .conn-provider
- RawForward (мягкий фиолетовый): .conn-raw
- SetVars (мятный): .conn-setvars
- Return (холодный серо-синий): .conn-return
- Входящие к узлу с ошибкой подсвечиваются красным: .conn-upstream-err
А ещё стрелочки направления рисуются поверх линий, и лейблы «true/false» к If-веткам, так что перестань путаться, пожалуйста.
РАЗДЕЛ 11 — ЧАСТЫЕ ПАТТЕРНЫ (РЕЦЕПТЫ НА 1 МИНУТУ)
1) «Прокинуть, но если 502 — подёргать ещё»
- RawForward:
- ignore_errors: true
- while_expr: "{{ OUT.n3.result.status|default(0) }} == 502 && cycleindex < 3"
2) «Gemini: взять входные contents, добавить свой system и отправить в OpenAI»
- ProviderCall (openai):
- prompt_combine: "[[VAR:incoming.json.contents]] & [[PROMPT]]@pos=prepend"
3) «Сделать язык вывода в зависимости от заголовка X-Lang»
- SetVars:
- LANG (string): "[[VAR:incoming.headers.X-Lang|default('en')]]"
- If:
- expr: "[[LANG]] == 'ru'"
- Return:
- text_template: "[[OUT2]]" (где n2 твоя ветка для RU)
4) «Доставать base64 из ответа и вставлять картинкой куда нужно»
- jp_text(OUT, "candidates.*.content.parts.*.inlineData.data", "")
- Либо сразу data URL через img(png)[[...]] на канвасе.
5) «Стабильно вытягивать текст из Claude»
- Настраиваешь пресет: json_path="content.*.text", join="\n"
- В ноде ProviderCall выбираешь этот preset.
РАЗДЕЛ 12 БЕЗОПАСНОСТЬ И НЕ ПАЛИ КЛЮЧИ
- Никогда не вписывай реальные ключи в presets/pipeline.json. Никогда слышишь?
- Передавай ключи из клиента заголовками:
- OpenAI: Authorization: Bearer X
- Anthropic: x-api-key: X
- Gemini: ?key=... в URL
- В шаблонах юзай [[VAR:incoming.headers.authorization]], [[VAR:incoming.headers.x-api-key]], [[VAR:incoming.api_keys.key]]
- Убедись, что логирование не льёт секреты в проде (маскируй, см. сервер).
РАЗДЕЛ 13 ТРОБЛШУТИНГ (КАК НЕ ПЛАКАТЬ)
- JSON «не валидный» в ProviderCall:
- В template лишняя запятая вокруг [[PROMPT]] или ты вставил строкой не-JSON. Проверь печать «rendered_template» в консоли (см. [ProviderCallNode.run()](agentui/pipeline/executor.py:2055)).
- Линии «исчезают» в редакторе:
- Жми «Загрузить пайплайн» ещё раз отложенные порты и наблюдатели синхронизируются.
- [[OUTx]] пустой:
- Настрой пресет извлечения OUTx (Раздел 8) либо задействуй явный json_path.
- While завис навечно:
- Проверь while_max_iters и само условие. Помни: это do-while, первая итерация всегда.
- Claude system вдруг не где надо:
- Смотри флаг claude_no_system (нода ProviderCall) он переносит system в user.
ПРИЛОЖЕНИЕ ПОЛНЫЙ ЧЕК-ЛИСТ ПРИ СБОРКЕ НОДЫ PROVIDERCALL
1) Выбери provider (openai/gemini/claude) в инспекторе.
2) Заполни provider_configs.{provider}.(base_url, endpoint, headers, template).
- Подстановки в headers/template через [[...]] / {{ ... }} (см. [render_template_simple()](agentui/pipeline/templating.py:205))
3) Заполни Prompt Blocks (system/user/assistant/tool) они в [[PROMPT]].
4) Если нужно смешать с входящим payload используй prompt_combine (Раздел 6).
5) Если нужно ретраить поставь while_expr/ignore_errors/sleep_ms.
6) Если нужно извлекать текст особым образом выбери preset или text_extract_*.
7) Соедини depends по порядку и посмотри цвета проводов (Раздел 10).
8) Готово. Без косяков? Правда? Ну, посмотрим.
СЛИШКОМ ДЛИННО, НЕ ЧИТАЛ:
- [[...]] текстовая подстановка.
- {{ ... }} типобезопасная подстановка (числа/объекты).
- SetVars expr только whitelist функций (rand, randint, choice, from_json, jp, jp_text, file_b64, data_url, file_data_url) и операции + - * / // % and/or/сравнения.
- If && || !, contains, скобки, макросы внутри.
- While do-while в ProviderCall/RawForward, есть cycleindex и WAS_ERROR; можно заменить If в ретраях.
- prompt_combine склейка сообщений из разных форматов с @pos= и автоконвертацией под провайдера.
- JSONPath a.b.0.*.x, звёздочка и индексы; jp/jp_text.
- Цвета линий true/false пунктир, по типу ноды разные цвета; ошибка красные upstream.
- Не пались: ключи только через incoming.headers/URL.
Если ты дошёл досюда ну я не впечатлена. Просто запомни и не ломи моя нервная система, ладно? Хмф.

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 973 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
favicon_io_saya/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
favicon_io_saya/saya1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

203
pipeline.json Normal file
View File

@@ -0,0 +1,203 @@
{
"id": "pipeline_editor",
"name": "Edited Pipeline",
"parallel_limit": 8,
"loop_mode": "iterative",
"loop_max_iters": 1000,
"loop_time_budget_ms": 999999999999,
"clear_var_store": true,
"http_timeout_sec": 999.0,
"text_extract_strategy": "auto",
"text_extract_json_path": "",
"text_join_sep": "\n",
"text_extract_presets": [
{
"id": "pmfqonx6fvcubc09k4ep",
"name": "candidates.0.content.parts.1.inlineData.data",
"strategy": "jsonpath",
"json_path": "candidates.0.content.parts.1.inlineData.data",
"join_sep": "\n"
},
{
"id": "pmfqrelw6wu9rutnzk1",
"name": "candidates.0.content.parts.1.inlineData",
"strategy": "jsonpath",
"json_path": "candidates.0.content.parts.1.inlineData",
"join_sep": "\n"
}
],
"nodes": [
{
"id": "n5",
"type": "SetVars",
"pos_x": 300,
"pos_y": 720,
"config": {
"variables": [
{
"id": "vmfi99ftc",
"name": "Clod",
"mode": "string",
"value": "igrovik"
},
{
"id": "vmfi99gjw",
"name": "MyOpenAiKey",
"mode": "string",
"value": "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"
},
{
"id": "vmfjkn09i",
"name": "NAMETest",
"mode": "expr",
"value": "128 + 64"
}
],
"_origId": "n5"
},
"in": {}
},
{
"id": "n2",
"type": "Return",
"pos_x": 1344,
"pos_y": 756,
"config": {
"target_format": "auto",
"text_template": "[[OUT7]]",
"_origId": "n2"
},
"in": {
"depends": "n7.done"
}
},
{
"id": "n3",
"type": "RawForward",
"pos_x": 552,
"pos_y": 696,
"config": {
"passthrough_headers": true,
"extra_headers": "{\"connection\": \"close\"}",
"_origId": "n3",
"while_expr": "([[OUT3]] contains \"Stream failed to\") || ([[OUT3]] contains \"gemini-2.5-pro\") ) || [[WAS_ERROR]]",
"ignore_errors": true,
"while_max_iters": 50,
"override_path": "",
"base_url": ""
},
"in": {
"depends": "n5.done"
}
},
{
"id": "n4",
"type": "ProviderCall",
"pos_x": 780,
"pos_y": 672,
"config": {
"provider": "gemini",
"provider_configs": {
"openai": {
"base_url": "https://api.openai.com",
"endpoint": "/v1/chat/completions",
"headers": "{\"Authorization\":\"[[VAR:incoming.headers.authorization]]\"}",
"template": "{\n \"model\": \"gpt-5-chat-latest\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_completion_tokens\": {{ incoming.json.max_completion_tokens|default(params.max_tokens|default(256)) }},\n \"presence_penalty\": {{ incoming.json.presence_penalty|default(0) }},\n \"frequency_penalty\": {{ incoming.json.frequency_penalty|default(0) }},\n \"stop\": {{ incoming.json.stop|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }}\n}"
},
"gemini": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]",
"headers": "{}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"safetySettings\": {{ incoming.json.safetySettings|default([]) }},\n \"generationConfig\": {\n \"temperature\": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},\n \"topP\": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},\n \"maxOutputTokens\": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},\n \"stopSequences\": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }},\n \"candidateCount\": {{ incoming.json.generationConfig.candidateCount|default(1) }},\n \"thinkingConfig\": {\n \"includeThoughts\": {{ incoming.json.generationConfig.thinkingConfig.includeThoughts|default(false) }},\n \"thinkingBudget\": {{ incoming.json.generationConfig.thinkingConfig.thinkingBudget|default(0) }}\n }\n }\n}"
},
"gemini_image": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent",
"headers": "{\"x-goog-api-key\":\"[[VAR:incoming.api_keys.key]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]]\n}"
},
"claude": {
"base_url": "https://api.anthropic.com",
"endpoint": "/v1/messages",
"headers": "{\"x-api-key\":\"[[VAR:incoming.headers.x-api-key]]\",\"anthropic-version\":\"2023-06-01\",\"anthropic-beta\":\"[[VAR:incoming.headers.anthropic-beta]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"stop_sequences\": {{ incoming.json.stop_sequences|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }},\n \"thinking\": {\n \"type\": \"{{ incoming.json.thinking.type|default('disabled') }}\",\n \"budget_tokens\": {{ incoming.json.thinking.budget_tokens|default(0) }}\n },\n \"anthropic_version\": \"{{ anthropic_version|default('2023-06-01') }}\"\n}"
}
},
"blocks": [
{
"id": "bmfwy94ev",
"name": "Твой ответ недостаточно хорош",
"role": "user",
"prompt": "Твой ответ:\n```\n[[OUT3]]\n```\nнедостаточно хорош, при его написании ты не следовал инструкциям. переделай исходя из инструкций, найди недостатки разобрав каждое действие оценив его логичность и следование истории от 0до10, перепиши эти моменты на нормальные.",
"enabled": true,
"order": 0
}
],
"_origId": "n4",
"prompt_preprocess": "[[VAR:incoming.json.contents]] delKeyContains \"Okie!\"",
"prompt_combine": "[[VAR:incoming.json.contents]] & [[PROMPT]]@pos=append",
"while_expr": "([[OUT4]] contains \"Stream failed to\") || ([[OUT4]] contains \"gemini-2.5-pro\") || ([[WAS_ERROR]] == true)",
"ignore_errors": true,
"while_max_iters": 50,
"sleep_ms": 555555000
},
"in": {
"depends": "n3.done"
}
},
{
"id": "n7",
"type": "ProviderCall",
"pos_x": 1080,
"pos_y": 600,
"config": {
"provider": "gemini",
"provider_configs": {
"openai": {
"base_url": "https://api.openai.com",
"endpoint": "/v1/chat/completions",
"headers": "{\"Authorization\":\"Bearer [[MyOpenAiKey]]\"}",
"template": "{\n \"model\": \"gpt-5-chat-latest\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_completion_tokens\": {{ incoming.json.max_completion_tokens|default(params.max_tokens|default(256)) }},\n \"presence_penalty\": {{ incoming.json.presence_penalty|default(0) }},\n \"frequency_penalty\": {{ incoming.json.frequency_penalty|default(0) }},\n \"stop\": {{ incoming.json.stop|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }}\n}"
},
"gemini": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]",
"headers": "{}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"safetySettings\": {{ incoming.json.safetySettings|default([]) }},\n \"generationConfig\": {\n \"temperature\": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},\n \"topP\": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},\n \"maxOutputTokens\": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},\n \"stopSequences\": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }},\n \"candidateCount\": {{ incoming.json.generationConfig.candidateCount|default(1) }},\n \"thinkingConfig\": {\n \"includeThoughts\": {{ incoming.json.generationConfig.thinkingConfig.includeThoughts|default(false) }},\n \"thinkingBudget\": {{ incoming.json.generationConfig.thinkingConfig.thinkingBudget|default(0) }}\n }\n }\n}"
},
"gemini_image": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent",
"headers": "{\"x-goog-api-key\":\"[[VAR:incoming.api_keys.key]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]]\n}"
},
"claude": {
"base_url": "https://proxy.malepreg.lol/proxy/aws/claude",
"endpoint": "/v1/messages",
"headers": "{\"x-api-key\":\"igrovik\",\"anthropic-version\":\"2023-06-01\",\"anthropic-beta\":\"[[VAR:incoming.headers.anthropic-beta]]\"}",
"template": "{\n \"model\": \"claude-opus-4-20250514\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"stop_sequences\": {{ incoming.json.stop_sequences|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }},\n \"thinking\": {\n \"type\": \"{{ incoming.json.thinking.type|default('disabled') }}\",\n \"budget_tokens\": {{ incoming.json.thinking.budget_tokens|default(0) }}\n },\n \"anthropic_version\": \"{{ anthropic_version|default('2023-06-01') }}\"\n}"
}
},
"blocks": [
{
"id": "bmfzvzpl7",
"name": "Может содержать такие конструкции",
"role": "user",
"prompt": "Твой ответ:\n```\n[[OUT4]]\n```\nМожет содержать такие конструкции:\n**'Not X, but Y'** narrative structure. This includes any and all variations of stating what something *is not* in order to emphasize what it *is*. Нужно заменить места на нормальный нарратив.",
"enabled": true,
"order": 0
}
],
"_origId": "n7",
"prompt_combine": "[[VAR:incoming.json.contents]] & [[PROMPT]]@pos=-1",
"claude_no_system": true,
"while_expr": "([[OUT7]] contains \"Stream failed to\") || ([[OUT7]] contains \"gemini-2.5-pro\") || [[WAS_ERROR]] == true",
"ignore_errors": true,
"while_max_iters": 50
},
"in": {
"depends": "n4.done"
}
}
]
}

237
presets/123123123.json Normal file
View File

@@ -0,0 +1,237 @@
{
"id": "pipeline_editor",
"name": "Edited Pipeline",
"parallel_limit": 8,
"loop_mode": "iterative",
"loop_max_iters": 1000,
"loop_time_budget_ms": 999999999999,
"clear_var_store": true,
"http_timeout_sec": 999,
"text_extract_strategy": "auto",
"text_extract_json_path": "",
"text_join_sep": "\n",
"text_extract_presets": [
{
"id": "pmfqonx6fvcubc09k4ep",
"name": "candidates.0.content.parts.1.inlineData.data",
"strategy": "jsonpath",
"json_path": "candidates.0.content.parts.1.inlineData.data",
"join_sep": "\n"
},
{
"id": "pmfqrelw6wu9rutnzk1",
"name": "candidates.0.content.parts.1.inlineData",
"strategy": "jsonpath",
"json_path": "candidates.0.content.parts.1.inlineData",
"join_sep": "\n"
}
],
"nodes": [
{
"id": "n5",
"type": "SetVars",
"pos_x": 12,
"pos_y": 780,
"config": {
"variables": [
{
"id": "vmfi99ftc",
"name": "Clod",
"mode": "string",
"value": "igrovik"
},
{
"id": "vmfi99gjw",
"name": "MyOpenAiKey",
"mode": "string",
"value": "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"
},
{
"id": "vmfjkn09i",
"name": "NAMETest",
"mode": "expr",
"value": "128 + 64"
}
],
"_origId": "n5"
},
"in": {}
},
{
"id": "n2",
"type": "Return",
"pos_x": 1344,
"pos_y": 756,
"config": {
"target_format": "auto",
"text_template": "[[OUT7]]",
"_origId": "n2"
},
"in": {
"depends": "n8.false"
}
},
{
"id": "n3",
"type": "RawForward",
"pos_x": 564,
"pos_y": 660,
"config": {
"passthrough_headers": true,
"extra_headers": "{\"connection\": \"close\"}",
"_origId": "n3"
},
"in": {
"depends": [
"n5.done",
"n1.true"
]
}
},
{
"id": "n1",
"type": "If",
"pos_x": 564,
"pos_y": 888,
"config": {
"expr": "([[OUT3]] contains \"Stream&#32;failed&#32;to\") || ([[OUT3]] contains \"gemini-2.5-pro\")",
"_origId": "n1"
},
"in": {
"depends": "n3.done"
}
},
{
"id": "n4",
"type": "ProviderCall",
"pos_x": 792,
"pos_y": 624,
"config": {
"provider": "gemini",
"provider_configs": {
"openai": {
"base_url": "https://api.openai.com",
"endpoint": "/v1/chat/completions",
"headers": "{\"Authorization\":\"Bearer [[VAR:incoming.headers.authorization]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"max_completion_tokens\": {{ incoming.json.max_completion_tokens|default(params.max_tokens|default(256)) }},\n \"presence_penalty\": {{ incoming.json.presence_penalty|default(0) }},\n \"frequency_penalty\": {{ incoming.json.frequency_penalty|default(0) }},\n \"stop\": {{ incoming.json.stop|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }}\n}"
},
"gemini": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]",
"headers": "{}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"safetySettings\": {{ incoming.json.safetySettings|default([]) }},\n \"generationConfig\": {\n \"temperature\": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},\n \"topP\": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},\n \"maxOutputTokens\": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},\n \"stopSequences\": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }},\n \"candidateCount\": {{ incoming.json.generationConfig.candidateCount|default(1) }},\n \"thinkingConfig\": {\n \"includeThoughts\": {{ incoming.json.generationConfig.thinkingConfig.includeThoughts|default(false) }},\n \"thinkingBudget\": {{ incoming.json.generationConfig.thinkingConfig.thinkingBudget|default(0) }}\n }\n }\n}"
},
"gemini_image": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent",
"headers": "{\"x-goog-api-key\":\"[[VAR:incoming.api_keys.key]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]]\n}"
},
"claude": {
"base_url": "https://api.anthropic.com",
"endpoint": "/v1/messages",
"headers": "{\"x-api-key\":\"[[VAR:incoming.headers.x-api-key]]\",\"anthropic-version\":\"2023-06-01\",\"anthropic-beta\":\"[[VAR:incoming.headers.anthropic-beta]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"stop_sequences\": {{ incoming.json.stop_sequences|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }},\n \"thinking\": {\n \"type\": \"{{ incoming.json.thinking.type|default('disabled') }}\",\n \"budget_tokens\": {{ incoming.json.thinking.budget_tokens|default(0) }}\n },\n \"anthropic_version\": \"{{ anthropic_version|default('2023-06-01') }}\"\n}"
}
},
"blocks": [
{
"id": "bmfwy94ev",
"name": "Твой ответ недостаточно хорош",
"role": "user",
"prompt": "Твой ответ:\n```\n[[OUT3]]\n```\nнедостаточно хорош, при его написании ты не следовал инструкциям. переделай исходя из инструкций, найди недостатк1.",
"enabled": true,
"order": 0
}
],
"_origId": "n4",
"prompt_combine": "[[VAR:incoming.json.contents]] & [[PROMPT]]@pos=-1"
},
"in": {
"depends": [
"n6.true",
"n1.false"
]
}
},
{
"id": "n6",
"type": "If",
"pos_x": 792,
"pos_y": 876,
"config": {
"expr": "([[OUT4]] contains \"Stream&#32;failed&#32;to\") || ([[OUT4]] contains \"gemini-2.5-pro\")",
"_origId": "n6"
},
"in": {
"depends": "n4.done"
}
},
{
"id": "n7",
"type": "ProviderCall",
"pos_x": 1068,
"pos_y": 540,
"config": {
"provider": "gemini",
"provider_configs": {
"openai": {
"base_url": "https://api.openai.com",
"endpoint": "/v1/chat/completions",
"headers": "{\"Authorization\":\"Bearer [[VAR:incoming.headers.authorization]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"max_completion_tokens\": {{ incoming.json.max_completion_tokens|default(params.max_tokens|default(256)) }},\n \"presence_penalty\": {{ incoming.json.presence_penalty|default(0) }},\n \"frequency_penalty\": {{ incoming.json.frequency_penalty|default(0) }},\n \"stop\": {{ incoming.json.stop|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }}\n}"
},
"gemini": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]",
"headers": "{}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"safetySettings\": {{ incoming.json.safetySettings|default([]) }},\n \"generationConfig\": {\n \"temperature\": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},\n \"topP\": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},\n \"maxOutputTokens\": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},\n \"stopSequences\": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }},\n \"candidateCount\": {{ incoming.json.generationConfig.candidateCount|default(1) }},\n \"thinkingConfig\": {\n \"includeThoughts\": {{ incoming.json.generationConfig.thinkingConfig.includeThoughts|default(false) }},\n \"thinkingBudget\": {{ incoming.json.generationConfig.thinkingConfig.thinkingBudget|default(0) }}\n }\n }\n}"
},
"gemini_image": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent",
"headers": "{\"x-goog-api-key\":\"[[VAR:incoming.api_keys.key]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]]\n}"
},
"claude": {
"base_url": "https://api.anthropic.com",
"endpoint": "/v1/messages",
"headers": "{\"x-api-key\":\"[[VAR:incoming.headers.x-api-key]]\",\"anthropic-version\":\"2023-06-01\",\"anthropic-beta\":\"[[VAR:incoming.headers.anthropic-beta]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"stop_sequences\": {{ incoming.json.stop_sequences|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }},\n \"thinking\": {\n \"type\": \"{{ incoming.json.thinking.type|default('disabled') }}\",\n \"budget_tokens\": {{ incoming.json.thinking.budget_tokens|default(0) }}\n },\n \"anthropic_version\": \"{{ anthropic_version|default('2023-06-01') }}\"\n}"
}
},
"blocks": [
{
"id": "bmfzvzpl7",
"name": "Может содержать такие конструкции",
"role": "user",
"prompt": "Твой ответ:\n```\n[[OUT4]]\n```\nМожет содержать такие конструкции:\n**'Not X, but Y'** narrative structure. This includes any and all variations of stating what something *is not* in order to emphasize what it *is*. Нужно заменить места на нормальный нарратив.",
"enabled": true,
"order": 0
}
],
"_origId": "n7",
"prompt_combine": "[[VAR:incoming.json.contents]] & [[PROMPT]]@pos=-1"
},
"in": {
"depends": [
"n6.false",
"n8.true"
]
}
},
{
"id": "n8",
"type": "If",
"pos_x": 1068,
"pos_y": 876,
"config": {
"expr": "([[OUT7]] contains \"Stream failed to\") || ([[OUT7]] contains \"gemini-2.5-pro\")",
"_origId": "n8"
},
"in": {
"depends": "n7.done"
}
}
]
}

191
presets/imgtests.json Normal file
View File

@@ -0,0 +1,191 @@
{
"id": "pipeline_editor",
"name": "Edited Pipeline",
"parallel_limit": 8,
"loop_mode": "iterative",
"loop_max_iters": 1000,
"loop_time_budget_ms": 999999999999,
"clear_var_store": true,
"http_timeout_sec": 999,
"text_extract_strategy": "auto",
"text_extract_json_path": "",
"text_join_sep": "\n",
"text_extract_presets": [
{
"id": "pmfqonx6fvcubc09k4ep",
"name": "candidates.0.content.parts.1.inlineData.data",
"strategy": "jsonpath",
"json_path": "candidates.0.content.parts.1.inlineData.data",
"join_sep": "\n"
},
{
"id": "pmfqrelw6wu9rutnzk1",
"name": "candidates.0.content.parts.1.inlineData",
"strategy": "jsonpath",
"json_path": "candidates.0.content.parts.1.inlineData",
"join_sep": "\n"
}
],
"nodes": [
{
"id": "n5",
"type": "SetVars",
"pos_x": -603,
"pos_y": 637,
"config": {
"variables": [
{
"id": "vmfi99ftc",
"name": "Clod",
"mode": "string",
"value": "igrovik"
},
{
"id": "vmfi99gjw",
"name": "MyOpenAiKey",
"mode": "string",
"value": "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"
},
{
"id": "vmfjkn09i",
"name": "NAMETest",
"mode": "expr",
"value": "128 + 64"
}
],
"_origId": "n5"
},
"in": {}
},
{
"id": "n2",
"type": "Return",
"pos_x": 509,
"pos_y": 459,
"config": {
"target_format": "auto",
"text_template": "[[OUT3]]",
"_origId": "n2"
},
"in": {
"depends": "n1.false"
}
},
{
"id": "n3",
"type": "RawForward",
"pos_x": 45,
"pos_y": 750,
"config": {
"passthrough_headers": true,
"extra_headers": "{\"connection\": \"close\"}",
"_origId": "n3"
},
"in": {
"depends": "n1.true"
}
},
{
"id": "n1",
"type": "If",
"pos_x": 344,
"pos_y": 730,
"config": {
"expr": "([[OUT3]] contains \"Stream&#32;failed&#32;to\") || ([[OUT3]] contains \"gemini-2.5-pro\")",
"_origId": "n1"
},
"in": {
"depends": "n3.done"
}
},
{
"id": "n4",
"type": "ProviderCall",
"pos_x": -185.88888888888889,
"pos_y": 523,
"config": {
"provider": "gemini_image",
"provider_configs": {
"openai": {
"base_url": "https://api.openai.com",
"endpoint": "/v1/chat/completions",
"headers": "{\"Authorization\":\"Bearer [[VAR:incoming.headers.authorization]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"max_completion_tokens\": {{ incoming.json.max_completion_tokens|default(params.max_tokens|default(256)) }},\n \"presence_penalty\": {{ incoming.json.presence_penalty|default(0) }},\n \"frequency_penalty\": {{ incoming.json.frequency_penalty|default(0) }},\n \"stop\": {{ incoming.json.stop|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }}\n}"
},
"gemini": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]",
"headers": "{}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"safetySettings\": {{ incoming.json.safetySettings|default([]) }},\n \"generationConfig\": {\n \"temperature\": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},\n \"topP\": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},\n \"maxOutputTokens\": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},\n \"stopSequences\": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }},\n \"candidateCount\": {{ incoming.json.generationConfig.candidateCount|default(1) }},\n \"thinkingConfig\": {\n \"includeThoughts\": {{ incoming.json.generationConfig.thinkingConfig.includeThoughts|default(false) }},\n \"thinkingBudget\": {{ incoming.json.generationConfig.thinkingConfig.thinkingBudget|default(0) }}\n }\n }\n}"
},
"gemini_image": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/gemini-2.5-flash-image-preview:generateContent",
"headers": "{\"x-goog-api-key\":\"[[VAR:incoming.api_keys.key]]\"}",
"template": "{\n \"model\": \"gemini-2.5-flash-image-preview\",\n [[OUT3]]\n}"
},
"claude": {
"base_url": "https://api.anthropic.com",
"endpoint": "/v1/messages",
"headers": "{\"x-api-key\":\"[[VAR:incoming.headers.x-api-key]]\",\"anthropic-version\":\"2023-06-01\",\"anthropic-beta\":\"[[VAR:incoming.headers.anthropic-beta]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"stop_sequences\": {{ incoming.json.stop_sequences|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }},\n \"thinking\": {\n \"type\": \"{{ incoming.json.thinking.type|default('disabled') }}\",\n \"budget_tokens\": {{ incoming.json.thinking.budget_tokens|default(0) }}\n },\n \"anthropic_version\": \"{{ anthropic_version|default('2023-06-01') }}\"\n}"
}
},
"blocks": [],
"_origId": "n4"
},
"in": {
"depends": "n6.done"
}
},
{
"id": "n6",
"type": "ProviderCall",
"pos_x": -391,
"pos_y": 648,
"config": {
"provider": "gemini",
"provider_configs": {
"openai": {
"base_url": "https://api.openai.com",
"endpoint": "/v1/chat/completions",
"headers": "{\"Authorization\":\"Bearer [[VAR:incoming.headers.authorization]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"max_completion_tokens\": {{ incoming.json.max_completion_tokens|default(params.max_tokens|default(256)) }},\n \"presence_penalty\": {{ incoming.json.presence_penalty|default(0) }},\n \"frequency_penalty\": {{ incoming.json.frequency_penalty|default(0) }},\n \"stop\": {{ incoming.json.stop|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }}\n}"
},
"gemini": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]",
"headers": "{}",
"template": "{\n \"model\": \"{{ model }}\",\n [[VAR:incoming.json.contents]],\n [[PROMPT]],\n \"safetySettings\": {{ incoming.json.safetySettings|default([]) }},\n \"generationConfig\": {\n \"temperature\": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},\n \"topP\": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},\n \"maxOutputTokens\": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},\n \"stopSequences\": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }},\n \"candidateCount\": {{ incoming.json.generationConfig.candidateCount|default(1) }},\n \"thinkingConfig\": {\n \"includeThoughts\": {{ incoming.json.generationConfig.thinkingConfig.includeThoughts|default(false) }},\n \"thinkingBudget\": {{ incoming.json.generationConfig.thinkingConfig.thinkingBudget|default(0) }}\n }\n }\n}"
},
"gemini_image": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent",
"headers": "{\"x-goog-api-key\":\"[[VAR:incoming.api_keys.key]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]]\n}"
},
"claude": {
"base_url": "https://api.anthropic.com",
"endpoint": "/v1/messages",
"headers": "{\"x-api-key\":\"[[VAR:incoming.headers.x-api-key]]\",\"anthropic-version\":\"2023-06-01\",\"anthropic-beta\":\"[[VAR:incoming.headers.anthropic-beta]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"stop_sequences\": {{ incoming.json.stop_sequences|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }},\n \"thinking\": {\n \"type\": \"{{ incoming.json.thinking.type|default('disabled') }}\",\n \"budget_tokens\": {{ incoming.json.thinking.budget_tokens|default(0) }}\n },\n \"anthropic_version\": \"{{ anthropic_version|default('2023-06-01') }}\"\n}"
}
},
"blocks": [
{
"id": "bmfuw6ayo",
"name": "Создание промпта",
"role": "user",
"prompt": "Создай промпт для генерации изображения исходя из последнего действие {{user}}. Промпт должен быть лаконичный, простенький, без сложных формулировок. В ответе не пиши ничего кроме промпта.",
"enabled": true,
"order": 0
}
],
"_origId": "n6"
},
"in": {
"depends": "n5.done"
}
}
]
}

171
presets/prepprst.json Normal file
View File

@@ -0,0 +1,171 @@
{
"id": "pipeline_editor",
"name": "Edited Pipeline",
"parallel_limit": 8,
"loop_mode": "iterative",
"loop_max_iters": 1000,
"loop_time_budget_ms": 999999999999,
"clear_var_store": true,
"http_timeout_sec": 999,
"text_extract_strategy": "auto",
"text_extract_json_path": "",
"text_join_sep": "\n",
"text_extract_presets": [
{
"id": "pmfqonx6fvcubc09k4ep",
"name": "candidates.0.content.parts.1.inlineData.data",
"strategy": "jsonpath",
"json_path": "candidates.0.content.parts.1.inlineData.data",
"join_sep": "\n"
},
{
"id": "pmfqrelw6wu9rutnzk1",
"name": "candidates.0.content.parts.1.inlineData",
"strategy": "jsonpath",
"json_path": "candidates.0.content.parts.1.inlineData",
"join_sep": "\n"
}
],
"nodes": [
{
"id": "n5",
"type": "SetVars",
"pos_x": -125,
"pos_y": 561,
"config": {
"variables": [
{
"id": "vmfi99ftc",
"name": "Clod",
"mode": "string",
"value": "igrovik"
},
{
"id": "vmfi99gjw",
"name": "MyOpenAiKey",
"mode": "string",
"value": "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"
},
{
"id": "vmfjkn09i",
"name": "NAMETest",
"mode": "expr",
"value": "128 + 64"
}
],
"_origId": "n5"
},
"in": {}
},
{
"id": "n2",
"type": "Return",
"pos_x": 954,
"pos_y": 564,
"config": {
"target_format": "auto",
"text_template": "[[OUT4]]",
"_origId": "n2"
},
"in": {
"depends": "n6.false"
}
},
{
"id": "n3",
"type": "RawForward",
"pos_x": 74,
"pos_y": 450.5,
"config": {
"passthrough_headers": true,
"extra_headers": "{\"connection\": \"close\"}",
"_origId": "n3"
},
"in": {
"depends": [
"n5.done",
"n1.true"
]
}
},
{
"id": "n1",
"type": "If",
"pos_x": 75,
"pos_y": 909,
"config": {
"expr": "([[OUT3]] contains \"Stream&#32;failed&#32;to\") || ([[OUT3]] contains \"gemini-2.5-pro\")",
"_origId": "n1"
},
"in": {
"depends": "n3.done"
}
},
{
"id": "n4",
"type": "ProviderCall",
"pos_x": 663,
"pos_y": 335,
"config": {
"provider": "gemini",
"provider_configs": {
"openai": {
"base_url": "https://api.openai.com",
"endpoint": "/v1/chat/completions",
"headers": "{\"Authorization\":\"Bearer [[VAR:incoming.headers.authorization]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"max_completion_tokens\": {{ incoming.json.max_completion_tokens|default(params.max_tokens|default(256)) }},\n \"presence_penalty\": {{ incoming.json.presence_penalty|default(0) }},\n \"frequency_penalty\": {{ incoming.json.frequency_penalty|default(0) }},\n \"stop\": {{ incoming.json.stop|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }}\n}"
},
"gemini": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]",
"headers": "{}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"safetySettings\": {{ incoming.json.safetySettings|default([]) }},\n \"generationConfig\": {\n \"temperature\": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},\n \"topP\": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},\n \"maxOutputTokens\": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},\n \"stopSequences\": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }},\n \"candidateCount\": {{ incoming.json.generationConfig.candidateCount|default(1) }},\n \"thinkingConfig\": {\n \"includeThoughts\": {{ incoming.json.generationConfig.thinkingConfig.includeThoughts|default(false) }},\n \"thinkingBudget\": {{ incoming.json.generationConfig.thinkingConfig.thinkingBudget|default(0) }}\n }\n }\n}"
},
"gemini_image": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent",
"headers": "{\"x-goog-api-key\":\"[[VAR:incoming.api_keys.key]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]]\n}"
},
"claude": {
"base_url": "https://api.anthropic.com",
"endpoint": "/v1/messages",
"headers": "{\"x-api-key\":\"[[VAR:incoming.headers.x-api-key]]\",\"anthropic-version\":\"2023-06-01\",\"anthropic-beta\":\"[[VAR:incoming.headers.anthropic-beta]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"stop_sequences\": {{ incoming.json.stop_sequences|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }},\n \"thinking\": {\n \"type\": \"{{ incoming.json.thinking.type|default('disabled') }}\",\n \"budget_tokens\": {{ incoming.json.thinking.budget_tokens|default(0) }}\n },\n \"anthropic_version\": \"{{ anthropic_version|default('2023-06-01') }}\"\n}"
}
},
"blocks": [
{
"id": "bmfwy94ev",
"name": "Твой ответ недостаточно хорош",
"role": "user",
"prompt": "Твой ответ:\n```\n[[OUT3]]\n```\nнедостаточно хорош, при его написании ты не следовал инструкциям. переделай исходя из инструкций, найди недостатки.",
"enabled": true,
"order": 0
}
],
"_origId": "n4",
"prompt_combine": "[[VAR:incoming.json.contents]] & [[PROMPT]]@pos=-1"
},
"in": {
"depends": [
"n6.true",
"n1.false"
]
}
},
{
"id": "n6",
"type": "If",
"pos_x": 675,
"pos_y": 882.25,
"config": {
"expr": "([[OUT4]] contains \"Stream&#32;failed&#32;to\") || ([[OUT4]] contains \"gemini-2.5-pro\")",
"_origId": "n6"
},
"in": {
"depends": "n4.done"
}
}
]
}

105
presets/retry.json Normal file
View File

@@ -0,0 +1,105 @@
{
"id": "pipeline_editor",
"name": "Edited Pipeline",
"parallel_limit": 8,
"loop_mode": "iterative",
"loop_max_iters": 1000,
"loop_time_budget_ms": 999999999999,
"clear_var_store": true,
"http_timeout_sec": 999,
"text_extract_strategy": "auto",
"text_extract_json_path": "",
"text_join_sep": "\n",
"text_extract_presets": [
{
"id": "pmfqonx6fvcubc09k4ep",
"name": "candidates.0.content.parts.1.inlineData.data",
"strategy": "jsonpath",
"json_path": "candidates.0.content.parts.1.inlineData.data",
"join_sep": "\n"
},
{
"id": "pmfqrelw6wu9rutnzk1",
"name": "candidates.0.content.parts.1.inlineData",
"strategy": "jsonpath",
"json_path": "candidates.0.content.parts.1.inlineData",
"join_sep": "\n"
}
],
"nodes": [
{
"id": "n5",
"type": "SetVars",
"pos_x": -125,
"pos_y": 561,
"config": {
"variables": [
{
"id": "vmfi99ftc",
"name": "Clod",
"mode": "string",
"value": "igrovik"
},
{
"id": "vmfi99gjw",
"name": "MyOpenAiKey",
"mode": "string",
"value": "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"
},
{
"id": "vmfjkn09i",
"name": "NAMETest",
"mode": "expr",
"value": "128 + 64"
}
],
"_origId": "n5"
},
"in": {}
},
{
"id": "n2",
"type": "Return",
"pos_x": 507,
"pos_y": 459,
"config": {
"target_format": "auto",
"text_template": "[[OUT3]]",
"_origId": "n2"
},
"in": {
"depends": "n1.false"
}
},
{
"id": "n3",
"type": "RawForward",
"pos_x": 114,
"pos_y": 425,
"config": {
"passthrough_headers": true,
"extra_headers": "{\"connection\": \"close\"}",
"_origId": "n3"
},
"in": {
"depends": [
"n5.done",
"n1.true"
]
}
},
{
"id": "n1",
"type": "If",
"pos_x": 344,
"pos_y": 730,
"config": {
"expr": "([[OUT3]] contains \"Stream&#32;failed&#32;to\") || ([[OUT3]] contains \"gemini-2.5-pro\")",
"_origId": "n1"
},
"in": {
"depends": "n3.done"
}
}
]
}

278
presets/test2.json Normal file
View File

@@ -0,0 +1,278 @@
{
"id": "pipeline_editor",
"name": "Edited Pipeline",
"parallel_limit": 8,
"loop_mode": "iterative",
"loop_max_iters": 1000,
"loop_time_budget_ms": 999999999999,
"clear_var_store": true,
"http_timeout_sec": 999,
"text_extract_strategy": "auto",
"text_extract_json_path": "",
"text_join_sep": "\n",
"text_extract_presets": [
{
"id": "pmfipb98aywtx6jepd5",
"name": "ввв",
"strategy": "jsonpath",
"json_path": "ввв",
"join_sep": "\n"
}
],
"nodes": [
{
"id": "n1",
"type": "RawForward",
"pos_x": 441,
"pos_y": 354,
"config": {
"passthrough_headers": true,
"extra_headers": "{}",
"_origId": "n1"
},
"in": {
"depends": "n5.done"
}
},
{
"id": "n2",
"type": "ProviderCall",
"pos_x": 652,
"pos_y": 46,
"config": {
"provider": "gemini",
"provider_configs": {
"openai": {
"base_url": "https://api.openai.com",
"endpoint": "/v1/chat/completions",
"headers": "{\"Authorization\":\"Bearer [[MyOpenAiKey]]\"}",
"template": "{\n \"model\": \"gpt-5-chat-latest\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": 500,\n \"presence_penalty\": {{ incoming.json.presence_penalty|default(0) }},\n \"frequency_penalty\": {{ incoming.json.frequency_penalty|default(0) }},\n \"stop\": {{ incoming.json.stop|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }}\n}"
},
"gemini": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]",
"headers": "{}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"safetySettings\": {{ incoming.json.safetySettings|default([]) }},\n \"generationConfig\": {\n \"temperature\": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},\n \"topP\": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},\n \"maxOutputTokens\": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},\n \"stopSequences\": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }},\n \"candidateCount\": {{ incoming.json.generationConfig.candidateCount|default(1) }},\n \"thinkingConfig\": {\n \"includeThoughts\": {{ incoming.json.generationConfig.thinkingConfig.includeThoughts|default(false) }},\n \"thinkingBudget\": {{ incoming.json.generationConfig.thinkingConfig.thinkingBudget|default(0) }}\n }\n }\n}"
},
"claude": {
"base_url": "https://proxy.malepreg.lol/proxy/aws/claude",
"endpoint": "/v1/messages",
"headers": "{\"x-api-key\":\"[[Clod]]\",\"anthropic-version\":\"2023-06-01\",\"anthropic-beta\":\"output-128k-2025-02-19\"}",
"template": "{\n \"model\": \"claude-opus-4-20250514\",\n [[PROMPT]],\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"stop_sequences\": {{ incoming.json.stop_sequences|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }},\n \"thinking\": {\n \"type\": \"{{ incoming.json.thinking.type|default('enabled') }}\",\n \"budget_tokens\": {{ incoming.json.thinking.budget_tokens|default(3000) }}\n }\n}"
},
"gemini_image": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent",
"headers": "{\"x-goog-api-key\":\"[[VAR:incoming.api_keys.key]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]]\n}"
}
},
"blocks": [
{
"id": "bmfmstojw",
"name": "Great assustant",
"role": "system",
"prompt": "You are Great assustant",
"enabled": true,
"order": 0
},
{
"id": "bmfchnynm",
"name": "Сделай [[OUT1]] красивее",
"role": "user",
"prompt": "Сделай [[OUT1]] красивее",
"enabled": true,
"order": 1
}
],
"_origId": "n2"
},
"in": {
"depends": "n1.done"
}
},
{
"id": "n3",
"type": "ProviderCall",
"pos_x": 654,
"pos_y": 566,
"config": {
"provider": "openai",
"provider_configs": {
"openai": {
"base_url": "https://api.openai.com",
"endpoint": "/v1/chat/completions",
"headers": "{\"Authorization\":\"Bearer [[MyOpenAiKey]]\"}",
"template": "{\n \"model\": \"gpt-5-chat-latest\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": 500,\n \"presence_penalty\": {{ incoming.json.presence_penalty|default(0) }},\n \"frequency_penalty\": {{ incoming.json.frequency_penalty|default(0) }},\n \"stop\": {{ incoming.json.stop|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }}\n}"
},
"gemini": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]",
"headers": "{}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"safetySettings\": {{ incoming.json.safetySettings|default([]) }},\n \"generationConfig\": {\n \"temperature\": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},\n \"topP\": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},\n \"maxOutputTokens\": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},\n \"stopSequences\": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }},\n \"candidateCount\": {{ incoming.json.generationConfig.candidateCount|default(1) }},\n \"thinkingConfig\": {\n \"includeThoughts\": {{ incoming.json.generationConfig.thinkingConfig.includeThoughts|default(false) }},\n \"thinkingBudget\": {{ incoming.json.generationConfig.thinkingConfig.thinkingBudget|default(0) }}\n }\n }\n}"
},
"claude": {
"base_url": "https://api.anthropic.com",
"endpoint": "/v1/messages",
"headers": "{\"x-api-key\":\"[[VAR:incoming.headers.x-api-key]]\",\"anthropic-version\":\"2023-06-01\",\"anthropic-beta\":\"[[VAR:incoming.headers.anthropic-beta]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"stop_sequences\": {{ incoming.json.stop_sequences|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }},\n \"thinking\": {\n \"type\": \"{{ incoming.json.thinking.type|default('disabled') }}\",\n \"budget_tokens\": {{ incoming.json.thinking.budget_tokens|default(0) }}\n },\n \"anthropic_version\": \"{{ anthropic_version|default('2023-06-01') }}\"\n}"
},
"gemini_image": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent",
"headers": "{\"x-goog-api-key\":\"[[VAR:incoming.api_keys.key]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]]\n}"
}
},
"blocks": [
{
"id": "bmfchn1hq",
"name": "Сделай [[OUT1]] красивее",
"role": "user",
"prompt": "Сделай [[OUT1]] красивее",
"enabled": true,
"order": 0
}
],
"_origId": "n3"
},
"in": {
"depends": "n1.done"
}
},
{
"id": "n4",
"type": "Return",
"pos_x": 1193,
"pos_y": 314,
"config": {
"target_format": "auto",
"text_template": "[[OUT6]] [[Test]]",
"_origId": "n4"
},
"in": {
"depends": "n7.true"
}
},
{
"id": "n5",
"type": "SetVars",
"pos_x": 171,
"pos_y": 487,
"config": {
"variables": [
{
"id": "vmfi99ftc",
"name": "Clod",
"mode": "string",
"value": "igrovik"
},
{
"id": "vmfi99gjw",
"name": "MyOpenAiKey",
"mode": "string",
"value": "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"
},
{
"id": "vmfjkn09i",
"name": "NAMETest",
"mode": "expr",
"value": "128 + 64"
}
],
"_origId": "n5"
},
"in": {}
},
{
"id": "n6",
"type": "ProviderCall",
"pos_x": 923,
"pos_y": 345,
"config": {
"provider": "openai",
"provider_configs": {
"openai": {
"base_url": "https://api.openai.com",
"endpoint": "/v1/chat/completions",
"headers": "{\"Authorization\":\"Bearer [[MyOpenAiKey]]\"}",
"template": "{\n \"model\": \"gpt-5-chat-latest\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": 500,\n \"presence_penalty\": {{ incoming.json.presence_penalty|default(0) }},\n \"frequency_penalty\": {{ incoming.json.frequency_penalty|default(0) }},\n \"stop\": {{ incoming.json.stop|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }}\n}"
},
"gemini": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]",
"headers": "{}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"safetySettings\": {{ incoming.json.safetySettings|default([]) }},\n \"generationConfig\": {\n \"temperature\": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},\n \"topP\": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},\n \"maxOutputTokens\": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},\n \"stopSequences\": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }},\n \"candidateCount\": {{ incoming.json.generationConfig.candidateCount|default(1) }},\n \"thinkingConfig\": {\n \"includeThoughts\": {{ incoming.json.generationConfig.thinkingConfig.includeThoughts|default(false) }},\n \"thinkingBudget\": {{ incoming.json.generationConfig.thinkingConfig.thinkingBudget|default(0) }}\n }\n }\n}"
},
"claude": {
"base_url": "https://api.anthropic.com",
"endpoint": "/v1/messages",
"headers": "{\"x-api-key\":\"[[VAR:incoming.headers.x-api-key]]\",\"anthropic-version\":\"2023-06-01\",\"anthropic-beta\":\"[[VAR:incoming.headers.anthropic-beta]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"stop_sequences\": {{ incoming.json.stop_sequences|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }},\n \"thinking\": {\n \"type\": \"{{ incoming.json.thinking.type|default('disabled') }}\",\n \"budget_tokens\": {{ incoming.json.thinking.budget_tokens|default(0) }}\n },\n \"anthropic_version\": \"{{ anthropic_version|default('2023-06-01') }}\"\n}"
},
"gemini_image": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent",
"headers": "{\"x-goog-api-key\":\"[[VAR:incoming.api_keys.key]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]]\n}"
}
},
"blocks": [
{
"id": "bmfmk7g4a",
"name": "New Block",
"role": "system",
"prompt": "",
"enabled": true,
"order": 0
},
{
"id": "bmfdyczbd",
"name": "Объедени [[OUT3]], [[OUT4]] сделай более красиво.",
"role": "user",
"prompt": "Объедени [ [[OUT3]], [[OUT2]] ] сделай более красиво. напиши слово \"Красиво\" в конце.",
"enabled": true,
"order": 1
},
{
"id": "bmfh98jkh",
"name": "New Block1",
"role": "system",
"prompt": "1",
"enabled": true,
"order": 2
},
{
"id": "bmfmk74yz",
"name": "New Block",
"role": "assistant",
"prompt": "fuf",
"enabled": true,
"order": 3
}
],
"_origId": "n6"
},
"in": {
"depends": [
"n2.done",
"n3.done",
"n7.false"
]
}
},
{
"id": "n7",
"type": "If",
"pos_x": 1311,
"pos_y": 566,
"config": {
"expr": "[[OUT6]] contains \"Красиво\"",
"_origId": "n7"
},
"in": {
"depends": "n6.done"
}
}
]
}

237
presets/test3.json Normal file
View File

@@ -0,0 +1,237 @@
{
"id": "pipeline_editor",
"name": "Edited Pipeline",
"parallel_limit": 8,
"loop_mode": "iterative",
"loop_max_iters": 1000,
"loop_time_budget_ms": 999999999999,
"clear_var_store": true,
"http_timeout_sec": 999,
"text_extract_strategy": "auto",
"text_extract_json_path": "",
"text_join_sep": "\n",
"text_extract_presets": [
{
"id": "pmfipb98aywtx6jepd5",
"name": "ввв",
"strategy": "jsonpath",
"json_path": "ввв",
"join_sep": "\n"
}
],
"nodes": [
{
"id": "n1",
"type": "RawForward",
"pos_x": 450,
"pos_y": 352,
"config": {
"passthrough_headers": true,
"extra_headers": "{}",
"_origId": "n1",
"sleep_ms": 5000
},
"in": {
"depends": "n5.done"
}
},
{
"id": "n2",
"type": "ProviderCall",
"pos_x": 653,
"pos_y": 51,
"config": {
"provider": "claude",
"provider_configs": {
"openai": {
"base_url": "https://api.openai.com",
"endpoint": "/v1/chat/completions",
"headers": "{\"Authorization\":\"Bearer [[MyOpenAiKey]]\"}",
"template": "{\n \"model\": \"gpt-5-chat-latest\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": 500,\n \"presence_penalty\": {{ incoming.json.presence_penalty|default(0) }},\n \"frequency_penalty\": {{ incoming.json.frequency_penalty|default(0) }},\n \"stop\": {{ incoming.json.stop|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }}\n}"
},
"gemini": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]",
"headers": "{}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"safetySettings\": {{ incoming.json.safetySettings|default([]) }},\n \"generationConfig\": {\n \"temperature\": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},\n \"topP\": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},\n \"maxOutputTokens\": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},\n \"stopSequences\": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }},\n \"candidateCount\": {{ incoming.json.generationConfig.candidateCount|default(1) }},\n \"thinkingConfig\": {\n \"includeThoughts\": {{ incoming.json.generationConfig.thinkingConfig.includeThoughts|default(false) }},\n \"thinkingBudget\": {{ incoming.json.generationConfig.thinkingConfig.thinkingBudget|default(0) }}\n }\n }\n}"
},
"claude": {
"base_url": "https://proxy.malepreg.lol/proxy/aws/claude",
"endpoint": "/v1/messages",
"headers": "{\"x-api-key\":\"[[Clod]]\",\"anthropic-version\":\"2023-06-01\",\"anthropic-beta\":\"output-128k-2025-02-19\"}",
"template": "{\n \"model\": \"claude-opus-4-20250514\",\n [[PROMPT]],\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"stop_sequences\": {{ incoming.json.stop_sequences|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }},\n \"thinking\": {\n \"type\": \"{{ incoming.json.thinking.type|default('enabled') }}\",\n \"budget_tokens\": {{ incoming.json.thinking.budget_tokens|default(6000) }}\n }\n}"
}
},
"blocks": [
{
"id": "bmfchnynm",
"name": "Сделай [[OUT1]] красивее",
"role": "user",
"prompt": "Сделай [[OUT1]] красивее",
"enabled": true,
"order": 0
}
],
"_origId": "n2"
},
"in": {
"depends": "n1.done"
}
},
{
"id": "n3",
"type": "ProviderCall",
"pos_x": 658,
"pos_y": 564,
"config": {
"provider": "openai",
"provider_configs": {
"openai": {
"base_url": "https://api.openai.com",
"endpoint": "/v1/chat/completions",
"headers": "{\"Authorization\":\"Bearer [[MyOpenAiKey]]\"}",
"template": "{\n \"model\": \"gpt-5-chat-latest\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": 500,\n \"presence_penalty\": {{ incoming.json.presence_penalty|default(0) }},\n \"frequency_penalty\": {{ incoming.json.frequency_penalty|default(0) }},\n \"stop\": {{ incoming.json.stop|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }}\n}"
},
"gemini": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]",
"headers": "{}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"safetySettings\": {{ incoming.json.safetySettings|default([]) }},\n \"generationConfig\": {\n \"temperature\": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},\n \"topP\": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},\n \"maxOutputTokens\": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},\n \"stopSequences\": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }},\n \"candidateCount\": {{ incoming.json.generationConfig.candidateCount|default(1) }},\n \"thinkingConfig\": {\n \"includeThoughts\": {{ incoming.json.generationConfig.thinkingConfig.includeThoughts|default(false) }},\n \"thinkingBudget\": {{ incoming.json.generationConfig.thinkingConfig.thinkingBudget|default(0) }}\n }\n }\n}"
},
"claude": {
"base_url": "https://api.anthropic.com",
"endpoint": "/v1/messages",
"headers": "{\"x-api-key\":\"[[VAR:incoming.headers.x-api-key]]\",\"anthropic-version\":\"2023-06-01\",\"anthropic-beta\":\"[[VAR:incoming.headers.anthropic-beta]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"stop_sequences\": {{ incoming.json.stop_sequences|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }},\n \"thinking\": {\n \"type\": \"{{ incoming.json.thinking.type|default('disabled') }}\",\n \"budget_tokens\": {{ incoming.json.thinking.budget_tokens|default(0) }}\n },\n \"anthropic_version\": \"{{ anthropic_version|default('2023-06-01') }}\"\n}"
}
},
"blocks": [
{
"id": "bmfchn1hq",
"name": "Сделай [[OUT1]] красивее",
"role": "user",
"prompt": "Сделай [[OUT1]] красивее",
"enabled": true,
"order": 0
}
],
"_origId": "n3"
},
"in": {
"depends": "n1.done"
}
},
{
"id": "n4",
"type": "Return",
"pos_x": 1277,
"pos_y": 139,
"config": {
"target_format": "auto",
"text_template": "[[OUT6]] [[Test]]",
"_origId": "n4"
},
"in": {
"depends": "n7.true"
}
},
{
"id": "n5",
"type": "SetVars",
"pos_x": 171,
"pos_y": 489,
"config": {
"variables": [
{
"id": "vmfi99ftc",
"name": "Clod",
"mode": "string",
"value": "igrovik"
},
{
"id": "vmfi99gjw",
"name": "MyOpenAiKey",
"mode": "string",
"value": "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"
},
{
"id": "vmfi99h1v",
"name": "Test",
"mode": "string",
"value": "Ьыыы"
}
],
"_origId": "n5"
},
"in": {}
},
{
"id": "n6",
"type": "ProviderCall",
"pos_x": 923,
"pos_y": 345,
"config": {
"provider": "openai",
"provider_configs": {
"openai": {
"base_url": "https://api.openai.com",
"endpoint": "/v1/chat/completions",
"headers": "{\"Authorization\":\"Bearer [[MyOpenAiKey]]\"}",
"template": "{\n \"model\": \"gpt-5-chat-latest\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": 500,\n \"presence_penalty\": {{ incoming.json.presence_penalty|default(0) }},\n \"frequency_penalty\": {{ incoming.json.frequency_penalty|default(0) }},\n \"stop\": {{ incoming.json.stop|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }}\n}"
},
"gemini": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]",
"headers": "{}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"safetySettings\": {{ incoming.json.safetySettings|default([]) }},\n \"generationConfig\": {\n \"temperature\": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},\n \"topP\": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},\n \"maxOutputTokens\": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},\n \"stopSequences\": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }},\n \"candidateCount\": {{ incoming.json.generationConfig.candidateCount|default(1) }},\n \"thinkingConfig\": {\n \"includeThoughts\": {{ incoming.json.generationConfig.thinkingConfig.includeThoughts|default(false) }},\n \"thinkingBudget\": {{ incoming.json.generationConfig.thinkingConfig.thinkingBudget|default(0) }}\n }\n }\n}"
},
"claude": {
"base_url": "https://api.anthropic.com",
"endpoint": "/v1/messages",
"headers": "{\"x-api-key\":\"[[VAR:incoming.headers.x-api-key]]\",\"anthropic-version\":\"2023-06-01\",\"anthropic-beta\":\"[[VAR:incoming.headers.anthropic-beta]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"stop_sequences\": {{ incoming.json.stop_sequences|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }},\n \"thinking\": {\n \"type\": \"{{ incoming.json.thinking.type|default('disabled') }}\",\n \"budget_tokens\": {{ incoming.json.thinking.budget_tokens|default(0) }}\n },\n \"anthropic_version\": \"{{ anthropic_version|default('2023-06-01') }}\"\n}"
}
},
"blocks": [
{
"id": "bmfdyczbd",
"name": "Объедени [[OUT3]], [[OUT4]] сделай более красиво.",
"role": "user",
"prompt": "Объедени [ [[OUT3]], [[OUT2]] ] сделай более красиво. напиши слово \"Красиво\" в конце.",
"enabled": true,
"order": 0
},
{
"id": "bmfh98jkh",
"name": "New Block1",
"role": "system",
"prompt": "1",
"enabled": true,
"order": 1
}
],
"_origId": "n6"
},
"in": {
"depends": [
"n2.done",
"n3.done",
"n7.false"
]
}
},
{
"id": "n7",
"type": "If",
"pos_x": 1313,
"pos_y": 566,
"config": {
"expr": "[[OUT6]] contains \"Красиво\"",
"_origId": "n7"
},
"in": {
"depends": "n6.done"
}
}
]
}

171
presets/testtesttt.json Normal file
View File

@@ -0,0 +1,171 @@
{
"id": "pipeline_editor",
"name": "Edited Pipeline",
"parallel_limit": 8,
"loop_mode": "iterative",
"loop_max_iters": 1000,
"loop_time_budget_ms": 999999999999,
"clear_var_store": true,
"http_timeout_sec": 999,
"text_extract_strategy": "auto",
"text_extract_json_path": "",
"text_join_sep": "\n",
"text_extract_presets": [
{
"id": "pmfqonx6fvcubc09k4ep",
"name": "candidates.0.content.parts.1.inlineData.data",
"strategy": "jsonpath",
"json_path": "candidates.0.content.parts.1.inlineData.data",
"join_sep": "\n"
},
{
"id": "pmfqrelw6wu9rutnzk1",
"name": "candidates.0.content.parts.1.inlineData",
"strategy": "jsonpath",
"json_path": "candidates.0.content.parts.1.inlineData",
"join_sep": "\n"
}
],
"nodes": [
{
"id": "n5",
"type": "SetVars",
"pos_x": -125,
"pos_y": 561,
"config": {
"variables": [
{
"id": "vmfi99ftc",
"name": "Clod",
"mode": "string",
"value": "igrovik"
},
{
"id": "vmfi99gjw",
"name": "MyOpenAiKey",
"mode": "string",
"value": "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"
},
{
"id": "vmfjkn09i",
"name": "NAMETest",
"mode": "expr",
"value": "128 + 64"
}
],
"_origId": "n5"
},
"in": {}
},
{
"id": "n2",
"type": "Return",
"pos_x": 954,
"pos_y": 564,
"config": {
"target_format": "auto",
"text_template": "[[OUT4]]",
"_origId": "n2"
},
"in": {
"depends": "n6.false"
}
},
{
"id": "n3",
"type": "RawForward",
"pos_x": 72,
"pos_y": 444,
"config": {
"passthrough_headers": true,
"extra_headers": "{\"connection\": \"close\"}",
"_origId": "n3"
},
"in": {
"depends": [
"n5.done",
"n1.true"
]
}
},
{
"id": "n1",
"type": "If",
"pos_x": 75,
"pos_y": 909,
"config": {
"expr": "([[OUT3]] contains \"Stream&#32;failed&#32;to\") || ([[OUT3]] contains \"gemini-2.5-pro\")",
"_origId": "n1"
},
"in": {
"depends": "n3.done"
}
},
{
"id": "n4",
"type": "ProviderCall",
"pos_x": 663,
"pos_y": 335,
"config": {
"provider": "gemini",
"provider_configs": {
"openai": {
"base_url": "https://api.openai.com",
"endpoint": "/v1/chat/completions",
"headers": "{\"Authorization\":\"Bearer [[VAR:incoming.headers.authorization]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"max_completion_tokens\": {{ incoming.json.max_completion_tokens|default(params.max_tokens|default(256)) }},\n \"presence_penalty\": {{ incoming.json.presence_penalty|default(0) }},\n \"frequency_penalty\": {{ incoming.json.frequency_penalty|default(0) }},\n \"stop\": {{ incoming.json.stop|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }}\n}"
},
"gemini": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]",
"headers": "{}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"safetySettings\": {{ incoming.json.safetySettings|default([]) }},\n \"generationConfig\": {\n \"temperature\": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},\n \"topP\": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},\n \"maxOutputTokens\": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},\n \"stopSequences\": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }},\n \"candidateCount\": {{ incoming.json.generationConfig.candidateCount|default(1) }},\n \"thinkingConfig\": {\n \"includeThoughts\": {{ incoming.json.generationConfig.thinkingConfig.includeThoughts|default(false) }},\n \"thinkingBudget\": {{ incoming.json.generationConfig.thinkingConfig.thinkingBudget|default(0) }}\n }\n }\n}"
},
"gemini_image": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent",
"headers": "{\"x-goog-api-key\":\"[[VAR:incoming.api_keys.key]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]]\n}"
},
"claude": {
"base_url": "https://api.anthropic.com",
"endpoint": "/v1/messages",
"headers": "{\"x-api-key\":\"[[VAR:incoming.headers.x-api-key]]\",\"anthropic-version\":\"2023-06-01\",\"anthropic-beta\":\"[[VAR:incoming.headers.anthropic-beta]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"stop_sequences\": {{ incoming.json.stop_sequences|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }},\n \"thinking\": {\n \"type\": \"{{ incoming.json.thinking.type|default('disabled') }}\",\n \"budget_tokens\": {{ incoming.json.thinking.budget_tokens|default(0) }}\n },\n \"anthropic_version\": \"{{ anthropic_version|default('2023-06-01') }}\"\n}"
}
},
"blocks": [
{
"id": "bmfwy94ev",
"name": "Твой ответ недостаточно хорош",
"role": "user",
"prompt": "Твой ответ:\n```\n[[OUT3]]\n```\nнедостаточно хорош, при его написании ты не следовал инструкциям. переделай исходя из инструкций, найди недостатк.",
"enabled": true,
"order": 0
}
],
"_origId": "n4",
"prompt_combine": "[[VAR:incoming.json.contents]] & [[PROMPT]]@pos=-1"
},
"in": {
"depends": [
"n6.true",
"n1.false"
]
}
},
{
"id": "n6",
"type": "If",
"pos_x": 675,
"pos_y": 882.25,
"config": {
"expr": "([[OUT4]] contains \"Stream&#32;failed&#32;to\") || ([[OUT4]] contains \"gemini-2.5-pro\")",
"_origId": "n6"
},
"in": {
"depends": "n4.done"
}
}
]
}

285
presets/tttttt.json Normal file
View File

@@ -0,0 +1,285 @@
{
"id": "pipeline_editor",
"name": "Edited Pipeline",
"parallel_limit": 8,
"loop_mode": "iterative",
"loop_max_iters": 1000,
"loop_time_budget_ms": 999999999999,
"clear_var_store": true,
"http_timeout_sec": 999,
"text_extract_strategy": "auto",
"text_extract_json_path": "",
"text_join_sep": "\n",
"text_extract_presets": [
{
"id": "pmfqonx6fvcubc09k4ep",
"name": "candidates.0.content.parts.1.inlineData.data",
"strategy": "jsonpath",
"json_path": "candidates.0.content.parts.1.inlineData.data",
"join_sep": "\n"
},
{
"id": "pmfqrelw6wu9rutnzk1",
"name": "candidates.0.content.parts.1.inlineData",
"strategy": "jsonpath",
"json_path": "candidates.0.content.parts.1.inlineData",
"join_sep": "\n"
}
],
"nodes": [
{
"id": "n5",
"type": "SetVars",
"pos_x": 12,
"pos_y": 780,
"config": {
"variables": [
{
"id": "vmfi99ftc",
"name": "Clod",
"mode": "string",
"value": "igrovik"
},
{
"id": "vmfi99gjw",
"name": "MyOpenAiKey",
"mode": "string",
"value": "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"
},
{
"id": "vmfjkn09i",
"name": "NAMETest",
"mode": "expr",
"value": "128 + 64"
}
],
"_origId": "n5"
},
"in": {}
},
{
"id": "n2",
"type": "Return",
"pos_x": 1344,
"pos_y": 756,
"config": {
"target_format": "auto",
"text_template": "[[OUT7]]",
"_origId": "n2"
},
"in": {
"depends": "n8.false"
}
},
{
"id": "n3",
"type": "RawForward",
"pos_x": 588,
"pos_y": 624,
"config": {
"passthrough_headers": true,
"extra_headers": "{\"connection\": \"close\"}",
"_origId": "n3"
},
"in": {
"depends": [
"n5.done",
"n1.true"
]
}
},
{
"id": "n1",
"type": "If",
"pos_x": 564,
"pos_y": 876,
"config": {
"expr": "([[OUT3]] contains \"Stream&#32;failed&#32;to\") || ([[OUT3]] contains \"gemini-2.5-pro\")",
"_origId": "n1"
},
"in": {
"depends": "n3.done"
}
},
{
"id": "n4",
"type": "ProviderCall",
"pos_x": 792,
"pos_y": 624,
"config": {
"provider": "openai",
"provider_configs": {
"openai": {
"base_url": "https://api.openai.com",
"endpoint": "/v1/chat/completions",
"headers": "{\"Authorization\":\"[[VAR:incoming.headers.authorization]]\"}",
"template": "{\n \"model\": \"gpt-5-chat-latest\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_completion_tokens\": {{ incoming.json.max_completion_tokens|default(params.max_tokens|default(256)) }},\n \"presence_penalty\": {{ incoming.json.presence_penalty|default(0) }},\n \"frequency_penalty\": {{ incoming.json.frequency_penalty|default(0) }},\n \"stop\": {{ incoming.json.stop|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }}\n}"
},
"gemini": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]",
"headers": "{}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"safetySettings\": {{ incoming.json.safetySettings|default([]) }},\n \"generationConfig\": {\n \"temperature\": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},\n \"topP\": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},\n \"maxOutputTokens\": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},\n \"stopSequences\": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }},\n \"candidateCount\": {{ incoming.json.generationConfig.candidateCount|default(1) }},\n \"thinkingConfig\": {\n \"includeThoughts\": {{ incoming.json.generationConfig.thinkingConfig.includeThoughts|default(false) }},\n \"thinkingBudget\": {{ incoming.json.generationConfig.thinkingConfig.thinkingBudget|default(0) }}\n }\n }\n}"
},
"gemini_image": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent",
"headers": "{\"x-goog-api-key\":\"[[VAR:incoming.api_keys.key]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]]\n}"
},
"claude": {
"base_url": "https://api.anthropic.com",
"endpoint": "/v1/messages",
"headers": "{\"x-api-key\":\"[[VAR:incoming.headers.x-api-key]]\",\"anthropic-version\":\"2023-06-01\",\"anthropic-beta\":\"[[VAR:incoming.headers.anthropic-beta]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"stop_sequences\": {{ incoming.json.stop_sequences|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }},\n \"thinking\": {\n \"type\": \"{{ incoming.json.thinking.type|default('disabled') }}\",\n \"budget_tokens\": {{ incoming.json.thinking.budget_tokens|default(0) }}\n },\n \"anthropic_version\": \"{{ anthropic_version|default('2023-06-01') }}\"\n}"
}
},
"blocks": [
{
"id": "bmfwy94ev",
"name": "Твой ответ недостаточно хорош",
"role": "user",
"prompt": "Твой ответ:\n```\n[[OUT3]]\n```\nнедостаточно хорош, при его написании ты не следовал инструкциям. переделай исходя из инструкций, найди недостатк1.",
"enabled": true,
"order": 0
}
],
"_origId": "n4",
"prompt_combine": "[[VAR:incoming.json.messages]] & [[PROMPT]]"
},
"in": {
"depends": [
"n6.true",
"n1.false"
]
}
},
{
"id": "n6",
"type": "If",
"pos_x": 792,
"pos_y": 876,
"config": {
"expr": "([[OUT4]] contains \"Stream&#32;failed&#32;to\") || ([[OUT4]] contains \"gemini-2.5-pro\")",
"_origId": "n6"
},
"in": {
"depends": "n4.done"
}
},
{
"id": "n7",
"type": "ProviderCall",
"pos_x": 1056,
"pos_y": 624,
"config": {
"provider": "claude",
"provider_configs": {
"openai": {
"base_url": "https://api.openai.com",
"endpoint": "/v1/chat/completions",
"headers": "{\"Authorization\":\"Bearer [[VAR:incoming.headers.authorization]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"max_completion_tokens\": {{ incoming.json.max_completion_tokens|default(params.max_tokens|default(256)) }},\n \"presence_penalty\": {{ incoming.json.presence_penalty|default(0) }},\n \"frequency_penalty\": {{ incoming.json.frequency_penalty|default(0) }},\n \"stop\": {{ incoming.json.stop|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }}\n}"
},
"gemini": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]",
"headers": "{}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"safetySettings\": {{ incoming.json.safetySettings|default([]) }},\n \"generationConfig\": {\n \"temperature\": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},\n \"topP\": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},\n \"maxOutputTokens\": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},\n \"stopSequences\": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }},\n \"candidateCount\": {{ incoming.json.generationConfig.candidateCount|default(1) }},\n \"thinkingConfig\": {\n \"includeThoughts\": {{ incoming.json.generationConfig.thinkingConfig.includeThoughts|default(false) }},\n \"thinkingBudget\": {{ incoming.json.generationConfig.thinkingConfig.thinkingBudget|default(0) }}\n }\n }\n}"
},
"gemini_image": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent",
"headers": "{\"x-goog-api-key\":\"[[VAR:incoming.api_keys.key]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]]\n}"
},
"claude": {
"base_url": "https://proxy.malepreg.lol/proxy/aws/claude",
"endpoint": "/v1/messages",
"headers": "{\"x-api-key\":\"igrovik\",\"anthropic-version\":\"2023-06-01\",\"anthropic-beta\":\"[[VAR:incoming.headers.anthropic-beta]]\"}",
"template": "{\n \"model\": \"claude-opus-4-20250514\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"stop_sequences\": {{ incoming.json.stop_sequences|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }},\n \"thinking\": {\n \"type\": \"{{ incoming.json.thinking.type|default('disabled') }}\",\n \"budget_tokens\": {{ incoming.json.thinking.budget_tokens|default(0) }}\n },\n \"anthropic_version\": \"{{ anthropic_version|default('2023-06-01') }}\"\n}"
}
},
"blocks": [
{
"id": "bmfzvzpl7",
"name": "Может содержать такие конструкции",
"role": "user",
"prompt": "Твой ответ:\n```\n[[OUT4]]\n```\nМожет содержать такие конструкции:\n**'Not X, but Y'** narrative structure. This includes any and all variations of stating what something *is not* in order to emphasize what it *is*. Нужно заменить места на нормальный нарратив.",
"enabled": true,
"order": 0
}
],
"_origId": "n7",
"prompt_combine": "[[VAR:incoming.json.messages]] & [[PROMPT]]",
"claude_no_system": true
},
"in": {
"depends": [
"n6.false",
"n8.true"
]
}
},
{
"id": "n8",
"type": "If",
"pos_x": 1068,
"pos_y": 876,
"config": {
"expr": "([[OUT7]] contains \"Stream failed to\") || ([[OUT7]] contains \"gemini-2.5-pro\")",
"_origId": "n8"
},
"in": {
"depends": "n7.done"
}
},
{
"id": "n9",
"type": "ProviderCall",
"pos_x": 1104,
"pos_y": 456,
"config": {
"provider": "claude",
"provider_configs": {
"openai": {
"base_url": "https://api.openai.com",
"endpoint": "/v1/chat/completions",
"headers": "{\"Authorization\":\"Bearer [[VAR:incoming.headers.authorization]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"max_completion_tokens\": {{ incoming.json.max_completion_tokens|default(params.max_tokens|default(256)) }},\n \"presence_penalty\": {{ incoming.json.presence_penalty|default(0) }},\n \"frequency_penalty\": {{ incoming.json.frequency_penalty|default(0) }},\n \"stop\": {{ incoming.json.stop|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }}\n}"
},
"gemini": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]",
"headers": "{}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"safetySettings\": {{ incoming.json.safetySettings|default([]) }},\n \"generationConfig\": {\n \"temperature\": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},\n \"topP\": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},\n \"maxOutputTokens\": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},\n \"stopSequences\": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }},\n \"candidateCount\": {{ incoming.json.generationConfig.candidateCount|default(1) }},\n \"thinkingConfig\": {\n \"includeThoughts\": {{ incoming.json.generationConfig.thinkingConfig.includeThoughts|default(false) }},\n \"thinkingBudget\": {{ incoming.json.generationConfig.thinkingConfig.thinkingBudget|default(0) }}\n }\n }\n}"
},
"gemini_image": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent",
"headers": "{\"x-goog-api-key\":\"[[VAR:incoming.api_keys.key]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]]\n}"
},
"claude": {
"base_url": "https://proxy.malepreg.lol/proxy/aws/claude",
"endpoint": "/v1/messages",
"headers": "{\"x-api-key\":\"igrovik\",\"anthropic-version\":\"2023-06-01\",\"anthropic-beta\":\"[[VAR:incoming.headers.anthropic-beta]]\"}",
"template": "{\n \"model\": \"claude-opus-4-20250514\",\n [[PROMPT]],\n \"top_p\": 1,\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"stop_sequences\": {{ incoming.json.stop_sequences|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }},\n \"thinking\": {\n \"type\": \"{{ incoming.json.thinking.type|default('disabled') }}\",\n \"budget_tokens\": {{ incoming.json.thinking.budget_tokens|default(0) }}\n },\n \"anthropic_version\": \"{{ anthropic_version|default('2023-06-01') }}\"\n}"
}
},
"blocks": [
{
"id": "bmg26nusx",
"name": "New Block",
"role": "user",
"prompt": "Hey",
"enabled": true,
"order": 0
}
],
"_origId": "n9"
},
"in": {}
}
]
}

247
presets/tttttt1.json Normal file
View File

@@ -0,0 +1,247 @@
{
"id": "pipeline_editor",
"name": "Edited Pipeline",
"parallel_limit": 8,
"loop_mode": "iterative",
"loop_max_iters": 1000,
"loop_time_budget_ms": 999999999999,
"clear_var_store": true,
"http_timeout_sec": 999,
"text_extract_strategy": "auto",
"text_extract_json_path": "",
"text_join_sep": "\n",
"text_extract_presets": [
{
"id": "pmfqonx6fvcubc09k4ep",
"name": "candidates.0.content.parts.1.inlineData.data",
"strategy": "jsonpath",
"json_path": "candidates.0.content.parts.1.inlineData.data",
"join_sep": "\n"
},
{
"id": "pmfqrelw6wu9rutnzk1",
"name": "candidates.0.content.parts.1.inlineData",
"strategy": "jsonpath",
"json_path": "candidates.0.content.parts.1.inlineData",
"join_sep": "\n"
}
],
"nodes": [
{
"id": "n5",
"type": "SetVars",
"pos_x": 12,
"pos_y": 780,
"config": {
"variables": [
{
"id": "vmfi99ftc",
"name": "Clod",
"mode": "string",
"value": "igrovik"
},
{
"id": "vmfi99gjw",
"name": "MyOpenAiKey",
"mode": "string",
"value": "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"
},
{
"id": "vmfjkn09i",
"name": "NAMETest",
"mode": "expr",
"value": "128 + 64"
}
],
"_origId": "n5"
},
"in": {}
},
{
"id": "n2",
"type": "Return",
"pos_x": 1344,
"pos_y": 756,
"config": {
"target_format": "auto",
"text_template": "[[OUT7]]",
"_origId": "n2"
},
"in": {
"depends": "n8.false"
}
},
{
"id": "n3",
"type": "RawForward",
"pos_x": 588,
"pos_y": 624,
"config": {
"passthrough_headers": true,
"extra_headers": "{\"connection\": \"close\"}",
"_origId": "n3",
"while_expr": "([[OUT3]] contains \"Stream failed to\") || ([[OUT3]] contains \"gemini-2.5-pro\")",
"ignore_errors": false,
"while_max_iters": 50
},
"in": {
"depends": [
"n5.done",
"n1.true"
]
}
},
{
"id": "n1",
"type": "If",
"pos_x": 600,
"pos_y": 876,
"config": {
"expr": "([[OUT3]] contains \"Stream&#32;failed&#32;to\") || ([[OUT3]] contains \"gemini-2.5-pro\")",
"_origId": "n1"
},
"in": {
"depends": "n3.done"
}
},
{
"id": "n4",
"type": "ProviderCall",
"pos_x": 792,
"pos_y": 624,
"config": {
"provider": "gemini",
"provider_configs": {
"openai": {
"base_url": "https://api.openai.com",
"endpoint": "/v1/chat/completions",
"headers": "{\"Authorization\":\"[[VAR:incoming.headers.authorization]]\"}",
"template": "{\n \"model\": \"gpt-5-chat-latest\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_completion_tokens\": {{ incoming.json.max_completion_tokens|default(params.max_tokens|default(256)) }},\n \"presence_penalty\": {{ incoming.json.presence_penalty|default(0) }},\n \"frequency_penalty\": {{ incoming.json.frequency_penalty|default(0) }},\n \"stop\": {{ incoming.json.stop|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }}\n}"
},
"gemini": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]",
"headers": "{}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"safetySettings\": {{ incoming.json.safetySettings|default([]) }},\n \"generationConfig\": {\n \"temperature\": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},\n \"topP\": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},\n \"maxOutputTokens\": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},\n \"stopSequences\": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }},\n \"candidateCount\": {{ incoming.json.generationConfig.candidateCount|default(1) }},\n \"thinkingConfig\": {\n \"includeThoughts\": {{ incoming.json.generationConfig.thinkingConfig.includeThoughts|default(false) }},\n \"thinkingBudget\": {{ incoming.json.generationConfig.thinkingConfig.thinkingBudget|default(0) }}\n }\n }\n}"
},
"gemini_image": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent",
"headers": "{\"x-goog-api-key\":\"[[VAR:incoming.api_keys.key]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]]\n}"
},
"claude": {
"base_url": "https://api.anthropic.com",
"endpoint": "/v1/messages",
"headers": "{\"x-api-key\":\"[[VAR:incoming.headers.x-api-key]]\",\"anthropic-version\":\"2023-06-01\",\"anthropic-beta\":\"[[VAR:incoming.headers.anthropic-beta]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"stop_sequences\": {{ incoming.json.stop_sequences|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }},\n \"thinking\": {\n \"type\": \"{{ incoming.json.thinking.type|default('disabled') }}\",\n \"budget_tokens\": {{ incoming.json.thinking.budget_tokens|default(0) }}\n },\n \"anthropic_version\": \"{{ anthropic_version|default('2023-06-01') }}\"\n}"
}
},
"blocks": [
{
"id": "bmfwy94ev",
"name": "Твой ответ недостаточно хорош",
"role": "user",
"prompt": "Твой ответ:\n```\n[[OUT3]]\n```\nнедостаточно хорош, при его написании ты не следовал инструкциям. переделай исходя из инструкций, найди недостатки разобрав каждое действие оценив его логичность и следование истории от 0до10, перепиши эти моменты на нормальные.",
"enabled": true,
"order": 0
}
],
"_origId": "n4",
"prompt_combine": "[[VAR:incoming.json.contents]] & [[PROMPT]]@pos=-1",
"while_expr": "([[OUT3]] contains \"Stream failed to\") || ([[OUT3]] contains \"gemini-2.5-pro\")",
"ignore_errors": false,
"while_max_iters": 50
},
"in": {
"depends": [
"n6.true",
"n1.false"
]
}
},
{
"id": "n6",
"type": "If",
"pos_x": 852,
"pos_y": 960,
"config": {
"expr": "([[OUT4]] contains \"Stream&#32;failed&#32;to\") || ([[OUT4]] contains \"gemini-2.5-pro\")",
"_origId": "n6"
},
"in": {
"depends": "n4.done"
}
},
{
"id": "n7",
"type": "ProviderCall",
"pos_x": 1080,
"pos_y": 624,
"config": {
"provider": "gemini",
"provider_configs": {
"openai": {
"base_url": "https://api.openai.com",
"endpoint": "/v1/chat/completions",
"headers": "{\"Authorization\":\"Bearer [[MyOpenAiKey]]\"}",
"template": "{\n \"model\": \"gpt-5-chat-latest\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_completion_tokens\": {{ incoming.json.max_completion_tokens|default(params.max_tokens|default(256)) }},\n \"presence_penalty\": {{ incoming.json.presence_penalty|default(0) }},\n \"frequency_penalty\": {{ incoming.json.frequency_penalty|default(0) }},\n \"stop\": {{ incoming.json.stop|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }}\n}"
},
"gemini": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]",
"headers": "{}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"safetySettings\": {{ incoming.json.safetySettings|default([]) }},\n \"generationConfig\": {\n \"temperature\": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},\n \"topP\": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},\n \"maxOutputTokens\": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},\n \"stopSequences\": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }},\n \"candidateCount\": {{ incoming.json.generationConfig.candidateCount|default(1) }},\n \"thinkingConfig\": {\n \"includeThoughts\": {{ incoming.json.generationConfig.thinkingConfig.includeThoughts|default(false) }},\n \"thinkingBudget\": {{ incoming.json.generationConfig.thinkingConfig.thinkingBudget|default(0) }}\n }\n }\n}"
},
"gemini_image": {
"base_url": "https://generativelanguage.googleapis.com",
"endpoint": "/v1beta/models/{{ model }}:generateContent",
"headers": "{\"x-goog-api-key\":\"[[VAR:incoming.api_keys.key]]\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]]\n}"
},
"claude": {
"base_url": "https://proxy.malepreg.lol/proxy/aws/claude",
"endpoint": "/v1/messages",
"headers": "{\"x-api-key\":\"igrovik\",\"anthropic-version\":\"2023-06-01\",\"anthropic-beta\":\"[[VAR:incoming.headers.anthropic-beta]]\"}",
"template": "{\n \"model\": \"claude-opus-4-20250514\",\n [[PROMPT]],\n \"temperature\": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},\n \"top_p\": {{ incoming.json.top_p|default(params.top_p|default(1)) }},\n \"max_tokens\": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},\n \"stop_sequences\": {{ incoming.json.stop_sequences|default(params.stop|default([])) }},\n \"stream\": {{ incoming.json.stream|default(false) }},\n \"thinking\": {\n \"type\": \"{{ incoming.json.thinking.type|default('disabled') }}\",\n \"budget_tokens\": {{ incoming.json.thinking.budget_tokens|default(0) }}\n },\n \"anthropic_version\": \"{{ anthropic_version|default('2023-06-01') }}\"\n}"
}
},
"blocks": [
{
"id": "bmfzvzpl7",
"name": "Может содержать такие конструкции",
"role": "user",
"prompt": "Твой ответ:\n```\n[[OUT4]]\n```\nМожет содержать такие конструкции:\n**'Not X, but Y'** narrative structure. This includes any and all variations of stating what something *is not* in order to emphasize what it *is*. Нужно заменить места на нормальный нарратив.",
"enabled": true,
"order": 0
}
],
"_origId": "n7",
"prompt_combine": "[[VAR:incoming.json.contents]] & [[PROMPT]]@pos=-1",
"claude_no_system": true,
"while_expr": "([[OUT3]] contains \"Stream failed to\") || ([[OUT3]] contains \"gemini-2.5-pro\")",
"ignore_errors": false,
"while_max_iters": 50
},
"in": {
"depends": [
"n6.false",
"n8.true"
]
}
},
{
"id": "n8",
"type": "If",
"pos_x": 1068,
"pos_y": 876,
"config": {
"expr": "([[OUT7]] contains \"Stream failed to\") || ([[OUT7]] contains \"gemini-2.5-pro\")",
"_origId": "n8"
},
"in": {
"depends": "n7.done"
}
}
]
}

View File

@@ -1 +0,0 @@
http:ip:port:log:pass

View File

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

View File

@@ -1,27 +1,51 @@
@echo off
setlocal
chcp 65001 >NUL
set PORT=7860
echo Installing dependencies...
python -m pip install --upgrade pip
REM -------- Config --------
if "%PORT%"=="" set PORT=7860
if "%HOST%"=="" set HOST=127.0.0.1
REM ------------------------
echo [НадTavern] Preparing virtual environment...
REM Pick Python launcher
where py >NUL 2>&1
if %ERRORLEVEL%==0 (
set PY=py
) else (
set PY=python
)
REM Create venv if missing
if not exist ".venv\Scripts\python.exe" (
%PY% -m venv .venv
if errorlevel 1 goto :fail
)
set "VENV_PY=.venv\Scripts\python.exe"
echo [НадTavern] Upgrading pip...
"%VENV_PY%" -m pip install --upgrade pip
if errorlevel 1 goto :fail
pip install -r requirements.txt
echo [НадTavern] Installing dependencies from requirements.txt...
"%VENV_PY%" -m pip install -r requirements.txt
if errorlevel 1 goto :fail
echo Starting НадTavern on http://127.0.0.1:%PORT%/
echo [НадTavern] Starting on http://%HOST%:%PORT%/
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
start "" "http://%HOST%:%PORT%/ui/editor.html"
"%VENV_PY%" -m uvicorn agentui.api.server:app --host %HOST% --port %PORT% --log-level info
if errorlevel 1 goto :fail
goto :end
:fail
echo.
echo Server failed with errorlevel %errorlevel%.
echo [НадTavern] Server failed with errorlevel %errorlevel%.
echo Check the console output above and the file agentui.log for details.
pause
:end
pause
endlocal

46
run_agentui.sh Normal file
View File

@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
# НадTavern Linux/macOS launcher with local .venv bootstrap
# Usage:
# chmod +x ./run_agentui.sh
# ./run_agentui.sh
# Optional env: HOST=0.0.0.0 PORT=7860
# Go to repo root (script location)
cd "$(dirname "$0")"
PORT="${PORT:-7860}"
HOST="${HOST:-127.0.0.1}"
# Pick python
if command -v python3 >/dev/null 2>&1; then
PY=python3
else
PY=python
fi
# Create venv if missing
if [ ! -f ".venv/bin/python" ]; then
echo "[НадTavern] Creating .venv ..."
"$PY" -m venv .venv
fi
VENV_PY=".venv/bin/python"
echo "[НадTavern] Upgrading pip ..."
"$VENV_PY" -m pip install --upgrade pip
echo "[НадTavern] Installing deps from requirements.txt ..."
"$VENV_PY" -m pip install -r requirements.txt
echo "[НадTavern] Starting on http://$HOST:$PORT/"
# Try to open UI editor in default browser (non-fatal if fails)
if command -v xdg-open >/dev/null 2>&1; then
xdg-open "http://$HOST:$PORT/ui/editor.html" >/dev/null 2>&1 || true
elif command -v open >/dev/null 2>&1; then
open "http://$HOST:$PORT/ui/editor.html" >/dev/null 2>&1 || true
fi
exec "$VENV_PY" -m uvicorn agentui.api.server:app --host "$HOST" --port "$PORT" --log-level info

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,12 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>НадTavern</title>
<link rel="icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#ffffff" />
<style>
body { font-family: Arial, sans-serif; margin: 24px; }
textarea { width: 100%; height: 200px; }

View File

@@ -21,10 +21,15 @@
// Готовим новые данные с глубокой копией 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));
try {
if (w.AU && typeof w.AU.updateNodeDataAndDom === 'function') {
w.AU.updateNodeDataAndDom(editor, id, newData);
} else {
editor.updateNodeDataFromId(id, newData);
const el2 = document.querySelector(`#node-${id}`);
if (el2) el2.__data = JSON.parse(JSON.stringify(newData));
}
} catch (e) {}
} catch (e) {}
}
// Initial sync to attach blocks into __data for toPipelineJSON
@@ -40,6 +45,17 @@
const cancelBtn = document.getElementById('pm-cancel');
let editingId = null;
// Безопасное экранирование HTML для вставок в UI
function pmEscapeHtml(s) {
const str = String(s ?? '');
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Изменения блока применяются только по кнопке «Сохранить» внутри редактора блока.
// Drag&Drop через SortableJS (если доступен)
@@ -77,11 +93,13 @@
li.style.alignItems = 'center';
li.style.gap = '6px';
li.style.padding = '4px 0';
const nameDisp = pmEscapeHtml(b.name || ('Block ' + (i + 1)));
const roleDisp = pmEscapeHtml(b.role || 'user');
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>
<span class="pm-name" style="flex:1">${nameDisp}</span>
<span class="pm-role" style="opacity:.8">${roleDisp}</span>
<button class="pm-edit" title="Редактировать">✎</button>
<button class="pm-del" title="Удалить">🗑</button>
`;

View File

@@ -0,0 +1,158 @@
/* global window */
(function (w) {
'use strict';
// Centralized registry for provider-specific defaults (base_url, endpoint, headers, template)
// Exposes window.ProviderTemplates with:
// .register(name, { defaultConfig: () => ({ base_url, endpoint, headers, template }) })
// .defaults(provider)
// .ensureConfigs(nodeData)
// .getActiveProv(nodeData)
// .getActiveCfg(nodeData)
// .providers()
const PT = {};
const _registry = new Map();
function norm(p) {
return String(p == null ? 'openai' : p).toLowerCase().trim();
}
PT.register = function register(name, def) {
const key = norm(name);
if (!def || typeof def.defaultConfig !== 'function') {
throw new Error('ProviderTemplates.register: def.defaultConfig() required');
}
_registry.set(key, { defaultConfig: def.defaultConfig });
};
PT.providers = function providers() {
return Array.from(_registry.keys());
};
PT.defaults = function defaults(provider) {
const key = norm(provider);
const rec = _registry.get(key);
if (rec && typeof rec.defaultConfig === 'function') {
try { return rec.defaultConfig(); } catch (_) {}
}
return { base_url: '', endpoint: '', headers: `{}`, template: `{}` };
};
PT.ensureConfigs = function ensureConfigs(d) {
if (!d) return;
if (!d.provider) d.provider = 'openai';
if (!d.provider_configs || typeof d.provider_configs !== 'object') d.provider_configs = {};
for (const p of PT.providers()) {
if (!d.provider_configs[p]) d.provider_configs[p] = PT.defaults(p);
}
};
PT.getActiveProv = function getActiveProv(d) {
return norm(d && d.provider);
};
PT.getActiveCfg = function getActiveCfg(d) {
PT.ensureConfigs(d);
const p = PT.getActiveProv(d);
return d && d.provider_configs ? (d.provider_configs[p] || {}) : {};
};
// --- Built-in providers (default presets) ---
// Templates mirror original editor.html logic; use macros [[...]] and {{ ... }} as-is.
function T_OPENAI() { return `{
"model": "{{ model }}",
[[PROMPT]],
"temperature": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},
"top_p": {{ incoming.json.top_p|default(params.top_p|default(1)) }},
"max_tokens": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},
"max_completion_tokens": {{ incoming.json.max_completion_tokens|default(params.max_tokens|default(256)) }},
"presence_penalty": {{ incoming.json.presence_penalty|default(0) }},
"frequency_penalty": {{ incoming.json.frequency_penalty|default(0) }},
"stop": {{ incoming.json.stop|default(params.stop|default([])) }},
"stream": {{ incoming.json.stream|default(false) }}
}`; }
function T_GEMINI() { return `{
"model": "{{ model }}",
[[PROMPT]],
"safetySettings": {{ incoming.json.safetySettings|default([]) }},
"generationConfig": {
"temperature": {{ incoming.json.generationConfig.temperature|default(params.temperature|default(0.7)) }},
"topP": {{ incoming.json.generationConfig.topP|default(params.top_p|default(1)) }},
"maxOutputTokens": {{ incoming.json.generationConfig.maxOutputTokens|default(params.max_tokens|default(256)) }},
"stopSequences": {{ incoming.json.generationConfig.stopSequences|default(params.stop|default([])) }},
"candidateCount": {{ incoming.json.generationConfig.candidateCount|default(1) }},
"thinkingConfig": {
"includeThoughts": {{ incoming.json.generationConfig.thinkingConfig.includeThoughts|default(false) }},
"thinkingBudget": {{ incoming.json.generationConfig.thinkingConfig.thinkingBudget|default(0) }}
}
}
}`; }
function T_GEMINI_IMAGE() { return `{
"model": "{{ model }}",
[[PROMPT]]
}`; }
function T_CLAUDE() { return `{
"model": "{{ model }}",
[[PROMPT]],
"temperature": {{ incoming.json.temperature|default(params.temperature|default(0.7)) }},
"top_p": {{ incoming.json.top_p|default(params.top_p|default(1)) }},
"max_tokens": {{ incoming.json.max_tokens|default(params.max_tokens|default(256)) }},
"stop_sequences": {{ incoming.json.stop_sequences|default(params.stop|default([])) }},
"stream": {{ incoming.json.stream|default(false) }},
"thinking": {
"type": "{{ incoming.json.thinking.type|default('disabled') }}",
"budget_tokens": {{ incoming.json.thinking.budget_tokens|default(0) }}
},
"anthropic_version": "{{ anthropic_version|default('2023-06-01') }}"
}`; }
// Register built-ins
PT.register('openai', {
defaultConfig: () => ({
base_url: 'https://api.openai.com',
endpoint: '/v1/chat/completions',
headers: `{"Authorization":"Bearer [[VAR:incoming.headers.authorization]]"}`,
template: T_OPENAI()
})
});
PT.register('gemini', {
defaultConfig: () => ({
base_url: 'https://generativelanguage.googleapis.com',
endpoint: '/v1beta/models/{{ model }}:generateContent?key=[[VAR:incoming.api_keys.key]]',
headers: `{}`,
template: T_GEMINI()
})
});
PT.register('gemini_image', {
defaultConfig: () => ({
base_url: 'https://generativelanguage.googleapis.com',
endpoint: '/v1beta/models/{{ model }}:generateContent',
headers: `{"x-goog-api-key":"[[VAR:incoming.api_keys.key]]"}`,
template: T_GEMINI_IMAGE()
})
});
PT.register('claude', {
defaultConfig: () => ({
base_url: 'https://api.anthropic.com',
endpoint: '/v1/messages',
headers: `{"x-api-key":"[[VAR:incoming.headers.x-api-key]]","anthropic-version":"2023-06-01","anthropic-beta":"[[VAR:incoming.headers.anthropic-beta]]"}`,
template: T_CLAUDE()
})
});
try { console.debug('[ProviderTemplates] providers:', PT.providers()); } catch (_) {}
// Export globals and compatibility shims
try {
w.ProviderTemplates = PT;
// Back-compat shims so existing code can call global helpers
w.providerDefaults = PT.defaults;
w.ensureProviderConfigs = PT.ensureConfigs;
w.getActiveProv = PT.getActiveProv;
w.getActiveCfg = PT.getActiveCfg;
} catch (_) {}
})(window);

View File

@@ -10,6 +10,96 @@
if (!w.NODE_IO) throw new Error('AgentUISer: global NODE_IO is not available');
}
// Top-level pipeline meta kept in memory and included into JSON on save.
// Allows UI to edit loop parameters without manual JSON edits.
// DRY: единый источник дефолтов и нормализации meta
const MetaDefaults = Object.freeze({
id: 'pipeline_editor',
name: 'Edited Pipeline',
parallel_limit: 8,
loop_mode: 'dag',
loop_max_iters: 1000,
loop_time_budget_ms: 10000,
clear_var_store: true,
http_timeout_sec: 60,
text_extract_strategy: 'auto',
text_extract_json_path: '',
text_join_sep: '\n',
// v2: коллекция пресетов извлечения текста, управляется в "Запуск"
// [{ id, name, strategy, json_path, join_sep }]
text_extract_presets: [],
});
let _pipelineMeta = { ...MetaDefaults };
// Нормализатор meta: приводит типы, поддерживает синонимы ключей, заполняет дефолты
function ensureMeta(p) {
const src = (p && typeof p === 'object') ? p : {};
const out = { ...MetaDefaults };
// helpers
const toInt = (v, def) => {
try {
const n = parseInt(v, 10);
return Number.isFinite(n) && n > 0 ? n : def;
} catch { return def; }
};
const toNum = (v, def) => {
try {
const n = parseFloat(v);
return !Number.isNaN(n) && n > 0 ? n : def;
} catch { return def; }
};
// базовые поля
try { out.id = String((src.id ?? out.id) || out.id); } catch {}
try { out.name = String((src.name ?? out.name) || out.name); } catch {}
out.parallel_limit = toInt(src.parallel_limit, out.parallel_limit);
out.loop_mode = String((src.loop_mode ?? out.loop_mode) || out.loop_mode);
out.loop_max_iters = toInt(src.loop_max_iters, out.loop_max_iters);
out.loop_time_budget_ms = toInt(src.loop_time_budget_ms, out.loop_time_budget_ms);
out.clear_var_store = (typeof src.clear_var_store === 'boolean') ? !!src.clear_var_store : out.clear_var_store;
out.http_timeout_sec = toNum(src.http_timeout_sec, out.http_timeout_sec);
out.text_extract_strategy = String((src.text_extract_strategy ?? out.text_extract_strategy) || out.text_extract_strategy);
out.text_extract_json_path = String((src.text_extract_json_path ?? out.text_extract_json_path) || out.text_extract_json_path);
// поддержка синонимов text_join_sep (регистр и вариации)
let joinSep = out.text_join_sep;
try {
for (const k of Object.keys(src)) {
if (String(k).toLowerCase() === 'text_join_sep') { joinSep = src[k]; break; }
}
} catch {}
out.text_join_sep = String((joinSep ?? src.text_join_sep ?? out.text_join_sep) || out.text_join_sep);
// коллекция пресетов
try {
const arr = Array.isArray(src.text_extract_presets) ? src.text_extract_presets : [];
out.text_extract_presets = arr
.filter(it => it && typeof it === 'object')
.map((it, idx) => ({
id: String((it.id ?? '') || ('p' + Date.now().toString(36) + Math.random().toString(36).slice(2) + idx)),
name: String(it.name ?? (it.json_path || 'Preset')),
strategy: String(it.strategy ?? 'auto'),
json_path: String(it.json_path ?? ''),
join_sep: String(it.join_sep ?? '\n'),
}));
} catch { out.text_extract_presets = []; }
return out;
}
function getPipelineMeta() {
return { ..._pipelineMeta };
}
function updatePipelineMeta(p) {
if (!p || typeof p !== 'object') return;
// DRY: единая точка нормализации
_pipelineMeta = ensureMeta({ ..._pipelineMeta, ...p });
}
// Drawflow -> pipeline JSON
function toPipelineJSON() {
ensureDeps();
@@ -22,20 +112,77 @@
const dfNodes = (data && data.drawflow && data.drawflow.Home && data.drawflow.Home.data) ? data.drawflow.Home.data : {};
// 1) Собираем ноды
let idx = 1;
// 1) Собираем ноды с устойчивыми id на основе _origId (если валиден/уникален)
const usedIds = new Set();
const wantIds = {}; // drawflow id -> желаемый/финальный nX
const isValidNid = (s) => typeof s === 'string' && /^n\d+$/i.test(s.trim());
// Helper: вернуть исключительно «живые» данные ноды из редактора (если доступны).
// Это исключает расхождения между DOM.__data и editor.getNodeFromId(..).data.
function mergedNodeData(df, el, dfid) {
try {
const nid = parseInt(dfid, 10);
const n = (w.editor && typeof w.editor.getNodeFromId === 'function') ? w.editor.getNodeFromId(nid) : null;
if (n && n.data) return n.data;
} catch (_) {}
if (df && df.data) return df.data;
// как последний fallback — DOM.__data (почти не используется после этого изменения)
return (el && el.__data) ? el.__data : {};
}
// Первый проход: резервируем существующие валидные _origId
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 datacopySrc = mergedNodeData(df, el, id);
const tmp = typeof w.applyNodeDefaults === 'function'
? w.applyNodeDefaults(df.name, JSON.parse(JSON.stringify(datacopySrc)))
: (JSON.parse(JSON.stringify(datacopySrc)));
let desired = (tmp && typeof tmp._origId === 'string') ? String(tmp._origId).trim() : '';
if (isValidNid(desired) && !usedIds.has(desired)) {
wantIds[id] = desired;
usedIds.add(desired);
} else {
wantIds[id] = null; // назначим позже
}
}
// Поиск ближайшего свободного nX
function nextFreeId() {
let x = 1;
while (usedIds.has('n' + x)) x += 1;
return 'n' + x;
}
// Второй проход: назначаем конфликты/пустые
for (const id in dfNodes) {
if (!wantIds[id]) {
const nid = nextFreeId();
wantIds[id] = nid;
usedIds.add(nid);
}
idMap[id] = wantIds[id];
}
// Финальный проход: формируем массив нод, синхронизируя _origId
for (const id in dfNodes) {
const df = dfNodes[id];
const el = document.querySelector(`#node-${id}`);
const datacopySrc = mergedNodeData(df, el, id);
const datacopy = typeof w.applyNodeDefaults === 'function'
? w.applyNodeDefaults(df.name, JSON.parse(JSON.stringify(datacopySrc)))
: (JSON.parse(JSON.stringify(datacopySrc)));
try { datacopy._origId = idMap[id]; } catch (e) {}
// Спец-обработка SetVars: гарантированно берём свежие variables из живых данных редактора
try {
if (String(df.name) === 'SetVars') {
const nid = parseInt(id, 10);
const nLive = (w.editor && typeof w.editor.getNodeFromId === 'function') ? w.editor.getNodeFromId(nid) : null;
const v = nLive && nLive.data && Array.isArray(nLive.data.variables) ? nLive.data.variables : (Array.isArray(datacopy.variables) ? datacopy.variables : []);
datacopy.variables = v.map(x => ({ ...(x || {}) })); // глубокая копия
}
} catch (_) {}
nodes.push({
id: genId,
id: idMap[id],
type: df.name,
pos_x: df.pos_x,
pos_y: df.pos_y,
@@ -43,6 +190,7 @@
in: {}
});
}
try { console.debug('[AgentUISer.toPipelineJSON] idMap drawflowId->nX', idMap); } catch (e) {}
// 2) Восстанавливаем связи по входам (inputs)
// В Drawflow v0.0.55 inputs/outputs — это объекты вида input_1/output_1
@@ -56,15 +204,15 @@
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
// 1) Попробуем определить индекс выхода из conn.output
let sourceOutIdx = -1;
let m = outKey.match(/output_(\d+)/);
if (m) {
@@ -74,28 +222,64 @@
} 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}`;
// 2) Fallback: если индекс не распознан или вне диапазона — проверим dfNodes[source].outputs
if (!(sourceOutIdx >= 0) || !(Array.isArray(sourceIo.outputs) && sourceIo.outputs[sourceOutIdx] != null)) {
try {
const srcDf = dfNodes[sourceDfId];
const outsObj = (srcDf && srcDf.outputs) ? srcDf.outputs : {};
let found = -1;
// Текущая целевая drawflow-нода — это id (внешняя переменная цикла по dfNodes)
const tgtDfId = id;
for (const k of Object.keys(outsObj || {})) {
const conns = (outsObj[k] && Array.isArray(outsObj[k].connections)) ? outsObj[k].connections : [];
if (conns.some(c => String(c && c.node) === String(tgtDfId))) {
const m2 = String(k).match(/output_(\d+)/);
if (m2) { found = parseInt(m2[1], 10) - 1; break; }
}
}
if (found >= 0) sourceOutIdx = found;
} catch (e) {
// ignore
}
}
// 3) Ещё один safety: если до сих пор индекс невалидный — зажмём в границы
if (!(sourceOutIdx >= 0)) sourceOutIdx = 0;
if (Array.isArray(sourceIo.outputs) && sourceIo.outputs.length > 0) {
if (sourceOutIdx >= sourceIo.outputs.length) sourceOutIdx = sourceIo.outputs.length - 1;
}
// 4) Вычислим каноническое имя выхода по NODE_IO
let sourceOutName;
if (Array.isArray(sourceIo.outputs) && sourceIo.outputs[sourceOutIdx] != null) {
sourceOutName = sourceIo.outputs[sourceOutIdx];
} else {
// Fallback на технические имена (совместимость со старыми out0/out1)
sourceOutName = `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 };
// 3) Собираем итоговый pipeline JSON с метаданными (нормализованными)
const meta = ensureMeta(getPipelineMeta());
try { console.debug('[AgentUISer.toPipelineJSON] meta_keys', Object.keys(meta || {})); } catch (e) {}
return { ...meta, nodes };
}
// pipeline JSON -> Drawflow
@@ -103,6 +287,25 @@
ensureDeps();
const editor = w.editor;
const NODE_IO = w.NODE_IO;
// Сохраняем метаданные пайплайна для UI (сквозная нормализация)
try {
updatePipelineMeta(p || {});
// Диагностический лог состава meta для подтверждения DRY-рефакторинга
try {
const metaKeys = ["id","name","parallel_limit","loop_mode","loop_max_iters","loop_time_budget_ms","clear_var_store","http_timeout_sec","text_extract_strategy","text_extract_json_path","text_join_sep","text_extract_presets"];
const incomingKeys = metaKeys.filter(k => (p && Object.prototype.hasOwnProperty.call(p, k)));
const currentMeta = (typeof getPipelineMeta === 'function') ? getPipelineMeta() : {};
console.debug('[AgentUISer.fromPipelineJSON] meta_keys', {
incomingKeys,
resultKeys: Object.keys(currentMeta || {}),
metaPreview: {
id: currentMeta && currentMeta.id,
loop_mode: currentMeta && currentMeta.loop_mode,
http_timeout_sec: currentMeta && currentMeta.http_timeout_sec
}
});
} catch (_) {}
} catch (e) {}
editor.clear();
let x = 100; let y = 120; // Fallback
@@ -243,5 +446,7 @@
w.AgentUISer = {
toPipelineJSON,
fromPipelineJSON,
getPipelineMeta,
updatePipelineMeta,
};
})(window);

213
static/js/utils.js Normal file
View File

@@ -0,0 +1,213 @@
/* global window */
// AgentUI common UI utilities (DRY helpers shared by editor.html and pm-ui.js)
(function (w) {
'use strict';
const AU = {};
// HTML escaping for safe text/attribute insertion
AU.escapeHtml = function escapeHtml(s) {
const str = String(s ?? '');
return str
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'");
};
// Attribute-safe escape (keeps quotes escaped; conservative)
AU.escAttr = function escAttr(v) {
const s = String(v ?? '');
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'");
};
// Text-node escape (keeps quotes as-is for readability)
AU.escText = function escText(v) {
const s = String(v ?? '');
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
};
// DRY helper: sync Drawflow node data + mirror into DOM.__data with deep copy
AU.updateNodeDataAndDom = function updateNodeDataAndDom(editor, id, data) {
try { editor && typeof editor.updateNodeDataFromId === 'function' && editor.updateNodeDataFromId(id, data); } catch (_) {}
try {
const el = document.querySelector('#node-' + id);
if (el) el.__data = JSON.parse(JSON.stringify(data));
} catch (_) {}
};
// Double rAF helper: waits for two animation frames; returns Promise or accepts callback
AU.nextRaf2 = function nextRaf2(cb) {
try {
if (typeof requestAnimationFrame === 'function') {
if (typeof cb === 'function') {
requestAnimationFrame(() => { requestAnimationFrame(() => { try { cb(); } catch (_) {} }); });
return;
}
return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
} else {
if (typeof cb === 'function') { setTimeout(() => { try { cb(); } catch (_) {} }, 32); return; }
return new Promise((resolve) => setTimeout(resolve, 32));
}
} catch (_) {
if (typeof cb === 'function') { try { cb(); } catch (__ ) {} }
return Promise.resolve();
}
};
// Heuristic: looks like long base64 payload
AU.isProbablyBase64 = function isProbablyBase64(s) {
try {
if (typeof s !== 'string') return false;
if (s.length < 64) return false;
return /^[A-Za-z0-9+/=\r\n]+$/.test(s);
} catch { return false; }
};
AU.trimBase64 = function trimBase64(s, maxLen = 180) {
try {
const str = String(s ?? '');
if (str.length > maxLen) {
return str.slice(0, maxLen) + `... (trimmed ${str.length - maxLen})`;
}
return str;
} catch { return String(s ?? ''); }
};
// Flatten JSON-like object into [path, stringValue] pairs
// Includes special handling for backend preview objects: { "__truncated__": true, "preview": "..." }
AU.flattenObject = function flattenObject(obj, prefix = '') {
const out = [];
if (obj == null) return out;
if (typeof obj !== 'object') {
out.push([prefix, String(obj)]);
return out;
}
try {
const entries = Object.entries(obj);
for (const [k, v] of entries) {
const p = prefix ? `${prefix}.${k}` : k;
if (v && typeof v === 'object' && !Array.isArray(v)) {
// Special preview shape from backend
if (Object.prototype.hasOwnProperty.call(v, '__truncated__') && Object.prototype.hasOwnProperty.call(v, 'preview')) {
out.push([p, String(v.preview ?? '')]);
continue;
}
out.push(...AU.flattenObject(v, p));
} else {
try {
const s = (typeof v === 'string') ? v : JSON.stringify(v, null, 0);
out.push([p, s]);
} catch {
out.push([p, String(v)]);
}
}
}
} catch {
// Fallback best-effort
try { out.push([prefix, JSON.stringify(obj)]); } catch { out.push([prefix, String(obj)]); }
}
return out;
};
// Format headers dictionary into text lines "Key: Value"
AU.fmtHeaders = function fmtHeaders(h) {
try {
const keys = Object.keys(h || {});
return keys.map(k => `${k}: ${String(h[k])}`).join('\n');
} catch { return ''; }
};
// Build HTTP request preview text
AU.buildReqText = function buildReqText(x) {
if (!x) return '';
const head = `${x.method || 'POST'} ${x.url || '/'} HTTP/1.1`;
const host = (() => {
try { const u = new URL(x.url); return `Host: ${u.host}`; } catch { return ''; }
})();
const hs = AU.fmtHeaders(x.headers || {});
const body = String(x.body_text || '').trim();
return [head, host, hs, '', body].filter(Boolean).join('\n');
};
// Build HTTP response preview text
AU.buildRespText = function buildRespText(x) {
if (!x) return '';
const head = `HTTP/1.1 ${x.status || 0}`;
const hs = AU.fmtHeaders(x.headers || {});
const body = String(x.body_text || '').trim();
return [head, hs, '', body].filter(Boolean).join('\n');
};
// Unified fetch helper with timeout and JSON handling
AU.apiFetch = async function apiFetch(url, opts) {
const t0 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
const o = opts || {};
const method = String(o.method || 'GET').toUpperCase();
const expectJson = (o.expectJson !== false); // default true
const headers = Object.assign({}, o.headers || {});
let body = o.body;
const timeoutMs = Number.isFinite(o.timeoutMs) ? o.timeoutMs : 15000;
const hasAbort = (typeof AbortController !== 'undefined');
const ctrl = hasAbort ? new AbortController() : null;
let to = null;
if (ctrl) {
try { to = setTimeout(() => { try { ctrl.abort(); } catch(_){} }, timeoutMs); } catch(_) {}
}
try {
if (expectJson) {
if (!headers['Accept'] && !headers['accept']) headers['Accept'] = 'application/json';
}
if (body != null) {
const isForm = (typeof FormData !== 'undefined' && body instanceof FormData);
const isBlob = (typeof Blob !== 'undefined' && body instanceof Blob);
if (typeof body === 'object' && !isForm && !isBlob) {
body = JSON.stringify(body);
if (!headers['Content-Type'] && !headers['content-type']) headers['Content-Type'] = 'application/json';
}
}
const res = await fetch(url, { method, headers, body, signal: ctrl ? ctrl.signal : undefined });
const ct = String(res.headers && res.headers.get ? (res.headers.get('Content-Type') || '') : '');
const isJsonCt = /application\/json/i.test(ct);
let data = null;
if (expectJson || isJsonCt) {
try { data = await res.json(); } catch (_) { data = null; }
} else {
try { data = await res.text(); } catch (_) { data = null; }
}
const t1 = (typeof performance !== 'undefined' && performance.now) ? performance.now() : Date.now();
try { console.debug('[AU.apiFetch]', { method, url, status: res.status, ms: Math.round(t1 - t0) }); } catch(_) {}
if (!res.ok) {
const msg = (data && typeof data === 'object' && data.error) ? String(data.error) : `HTTP ${res.status}`;
const err = new Error(`apiFetch: ${msg}`);
err.status = res.status;
err.data = data;
err.url = url;
throw err;
}
return data;
} finally {
if (to) { try { clearTimeout(to); } catch(_) {} }
}
};
// Expose
try { w.AU = AU; } catch (_) {}
try { w.nextRaf2 = AU.nextRaf2; } catch (_) {}
})(window);

View File

@@ -4,6 +4,12 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>НадTavern — Pipeline Editor (JSON)</title>
<link rel="icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#ffffff" />
<style>
body { font-family: Arial, sans-serif; margin: 24px; }
textarea { width: 100%; height: 70vh; }

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Make tests a package so imports like "from tests.utils import ..." work.

199
tests/test_cancel_modes.py Normal file
View File

@@ -0,0 +1,199 @@
import asyncio
import json
from typing import Any, Dict
from agentui.pipeline.executor import PipelineExecutor, ExecutionError
from agentui.common.cancel import request_cancel, clear_cancel
import agentui.providers.http_client as hc
import agentui.pipeline.executor as ex
from tests.utils import ctx as _ctx
class DummyResponse:
def __init__(self, status: int, json_obj: Dict[str, Any]) -> None:
self.status_code = status
self._json = json_obj
self.headers = {}
try:
self.content = json.dumps(json_obj, ensure_ascii=False).encode("utf-8")
except Exception:
self.content = b"{}"
try:
self.text = json.dumps(json_obj, ensure_ascii=False)
except Exception:
self.text = "{}"
def json(self) -> Dict[str, Any]:
return self._json
class DummyClient:
"""
Async client with artificial delay to simulate in-flight HTTP that can be cancelled.
Provides .post() and .request() compatible with executor usage.
"""
def __init__(self, delay: float = 0.3, status_code: int = 200) -> None:
self._delay = delay
self._status = status_code
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def post(self, url: str, content: bytes, headers: Dict[str, str]):
# Artificial delay to allow cancel/abort to happen while awaiting
await asyncio.sleep(self._delay)
try:
payload = json.loads(content.decode("utf-8"))
except Exception:
payload = {"_raw": content.decode("utf-8", errors="ignore")}
return DummyResponse(self._status, {"echo": payload})
async def request(self, method: str, url: str, headers: Dict[str, str], content: bytes | None):
return await self.post(url, content or b"{}", headers)
def _patch_http_client(delay: float = 0.3):
"""
Patch both providers.http_client.build_client and executor.build_client
to return our DummyClient with a given delay.
"""
orig_hc = hc.build_client
orig_ex = ex.build_client
hc.build_client = lambda timeout=60.0: DummyClient(delay=delay) # type: ignore[assignment]
ex.build_client = lambda timeout=60.0: DummyClient(delay=delay) # type: ignore[assignment]
return orig_hc, orig_ex
def _restore_http_client(orig_hc, orig_ex) -> None:
hc.build_client = orig_hc
ex.build_client = orig_ex
def test_graceful_cancel_while_providercall():
"""
Expectation:
- Cancel(mode=graceful) during in-flight HTTP should NOT interrupt the current request.
- While-wrapper should stop before starting next iteration.
- Final CYCLEINDEX__n2 == 0 (only first iteration finished), WAS_ERROR__n2 is False/absent.
"""
async def main():
p = {
"id": "p_cancel_soft",
"name": "ProviderCall graceful cancel",
"loop_mode": "dag",
"nodes": [
{
"id": "n2",
"type": "ProviderCall",
"config": {
"provider": "openai",
"while_expr": "cycleindex < 5",
"while_max_iters": 10,
# ignore_errors not needed for graceful (no interruption of in-flight)
"provider_configs": {
"openai": {
"base_url": "http://dummy.local",
"headers": "{}",
"template": "{}"
}
}
},
"in": {}
}
]
}
pid = p["id"]
orig_hc, orig_ex = _patch_http_client(delay=0.3)
try:
ctx = _ctx()
exr = PipelineExecutor(p)
task = asyncio.create_task(exr.run(ctx))
# Give the node time to start HTTP, then request graceful cancel
await asyncio.sleep(0.05)
request_cancel(pid, mode="graceful")
out = await task
finally:
_restore_http_client(orig_hc, orig_ex)
try:
clear_cancel(pid)
except Exception:
pass
assert isinstance(out, dict)
vars_map = out.get("vars") or {}
assert isinstance(vars_map, dict)
# Only first iteration should have finished; last index = 0
assert vars_map.get("CYCLEINDEX__n2") == 0
# No error expected on graceful (we didn't interrupt the in-flight HTTP)
assert vars_map.get("WAS_ERROR__n2") in (False, None)
asyncio.run(main())
def test_abort_cancel_inflight_providercall():
"""
Expectation:
- Cancel(mode=abort) during in-flight HTTP cancels the await with ExecutionError.
- While-wrapper with ignore_errors=True converts it into {"result":{"error":...}}.
- Final CYCLEINDEX__n2 == 0 and WAS_ERROR__n2 == True; error mentions 'Cancelled by user (abort)'.
"""
async def main():
p = {
"id": "p_cancel_abort",
"name": "ProviderCall abort cancel",
"loop_mode": "dag",
"nodes": [
{
"id": "n2",
"type": "ProviderCall",
"config": {
"provider": "openai",
"while_expr": "cycleindex < 5",
"while_max_iters": 10,
"ignore_errors": True, # convert cancellation exception into error payload
"provider_configs": {
"openai": {
"base_url": "http://dummy.local",
"headers": "{}",
"template": "{}"
}
}
},
"in": {}
}
]
}
pid = p["id"]
orig_hc, orig_ex = _patch_http_client(delay=0.3)
try:
ctx = _ctx()
exr = PipelineExecutor(p)
task = asyncio.create_task(exr.run(ctx))
# Let HTTP start, then trigger hard abort
await asyncio.sleep(0.05)
request_cancel(pid, mode="abort")
out = await task
finally:
_restore_http_client(orig_hc, orig_ex)
try:
clear_cancel(pid)
except Exception:
pass
assert isinstance(out, dict)
vars_map = out.get("vars") or {}
assert isinstance(vars_map, dict)
# First iteration was started; after abort it is considered errored and loop stops
assert vars_map.get("CYCLEINDEX__n2") == 0
assert vars_map.get("WAS_ERROR__n2") is True
# Error propagated into node's result (ignore_errors=True path)
res = out.get("result") or {}
assert isinstance(res, dict)
err = res.get("error")
assert isinstance(err, str) and "Cancelled by user (abort)" in err
asyncio.run(main())

297
tests/test_edge_cases.py Normal file
View File

@@ -0,0 +1,297 @@
import asyncio
import json
from agentui.pipeline.executor import PipelineExecutor, ExecutionError, Node, NODE_REGISTRY
from tests.utils import pp as _pp, base_ctx as _base_ctx
async def scenario_if_single_quotes_ok():
print("\n=== SCENARIO 1: If with single quotes ===")
p = {
"id": "p_if_single_quotes",
"name": "If Single Quotes",
"loop_mode": "iterative",
"nodes": [
{
"id": "n1",
"type": "SetVars",
"config": {
"variables": [
{"id": "v1", "name": "MSG", "mode": "string", "value": "Hello"}
]
},
"in": {}
},
{
"id": "nIf",
"type": "If",
"config": {
"expr": "[[MSG]] contains 'Hello'"
},
"in": {
"depends": "n1.done"
}
},
{
"id": "nRet",
"type": "Return",
"config": {
"target_format": "openai",
"text_template": "ok"
},
"in": {
"depends": "nIf.true"
}
}
]
}
exe = PipelineExecutor(p)
try:
out = await exe.run(_base_ctx())
print("OK:", _pp(out))
except Exception as e:
print("ERR:", type(e).__name__, str(e))
async def scenario_if_error_logging():
print("\n=== SCENARIO 2: If with unterminated string (expect error log) ===")
p = {
"id": "p_if_bad_string",
"name": "If Bad String",
"loop_mode": "iterative",
"nodes": [
{
"id": "n1",
"type": "SetVars",
"config": {
"variables": [
{"id": "v1", "name": "MSG", "mode": "string", "value": "Hello"}
]
},
"in": {}
},
{
"id": "nIf",
"type": "If",
"config": {
# Missing closing quote to force tokenizer error
"expr": "[[MSG]] contains 'Hello"
},
"in": {
"depends": "n1.done"
}
},
{
"id": "nRet",
"type": "Return",
"config": {
"target_format": "openai",
"text_template": "should_not_run"
},
"in": {
"depends": "nIf.true"
}
}
]
}
exe = PipelineExecutor(p)
try:
out = await exe.run(_base_ctx())
print("UNEXPECTED_OK:", _pp(out))
except Exception as e:
print("EXPECTED_ERROR:", type(e).__name__, str(e))
async def scenario_multi_depends_array():
print("\n=== SCENARIO 3: multi-depends array ===")
# n2 and n3 both depend on n1; n4 depends on [n2.done, n3.done]
p = {
"id": "p_multi_depends",
"name": "Multi Depends",
"loop_mode": "iterative",
"nodes": [
{
"id": "n1",
"type": "SetVars",
"config": {
"variables": [
{"id": "v1", "name": "A", "mode": "string", "value": "foo"},
{"id": "v2", "name": "B", "mode": "string", "value": "bar"}
]
},
"in": {}
},
{
"id": "n2",
"type": "Return",
"config": {
"target_format": "openai",
"text_template": "[[A]]"
},
"in": {
"depends": "n1.done"
}
},
{
"id": "n3",
"type": "Return",
"config": {
"target_format": "openai",
"text_template": "[[B]]"
},
"in": {
"depends": "n1.done"
}
},
{
"id": "n4",
"type": "Return",
"config": {
"target_format": "openai",
"text_template": "[[OUT2]] + [[OUT3]]"
},
"in": {
"depends": ["n2.done", "n3.done"]
}
}
]
}
exe = PipelineExecutor(p)
try:
out = await exe.run(_base_ctx())
print("OK:", _pp(out))
except Exception as e:
print("ERR:", type(e).__name__, str(e))
async def scenario_gate_only_dependency():
print("\n=== SCENARIO 4: gate-only dependency (no real parents) ===")
# nThen depends only on nIf.true (should run only when gate becomes true)
p = {
"id": "p_gate_only",
"name": "Gate Only",
"loop_mode": "iterative",
"nodes": [
{
"id": "n1",
"type": "SetVars",
"config": {
"variables": [
{"id": "v1", "name": "FLAG", "mode": "string", "value": "yes"}
]
},
"in": {}
},
{
"id": "nIf",
"type": "If",
"config": {
"expr": "[[FLAG]] == 'yes'"
},
"in": {
"depends": "n1.done"
}
},
{
"id": "nThen",
"type": "Return",
"config": {
"target_format": "openai",
"text_template": "then-branch"
},
"in": {
# gate-only
"depends": "nIf.true"
}
}
]
}
exe = PipelineExecutor(p)
try:
out = await exe.run(_base_ctx())
print("OK:", _pp(out))
except Exception as e:
print("ERR:", type(e).__name__, str(e))
async def scenario_provider_prompt_empty_json_error():
print("\n=== SCENARIO 5: ProviderCall with empty PROMPT causing JSON error (collect logs) ===")
# Template has [[PROMPT]] surrounded by commas; blocks are empty => PROMPT = ""
# Resulting JSON likely invalid -> ExecutionError expected before any network call.
p = {
"id": "p_prompt_empty",
"name": "Prompt Empty JSON Error",
"loop_mode": "dag",
"nodes": [
{
"id": "n1",
"type": "ProviderCall",
"config": {
"provider": "openai",
"provider_configs": {
"openai": {
"base_url": "https://api.openai.com",
"endpoint": "/v1/chat/completions",
"headers": "{\"Authorization\":\"Bearer TEST\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"temperature\": 0.1\n}"
}
},
"blocks": [] # empty -> PROMPT empty
},
"in": {}
}
]
}
exe = PipelineExecutor(p)
try:
out = await exe.run(_base_ctx())
print("UNEXPECTED_OK:", _pp(out))
except Exception as e:
print("EXPECTED_ERROR:", type(e).__name__, str(e))
async def scenario_rawforward_vendor_unknown():
print("\n=== SCENARIO 6: RawForward vendor unknown (non-JSON body simulated) ===")
# We simulate incoming.json as a plain string that doesn't look like any known vendor payload.
# RawForward will try vendor detect, fail and raise ExecutionError (collect logs, do not fix).
p = {
"id": "p_rawforward_unknown",
"name": "RawForward Unknown",
"loop_mode": "dag",
"nodes": [
{
"id": "n1",
"type": "RawForward",
"config": {
# No base_url -> autodetect vendor from incoming.json (will fail)
"passthrough_headers": True,
"extra_headers": "{}"
},
"in": {}
}
]
}
exe = PipelineExecutor(p)
ctx = _base_ctx()
ctx["incoming"] = {
"method": "POST",
"url": "http://example.test/whatever",
"path": "/whatever",
"query": "",
"headers": {"Content-Type": "text/plain"},
"json": "raw-plain-body-simulated" # NOT JSON object -> detect_vendor -> unknown
}
try:
out = await exe.run(ctx)
print("UNEXPECTED_OK:", _pp(out))
except Exception as e:
print("EXPECTED_ERROR:", type(e).__name__, str(e))
def run_all():
async def main():
await scenario_if_single_quotes_ok()
await scenario_if_error_logging()
await scenario_multi_depends_array()
await scenario_gate_only_dependency()
await scenario_provider_prompt_empty_json_error()
await scenario_rawforward_vendor_unknown()
print("\n=== EDGE CASES: DONE ===")
asyncio.run(main())
if __name__ == "__main__":
run_all()

View File

@@ -0,0 +1,167 @@
import asyncio
from agentui.pipeline.executor import PipelineExecutor, ExecutionError, Node, NODE_REGISTRY
def run_checks():
async def scenario():
# Test 1: linear pipeline in iterative mode (SetVars -> Return)
p1 = {
"id": "pipeline_test_iter_1",
"name": "Iterative Linear",
"loop_mode": "iterative",
"loop_max_iters": 100,
"loop_time_budget_ms": 5000,
"nodes": [
{
"id": "n1",
"type": "SetVars",
"config": {
"variables": [
{"id": "v1", "name": "MSG", "mode": "string", "value": "Hello"}
]
},
"in": {}
},
{
"id": "n2",
"type": "Return",
"config": {
"target_format": "openai",
"text_template": "[[MSG]]"
},
"in": {
"depends": "n1.done"
}
}
]
}
ctx = {
"model": "gpt-x",
"vendor_format": "openai",
"params": {},
"chat": {"last_user": "hi"},
"OUT": {}
}
ex1 = PipelineExecutor(p1)
out1 = await ex1.run(ctx)
assert isinstance(out1, dict) and "result" in out1
res1 = out1["result"]
# OpenAI-like object from Return formatter
assert res1.get("object") == "chat.completion"
msg1 = res1.get("choices", [{}])[0].get("message", {}).get("content")
assert msg1 == "Hello"
# Test 2: If gating in iterative mode (SetVars -> If -> Return(true))
p2 = {
"id": "pipeline_test_iter_2",
"name": "Iterative If Gate",
"loop_mode": "iterative",
"loop_max_iters": 100,
"loop_time_budget_ms": 5000,
"nodes": [
{
"id": "n1",
"type": "SetVars",
"config": {
"variables": [
{"id": "v1", "name": "MSG", "mode": "string", "value": "Hello world"}
]
},
"in": {}
},
{
"id": "nIf",
"type": "If",
"config": {
"expr": '[[MSG]] contains "Hello"'
},
"in": {
"depends": "n1.done"
}
},
{
"id": "nRet",
"type": "Return",
"config": {
"target_format": "openai",
"text_template": "[[MSG]] ok"
},
"in": {
"depends": "nIf.true"
}
}
]
}
ex2 = PipelineExecutor(p2)
out2 = await ex2.run(ctx)
assert "result" in out2
res2 = out2["result"]
assert res2.get("object") == "chat.completion"
msg2 = res2.get("choices", [{}])[0].get("message", {}).get("content")
assert msg2 == "Hello world ok"
# Test 3: [[OUT:...]] is treated as a real dependency in iterative mode
class ProbeNode(Node):
type_name = "Probe"
async def run(self, inputs, context):
x = inputs.get("x")
assert isinstance(x, dict) and isinstance(x.get("vars"), dict)
v = x["vars"].get("MSG")
assert v == "Hello OUT"
return {"vars": {"X_MSG": v}}
# Register probe node
NODE_REGISTRY[ProbeNode.type_name] = ProbeNode
p3 = {
"id": "pipeline_test_iter_3",
"name": "Iterative OUT dependency",
"loop_mode": "iterative",
"loop_max_iters": 100,
"loop_time_budget_ms": 5000,
"nodes": [
{
"id": "n1",
"type": "SetVars",
"config": {
"variables": [
{"id": "v1", "name": "MSG", "mode": "string", "value": "Hello OUT"}
]
},
"in": {}
},
{
"id": "n2",
"type": "Probe",
"config": {},
"in": {
"x": "[[OUT:n1]]"
}
},
{
"id": "n3",
"type": "Return",
"config": {
"target_format": "openai",
"text_template": "[[VAR:vars.X_MSG]]"
},
"in": {
"depends": "n2.done"
}
}
]
}
ex3 = PipelineExecutor(p3)
out3 = await ex3.run(ctx)
assert "result" in out3
res3 = out3["result"]
assert res3.get("object") == "chat.completion"
msg3 = res3.get("choices", [{}])[0].get("message", {}).get("content")
assert msg3 == "Hello OUT"
asyncio.run(scenario())
print("Iterative executor tests: OK")
if __name__ == "__main__":
run_checks()

227
tests/test_macros_vars.py Normal file
View File

@@ -0,0 +1,227 @@
import asyncio
from agentui.pipeline.executor import PipelineExecutor
from agentui.pipeline.storage import clear_var_store
from tests.utils import pp as _pp, ctx as _ctx
async def scenario_bare_vars_and_braces():
print("\n=== MACROS 1: Bare [[NAME]] и {{ NAME }} + числа/объекты без кавычек ===")
p = {
"id": "p_macros_1",
"name": "Bare and Braces",
"loop_mode": "iterative",
"nodes": [
{
"id": "n1",
"type": "SetVars",
"config": {
"variables": [
{"id": "v1", "name": "STR", "mode": "string", "value": "строка"},
{"id": "v2", "name": "NUM", "mode": "expr", "value": "42"},
{"id": "v3", "name": "OBJ", "mode": "expr", "value": '{"x": 1, "y": [2,3]}'},
]
},
"in": {}
},
{
"id": "n2",
"type": "Return",
"config": {
"target_format": "openai",
# Вставляем: строка из [[STR]], число через {{ NUM }}, и словарь через {{ OBJ }}
"text_template": "[[STR]] | {{ NUM }} | {{ OBJ }}"
},
"in": { "depends": "n1.done" }
}
]
}
out = await PipelineExecutor(p).run(_ctx())
print("OUT:", _pp(out))
async def scenario_var_path_and_defaults():
print("\n=== MACROS 2: [[VAR:path]] и {{ ...|default(...) }} (вложенные и JSON-литералы) ===")
incoming = {
"method": "POST",
"url": "http://localhost/test?foo=bar",
"path": "/test",
"query": "foo=bar",
"headers": {"authorization": "Bearer X", "x-api-key": "Y"},
"json": {"a": None}
}
p = {
"id": "p_macros_2",
"name": "VAR and defaults",
"loop_mode": "dag",
"nodes": [
{
"id": "n1",
"type": "Return",
"config": {
"target_format": "openai",
"text_template": (
"auth=[[VAR:incoming.headers.authorization]] | "
"xkey=[[VAR:incoming.headers.x-api-key]] | "
# nested default: params.a|default(123) -> если param не задан, 123
"num={{ params.a|default(123) }} | "
# deeper default chain: incoming.json.a|default(params.a|default(456))
"num2={{ incoming.json.a|default(params.a|default(456)) }} | "
# JSON literal default list/object
"lit_list={{ missing|default([1,2,3]) }} | lit_obj={{ missing2|default({\"k\":10}) }}"
)
},
"in": {}
}
]
}
out = await PipelineExecutor(p).run(_ctx(incoming=incoming, params={"temperature": 0.2}))
print("OUT:", _pp(out))
async def scenario_out_macros_full_and_short():
print("\n=== MACROS 3: [[OUT:nX...]] и короткая форма [[OUTx]] ===")
p = {
"id": "p_macros_3",
"name": "OUT full and short",
"loop_mode": "iterative",
"nodes": [
{
"id": "n1",
"type": "SetVars",
"config": {
"variables": [
{"id": "v1", "name": "MSG", "mode": "string", "value": "hello"}
]
},
"in": {}
},
{
"id": "n2",
"type": "Return",
"config": {
"target_format": "openai",
"text_template": "[[MSG]]"
},
"in": { "depends": "n1.done" }
},
{
"id": "n3",
"type": "Return",
"config": {
"target_format": "openai",
# Две формы: полная от n1.vars.MSG и короткая от n2 => [[OUT2]]
"text_template": "[[OUT:n1.vars.MSG]] + [[OUT2]]"
},
"in": { "depends": "n2.done" }
}
]
}
out = await PipelineExecutor(p).run(_ctx())
print("OUT:", _pp(out))
async def scenario_store_macros_two_runs():
print("\n=== MACROS 4: [[STORE:key]] и {{ STORE.key }} между запусками (clear_var_store=False) ===")
pid = "p_macros_4_store"
# начинаем с чистого стора
clear_var_store(pid)
p = {
"id": pid,
"name": "STORE across runs",
"loop_mode": "iterative",
"clear_var_store": False, # критично: не очищать между запусками
"nodes": [
{
"id": "n1",
"type": "SetVars",
"config": {
"variables": [
{"id": "v1", "name": "KEEP", "mode": "string", "value": "persist-me"}
]
},
"in": {}
},
{
"id": "n2",
"type": "Return",
"config": {
"target_format": "openai",
"text_template": "first-run"
},
"in": { "depends": "n1.done" }
}
]
}
# Первый запуск — кладём KEEP в STORE
out1 = await PipelineExecutor(p).run(_ctx())
print("RUN1:", _pp(out1))
# Второй запуск — читаем из STORE через макросы
p2 = {
"id": pid,
"name": "STORE read",
"loop_mode": "dag",
"clear_var_store": False,
"nodes": [
{
"id": "n1",
"type": "Return",
"config": {
"target_format": "openai",
"text_template": "[[STORE:KEEP]] | {{ STORE.KEEP }}"
},
"in": {}
}
]
}
out2 = await PipelineExecutor(p2).run(_ctx())
print("RUN2:", _pp(out2))
async def scenario_pm_prompt_blocks_to_provider_structs():
print("\n=== MACROS 5: Prompt Blocks ([[PROMPT]]) → provider-structures (OpenAI) ===")
# Проверяем, что [[PROMPT]] со списком блоков превращается в "messages":[...]
p = {
"id": "p_macros_5",
"name": "PROMPT OpenAI",
"loop_mode": "dag",
"nodes": [
{
"id": "n1",
"type": "ProviderCall",
"config": {
"provider": "openai",
"provider_configs": {
"openai": {
"base_url": "https://api.openai.com",
"endpoint": "/v1/chat/completions",
"headers": "{\"Authorization\":\"Bearer TEST\"}",
"template": "{\n \"model\": \"{{ model }}\",\n [[PROMPT]],\n \"temperature\": {{ params.temperature|default(0.7) }}\n}"
}
},
"blocks": [
{"id": "b1", "name": "sys", "role": "system", "prompt": "You are test", "enabled": True, "order": 0},
{"id": "b2", "name": "user", "role": "user", "prompt": "Say [[VAR:chat.last_user]]", "enabled": True, "order": 1}
]
},
"in": {}
}
]
}
# Сборка и запрос (ожидаемый 401), главное — валидное тело с messages:[...]
out = await PipelineExecutor(p).run(_ctx())
print("OUT:", _pp(out))
def run_all():
async def main():
await scenario_bare_vars_and_braces()
await scenario_var_path_and_defaults()
await scenario_out_macros_full_and_short()
await scenario_store_macros_two_runs()
await scenario_pm_prompt_blocks_to_provider_structs()
print("\n=== MACROS VARS SUITE: DONE ===")
asyncio.run(main())
if __name__ == "__main__":
run_all()

View File

@@ -0,0 +1,249 @@
import asyncio
import json
from typing import Any, Dict, List
from agentui.pipeline.executor import PipelineExecutor
import agentui.providers.http_client as hc
from tests.utils import ctx as _ctx, pp as _pp
# Capture of all outbound ProviderCall HTTP requests (one per run)
CAPTURED: List[Dict[str, Any]] = []
class DummyResponse:
def __init__(self, status_code: int = 200, body: Dict[str, Any] | None = None):
self.status_code = status_code
self._json = body if body is not None else {"ok": True}
self.headers = {}
try:
self.content = json.dumps(self._json, ensure_ascii=False).encode("utf-8")
except Exception:
self.content = b"{}"
try:
self.text = json.dumps(self._json, ensure_ascii=False)
except Exception:
self.text = "{}"
def json(self) -> Any:
return self._json
class DummyClient:
def __init__(self, capture: List[Dict[str, Any]], status_code: int = 200):
self._capture = capture
self._status = status_code
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def post(self, url: str, content: bytes, headers: Dict[str, str]):
try:
payload = json.loads(content.decode("utf-8"))
except Exception:
payload = {"_raw": content.decode("utf-8", errors="ignore")}
rec = {"url": url, "headers": headers, "payload": payload}
self._capture.append(rec)
# Echo payload back to keep extractor happy but not tied to vendor formats
return DummyResponse(self._status, {"echo": rec})
# RawForward may use .request, but we don't need it here
async def request(self, method: str, url: str, headers: Dict[str, str], content: bytes | None):
return await self.post(url, content or b"{}", headers)
def _patch_http_client():
"""Monkeypatch build_client used by ProviderCall to our dummy."""
hc.build_client = lambda timeout=60.0: DummyClient(CAPTURED, 200) # type: ignore[assignment]
# Также патчим символ, импортированный внутрь executor, чтобы ProviderCall использовал DummyClient
import agentui.pipeline.executor as ex # type: ignore
ex.build_client = lambda timeout=60.0: DummyClient(CAPTURED, 200) # type: ignore
def _mk_pipeline(provider: str, prompt_combine: str) -> Dict[str, Any]:
"""Build a minimal ProviderCall-only pipeline for a given provider and combine spec."""
provider = provider.lower().strip()
if provider not in {"openai", "gemini", "claude"}:
raise AssertionError(f"Unsupported provider in test: {provider}")
base_url = "http://mock.local"
if provider == "openai":
endpoint = "/v1/chat/completions"
template = '{ "model": "{{ model }}", [[PROMPT]] }'
elif provider == "gemini":
endpoint = "/v1beta/models/{{ model }}:generateContent"
template = '{ "model": "{{ model }}", [[PROMPT]] }'
else: # claude
endpoint = "/v1/messages"
template = '{ "model": "{{ model }}", [[PROMPT]] }'
p = {
"id": f"p_prompt_combine_{provider}",
"name": f"prompt_combine to {provider}",
"loop_mode": "dag",
"nodes": [
{
"id": "n1",
"type": "ProviderCall",
"config": {
"provider": provider,
"provider_configs": {
provider: {
"base_url": base_url,
"endpoint": endpoint,
"headers": "{}",
"template": template,
}
},
# Key under test:
"prompt_combine": prompt_combine,
# Prompt Blocks (PROMPT)
"blocks": [
{"id": "b1", "name": "sys", "role": "system", "prompt": "Ты — Narrator-chan.", "enabled": True, "order": 0},
{"id": "b2", "name": "user", "role": "user", "prompt": "как лела", "enabled": True, "order": 1},
],
},
"in": {},
}
],
}
return p
def _ctx_with_incoming(incoming_json: Dict[str, Any], vendor: str = "openai") -> Dict[str, Any]:
base = _ctx(vendor=vendor)
inc = dict(base["incoming"])
inc["json"] = incoming_json
base["incoming"] = inc
return base
async def scenario_openai_target_from_gemini_contents():
print("\n=== PROMPT_COMBINE 1: target=openai, incoming=gemini.contents & PROMPT ===")
_patch_http_client()
CAPTURED.clear()
# Incoming JSON in Gemini shape
incoming_json = {
"contents": [
{"role": "user", "parts": [{"text": "Прив"}]},
{"role": "model", "parts": [{"text": "И тебе привет!"}]},
]
}
p = _mk_pipeline("openai", "[[VAR:incoming.json.contents]] & [[PROMPT]]")
out = await PipelineExecutor(p).run(_ctx_with_incoming(incoming_json, vendor="gemini"))
print("PIPE OUT:", _pp(out))
assert CAPTURED, "No HTTP request captured"
req = CAPTURED[-1]
payload = req["payload"]
# Validate OpenAI body
assert "messages" in payload, "OpenAI payload must contain messages"
msgs = payload["messages"]
# Expected: 2 (converted Gemini) + 2 (PROMPT blocks system+user) = 4
assert isinstance(msgs, list) and len(msgs) == 4
roles = [m.get("role") for m in msgs]
# Gemini model -> OpenAI assistant
assert "assistant" in roles and "user" in roles
# PROMPT system+user present (system may be not first without @pos; we just ensure existence)
assert any(m.get("role") == "system" for m in msgs), "System message from PROMPT must be present"
async def scenario_gemini_target_from_openai_messages():
print("\n=== PROMPT_COMBINE 2: target=gemini, incoming=openai.messages & PROMPT ===")
_patch_http_client()
CAPTURED.clear()
incoming_json = {
"messages": [
{"role": "system", "content": "Системный-тест из входящего"},
{"role": "user", "content": "Its just me.."},
{"role": "assistant", "content": "Reply from model"},
]
}
p = _mk_pipeline("gemini", "[[VAR:incoming.json.messages]] & [[PROMPT]]")
out = await PipelineExecutor(p).run(_ctx_with_incoming(incoming_json, vendor="openai"))
print("PIPE OUT:", _pp(out))
assert CAPTURED, "No HTTP request captured"
payload = CAPTURED[-1]["payload"]
# Validate Gemini body
assert "contents" in payload, "Gemini payload must contain contents"
cnts = payload["contents"]
assert isinstance(cnts, list)
# PROMPT system goes to systemInstruction, user block goes to contents
assert "systemInstruction" in payload, "Gemini payload must contain systemInstruction when system text exists"
si = payload["systemInstruction"]
# SystemInstruction.parts[].text must include both incoming system and PROMPT system merged
si_texts = []
try:
for prt in si.get("parts", []):
t = prt.get("text")
if isinstance(t, str) and t.strip():
si_texts.append(t.strip())
except Exception:
pass
joined = "\n".join(si_texts)
assert "Системный-тест из входящего" in joined, "Incoming system must be merged into systemInstruction"
assert "Narrator-chan" in joined, "PROMPT system must be merged into systemInstruction"
async def scenario_claude_target_from_openai_messages():
print("\n=== PROMPT_COMBINE 3: target=claude, incoming=openai.messages & PROMPT ===")
_patch_http_client()
CAPTURED.clear()
incoming_json = {
"messages": [
{"role": "system", "content": "Системный-тест CLAUDE"},
{"role": "user", "content": "Прив"},
{"role": "assistant", "content": "Привет!"},
]
}
p = _mk_pipeline("claude", "[[VAR:incoming.json.messages]] & [[PROMPT]]")
out = await PipelineExecutor(p).run(_ctx_with_incoming(incoming_json, vendor="openai"))
print("PIPE OUT:", _pp(out))
assert CAPTURED, "No HTTP request captured"
payload = CAPTURED[-1]["payload"]
# Validate Claude body
assert "messages" in payload, "Claude payload must contain messages"
assert "system" in payload, "Claude payload must contain system blocks"
sys_blocks = payload["system"]
# system must be array of blocks with type=text
assert isinstance(sys_blocks, list) and any(isinstance(b, dict) and b.get("type") == "text" for b in sys_blocks)
sys_text_join = "\n".join([b.get("text") for b in sys_blocks if isinstance(b, dict) and isinstance(b.get("text"), str)])
assert "Системный-тест CLAUDE" in sys_text_join, "Incoming system should be present"
assert "Narrator-chan" in sys_text_join, "PROMPT system should be present"
async def scenario_prepend_positioning_openai():
print("\n=== PROMPT_COMBINE 4: target=openai, PROMPT@pos=prepend & incoming.contents ===")
_patch_http_client()
CAPTURED.clear()
incoming_json = {
"contents": [
{"role": "user", "parts": [{"text": "A"}]},
{"role": "model", "parts": [{"text": "B"}]},
]
}
# Put PROMPT first; ensure system message becomes first in messages
p = _mk_pipeline("openai", "[[PROMPT]]@pos=prepend & [[VAR:incoming.json.contents]]")
out = await PipelineExecutor(p).run(_ctx_with_incoming(incoming_json, vendor="gemini"))
print("PIPE OUT:", _pp(out))
assert CAPTURED, "No HTTP request captured"
payload = CAPTURED[-1]["payload"]
msgs = payload.get("messages", [])
assert isinstance(msgs, list) and len(msgs) >= 2
first = msgs[0]
# Expect first to be system (from PROMPT) due to prepend
assert first.get("role") == "system", f"Expected system as first message, got {first}"
def test_prompt_combine_all():
async def main():
await scenario_openai_target_from_gemini_contents()
await scenario_gemini_target_from_openai_messages()
await scenario_claude_target_from_openai_messages()
await scenario_prepend_positioning_openai()
print("\n=== PROMPT_COMBINE: DONE ===")
asyncio.run(main())

View File

@@ -0,0 +1,23 @@
# Pytest-обёртка для существующих сценариев, которые сами себя запускают через run_all()/run_checks()
# Позволяет запускать все тесты одной командой: python -m pytest -q
# Не меняем исходные файлы, просто вызываем их публичные функции из pytest-тестов.
def test_executor_iterative():
# tests/test_executor_iterative.py содержит run_checks() (внутри сам asyncio.run)
from tests.test_executor_iterative import run_checks
run_checks()
def test_edge_cases():
# tests/test_edge_cases.py содержит run_all() (внутри сам asyncio.run)
from tests.test_edge_cases import run_all
run_all()
def test_macros_and_vars():
# tests/test_macros_vars.py содержит run_all() (внутри сам asyncio.run)
from tests.test_macros_vars import run_all
run_all()
def test_while_nodes():
# наш новый набор сценариев; внутри есть run_all() со своим asyncio.run
from tests.test_while_nodes import run_all
run_all()

97
tests/test_setvars_jp.py Normal file
View File

@@ -0,0 +1,97 @@
import asyncio
import json
from agentui.pipeline.executor import PipelineExecutor
def run_checks():
async def main():
print("\n=== JP FUNCTIONS: SetVars expr → jp/from_json/jp_text ===")
# 1) from_json + jp: извлечь число по пути
p1 = {
"id": "p_jp_1",
"name": "JP basic",
"loop_mode": "dag",
"nodes": [
{
"id": "n1",
"type": "SetVars",
"config": {
"variables": [
{
"id": "v1",
"name": "VAL",
"mode": "expr",
# JSON-строка → объект → jp по пути "a.b.1.x" -> 2
"value": "jp(from_json('{\"a\":{\"b\":[{\"x\":1},{\"x\":2}]}}'), 'a.b.1.x')"
}
]
},
"in": {}
},
{
"id": "n2",
"type": "Return",
"config": {
"target_format": "openai",
# Должно быть число 2, выводим как текст
"text_template": "{{ VAL }}"
},
"in": { "depends": "n1.done" }
}
]
}
ctx = {
"model": "gpt-x",
"vendor_format": "openai",
"params": {},
"chat": {"last_user": "hi"},
"OUT": {}
}
out1 = await PipelineExecutor(p1).run(ctx)
msg1 = out1["result"]["choices"][0]["message"]["content"]
print("OUT1:", msg1)
assert msg1 == "2"
# 2) jp_text: собрать строки из массива
p2 = {
"id": "p_jp_2",
"name": "JP text join",
"loop_mode": "dag",
"nodes": [
{
"id": "n1",
"type": "SetVars",
"config": {
"variables": [
{
"id": "v1",
"name": "TXT",
"mode": "expr",
"value": "jp_text(from_json('{\"items\":[{\"t\":\"A\"},{\"t\":\"B\"},{\"t\":\"C\"}]}'), 'items.*.t', ' | ')"
}
]
},
"in": {}
},
{
"id": "n2",
"type": "Return",
"config": {
"target_format": "openai",
"text_template": "[[TXT]]"
},
"in": { "depends": "n1.done" }
}
]
}
out2 = await PipelineExecutor(p2).run(ctx)
msg2 = out2["result"]["choices"][0]["message"]["content"]
print("OUT2:", msg2)
assert msg2 == "A | B | C"
print("JP functions tests: OK")
asyncio.run(main())
if __name__ == "__main__":
run_checks()
print("Tests completed")

134
tests/test_while_nodes.py Normal file
View File

@@ -0,0 +1,134 @@
import asyncio
from agentui.pipeline.executor import PipelineExecutor
from tests.utils import ctx as _ctx
async def scenario_providercall_while_ignore():
# ProviderCall with while loop and ignore_errors enabled.
# No base_url is provided to force ExecutionError inside node.run();
# wrapper will catch it and expose {"error": "..."} plus vars.
p = {
"id": "p_pc_while_ignore",
"name": "ProviderCall while+ignore",
"loop_mode": "dag",
"nodes": [
{
"id": "n2",
"type": "ProviderCall",
"config": {
"provider": "openai",
# while: 3 iterations (0,1,2)
"while_expr": "cycleindex < 3",
"while_max_iters": 10,
"ignore_errors": True,
# no base_url / provider_configs to trigger error safely
},
"in": {}
}
]
}
out = await PipelineExecutor(p).run(_ctx())
assert isinstance(out, dict)
# Wrapper returns final out with .vars merged by executor into STORE as well, but we assert on node out.
vars_map = out.get("vars") or {}
assert isinstance(vars_map, dict)
# Final iteration index should be 2
assert vars_map.get("WAS_ERROR__n2") is True
assert vars_map.get("CYCLEINDEX__n2") == 2
async def scenario_rawforward_while_ignore():
# RawForward with while loop and ignore_errors enabled.
# No base_url and incoming.json is a plain string -> detect_vendor=unknown -> ExecutionError,
# wrapper catches and returns {"error": "..."} with vars set.
p = {
"id": "p_rf_while_ignore",
"name": "RawForward while+ignore",
"loop_mode": "dag",
"nodes": [
{
"id": "n1",
"type": "RawForward",
"config": {
"while_expr": "cycleindex < 2",
"while_max_iters": 10,
"ignore_errors": True,
# no base_url; vendor detect will fail on plain text
},
"in": {}
}
]
}
ctx = _ctx()
# Provide incoming as plain text-like JSON so detect_vendor returns unknown
ctx["incoming"] = {
"method": "POST",
"url": "http://example.local/test",
"path": "/test",
"query": "",
"headers": {"content-type": "text/plain"},
"json": "raw-plain-body-simulated"
}
out = await PipelineExecutor(p).run(ctx)
assert isinstance(out, dict)
vars_map = out.get("vars") or {}
assert isinstance(vars_map, dict)
# Final iteration index should be 1 (0 and 1)
assert vars_map.get("WAS_ERROR__n1") is True
assert vars_map.get("CYCLEINDEX__n1") == 1
async def scenario_providercall_while_with_out_macro():
# SetVars -> ProviderCall while uses OUT from n1 in expression
# Expression: ([[OUT:n1.vars.MSG]] contains "123") && (cycleindex < 2)
# Ignore errors to bypass real HTTP
p = {
"id": "p_pc_while_out_macro",
"name": "ProviderCall while with OUT macro",
"loop_mode": "iterative",
"nodes": [
{
"id": "n1",
"type": "SetVars",
"config": {
"variables": [
{"id": "v1", "name": "MSG", "mode": "string", "value": "abc123xyz"}
]
},
"in": {}
},
{
"id": "n2",
"type": "ProviderCall",
"config": {
"provider": "openai",
"while_expr": "([[OUT:n1.vars.MSG]] contains \"123\") && (cycleindex < 2)",
"while_max_iters": 10,
"ignore_errors": True
},
"in": {
"depends": "n1.done"
}
}
]
}
out = await PipelineExecutor(p).run(_ctx())
assert isinstance(out, dict)
vars_map = out.get("vars") or {}
assert isinstance(vars_map, dict)
# Since MSG contains "123" and cycleindex < 2, two iterations (0,1)
assert vars_map.get("WAS_ERROR__n2") is True
assert vars_map.get("CYCLEINDEX__n2") == 1
def run_all():
async def main():
await scenario_providercall_while_ignore()
await scenario_rawforward_while_ignore()
await scenario_providercall_while_with_out_macro()
print("\n=== WHILE_NODES: DONE ===")
asyncio.run(main())
if __name__ == "__main__":
run_all()

52
tests/utils.py Normal file
View File

@@ -0,0 +1,52 @@
from __future__ import annotations
import json
from typing import Any, Dict, Optional
def pp(obj: Any, max_len: int = 800) -> str:
"""
Pretty-print JSON-like objects in tests with length guard.
"""
try:
s = json.dumps(obj, ensure_ascii=False, indent=2)
except Exception:
s = str(obj)
if len(s) > max_len:
return s[:max_len] + "...<truncated>"
return s
def base_ctx(vendor: str = "openai") -> Dict[str, Any]:
"""
Base context used by edge-case tests (mirrors previous _base_ctx).
"""
return {
"model": "gpt-x",
"vendor_format": vendor,
"params": {"temperature": 0.1},
"chat": {"last_user": "hi"},
"OUT": {},
}
def ctx(vendor: str = "openai", incoming: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
General context used by macros/vars tests (mirrors previous _ctx).
"""
return {
"model": "gpt-x",
"vendor_format": vendor,
"params": params or {"temperature": 0.25},
"chat": {"last_user": "Привет"},
"OUT": {},
"incoming": incoming
or {
"method": "POST",
"url": "http://localhost/test",
"path": "/test",
"query": "",
"headers": {"x": "X-HEADER"},
"json": {},
},
}