mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-20 19:36:08 +00:00
new node
This commit is contained in:
@@ -323,18 +323,16 @@ class WorkflowsRouterGroup(group.RouterGroup):
|
|||||||
"model_uuid": "uuid-of-model",
|
"model_uuid": "uuid-of-model",
|
||||||
"system_prompt": "optional system prompt",
|
"system_prompt": "optional system prompt",
|
||||||
"user_prompt": "test message",
|
"user_prompt": "test message",
|
||||||
"enable_tools": false,
|
|
||||||
"tools": [],
|
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tokens": 100
|
"max_tokens": 100
|
||||||
}
|
}
|
||||||
|
|
||||||
Response includes timing for each step:
|
Response includes timing for each step:
|
||||||
- model_fetch: Time to get model from model_mgr
|
- model_fetch: Time to get model from model_mgr
|
||||||
- tool_fetch: Time to load tools (if enabled)
|
|
||||||
- prompt_build: Time to build messages
|
- prompt_build: Time to build messages
|
||||||
- llm_call: Time for actual LLM invocation
|
- llm_call: Time for actual LLM invocation
|
||||||
- total: Total time
|
- total: Total time
|
||||||
|
- usage: Token usage information
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -348,8 +346,6 @@ class WorkflowsRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
user_prompt = json_data.get('user_prompt', 'test')
|
user_prompt = json_data.get('user_prompt', 'test')
|
||||||
system_prompt = json_data.get('system_prompt', '')
|
system_prompt = json_data.get('system_prompt', '')
|
||||||
enable_tools = json_data.get('enable_tools', False)
|
|
||||||
tools_config = json_data.get('tools', [])
|
|
||||||
temperature = json_data.get('temperature')
|
temperature = json_data.get('temperature')
|
||||||
max_tokens = json_data.get('max_tokens', 0)
|
max_tokens = json_data.get('max_tokens', 0)
|
||||||
|
|
||||||
@@ -372,24 +368,7 @@ class WorkflowsRouterGroup(group.RouterGroup):
|
|||||||
'timings': timings,
|
'timings': timings,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Step 2: Tool fetch (if enabled)
|
# Step 2: Build messages
|
||||||
timings['tool_fetch_ms'] = 0
|
|
||||||
timings['tools_loaded'] = 0
|
|
||||||
if enable_tools and tools_config:
|
|
||||||
t_start = time.perf_counter()
|
|
||||||
try:
|
|
||||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
|
||||||
all_tools = await self.ap.tool_mgr.get_tools()
|
|
||||||
tool_names = tools_config if isinstance(tools_config, list) else []
|
|
||||||
funcs = [t for t in all_tools if t.name in tool_names]
|
|
||||||
timings['tool_fetch_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
|
|
||||||
timings['tools_loaded'] = len(funcs)
|
|
||||||
timings['tool_names'] = [t.name for t in funcs]
|
|
||||||
except Exception as e:
|
|
||||||
timings['tool_fetch_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
|
|
||||||
errors.append(f'Tool fetch failed: {str(e)}')
|
|
||||||
|
|
||||||
# Step 3: Build messages
|
|
||||||
t_start = time.perf_counter()
|
t_start = time.perf_counter()
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||||
messages = []
|
messages = []
|
||||||
@@ -398,21 +377,21 @@ class WorkflowsRouterGroup(group.RouterGroup):
|
|||||||
messages.append(provider_message.Message(role='user', content=user_prompt))
|
messages.append(provider_message.Message(role='user', content=user_prompt))
|
||||||
timings['prompt_build_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
|
timings['prompt_build_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
|
||||||
|
|
||||||
# Step 4: Build extra args
|
# Step 3: Build extra args
|
||||||
extra_args = {}
|
extra_args = {}
|
||||||
if temperature is not None:
|
if temperature is not None:
|
||||||
extra_args['temperature'] = float(temperature)
|
extra_args['temperature'] = float(temperature)
|
||||||
if max_tokens and int(max_tokens) > 0:
|
if max_tokens and int(max_tokens) > 0:
|
||||||
extra_args['max_tokens'] = int(max_tokens)
|
extra_args['max_tokens'] = int(max_tokens)
|
||||||
|
|
||||||
# Step 5: LLM call
|
# Step 4: LLM call
|
||||||
t_start = time.perf_counter()
|
t_start = time.perf_counter()
|
||||||
try:
|
try:
|
||||||
result_message = await runtime_model.provider.invoke_llm(
|
result_message = await runtime_model.provider.invoke_llm(
|
||||||
query=None,
|
query=None,
|
||||||
model=runtime_model,
|
model=runtime_model,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
funcs=funcs if enable_tools else None,
|
funcs=None,
|
||||||
extra_args=extra_args,
|
extra_args=extra_args,
|
||||||
)
|
)
|
||||||
timings['llm_call_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
|
timings['llm_call_ms'] = round((time.perf_counter() - t_start) * 1000, 2)
|
||||||
@@ -451,7 +430,6 @@ class WorkflowsRouterGroup(group.RouterGroup):
|
|||||||
# Calculate total
|
# Calculate total
|
||||||
timings['total_ms'] = round(sum([
|
timings['total_ms'] = round(sum([
|
||||||
timings.get('model_fetch_ms', 0),
|
timings.get('model_fetch_ms', 0),
|
||||||
timings.get('tool_fetch_ms', 0),
|
|
||||||
timings.get('prompt_build_ms', 0),
|
timings.get('prompt_build_ms', 0),
|
||||||
timings.get('llm_call_ms', 0),
|
timings.get('llm_call_ms', 0),
|
||||||
]), 2)
|
]), 2)
|
||||||
@@ -460,7 +438,6 @@ class WorkflowsRouterGroup(group.RouterGroup):
|
|||||||
if timings['total_ms'] > 0:
|
if timings['total_ms'] > 0:
|
||||||
timings['breakdown'] = {
|
timings['breakdown'] = {
|
||||||
'model_fetch_pct': round(timings.get('model_fetch_ms', 0) / timings['total_ms'] * 100, 1),
|
'model_fetch_pct': round(timings.get('model_fetch_ms', 0) / timings['total_ms'] * 100, 1),
|
||||||
'tool_fetch_pct': round(timings.get('tool_fetch_ms', 0) / timings['total_ms'] * 100, 1),
|
|
||||||
'prompt_build_pct': round(timings.get('prompt_build_ms', 0) / timings['total_ms'] * 100, 1),
|
'prompt_build_pct': round(timings.get('prompt_build_ms', 0) / timings['total_ms'] * 100, 1),
|
||||||
'llm_call_pct': round(timings.get('llm_call_ms', 0) / timings['total_ms'] * 100, 1),
|
'llm_call_pct': round(timings.get('llm_call_ms', 0) / timings['total_ms'] * 100, 1),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ class WorkflowService:
|
|||||||
'uuid': workflow_uuid,
|
'uuid': workflow_uuid,
|
||||||
'name': workflow_data.get('name', 'New Workflow'),
|
'name': workflow_data.get('name', 'New Workflow'),
|
||||||
'description': workflow_data.get('description', ''),
|
'description': workflow_data.get('description', ''),
|
||||||
'emoji': workflow_data.get('emoji', '🔄'),
|
'emoji': workflow_data.get('emoji', '💼'),
|
||||||
'version': 1,
|
'version': 1,
|
||||||
'is_enabled': workflow_data.get('is_enabled', True),
|
'is_enabled': workflow_data.get('is_enabled', True),
|
||||||
'definition': workflow_data.get(
|
'definition': workflow_data.get(
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ class WorkflowDefinition(pydantic.BaseModel):
|
|||||||
uuid: str
|
uuid: str
|
||||||
name: str
|
name: str
|
||||||
description: str = ''
|
description: str = ''
|
||||||
emoji: str = '🔄'
|
emoji: str = '💼'
|
||||||
version: int = 1
|
version: int = 1
|
||||||
|
|
||||||
# Workflow graph
|
# Workflow graph
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from . import variable_aggregator
|
|||||||
from . import send_message
|
from . import send_message
|
||||||
from . import reply_message
|
from . import reply_message
|
||||||
from . import call_pipeline
|
from . import call_pipeline
|
||||||
|
from . import call_workflow
|
||||||
from . import store_data
|
from . import store_data
|
||||||
from . import set_variable
|
from . import set_variable
|
||||||
from . import opening_statement
|
from . import opening_statement
|
||||||
@@ -74,6 +75,7 @@ __all__ = [
|
|||||||
'send_message',
|
'send_message',
|
||||||
'reply_message',
|
'reply_message',
|
||||||
'call_pipeline',
|
'call_pipeline',
|
||||||
|
'call_workflow',
|
||||||
'store_data',
|
'store_data',
|
||||||
'set_variable',
|
'set_variable',
|
||||||
'opening_statement',
|
'opening_statement',
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Call Workflow Node - invoke an existing workflow
|
||||||
|
|
||||||
|
Node metadata is loaded from: ../../templates/metadata/nodes/call_workflow.yaml
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
|
||||||
|
|
||||||
|
from ..node import WorkflowNode, workflow_node
|
||||||
|
|
||||||
|
|
||||||
|
@workflow_node('call_workflow')
|
||||||
|
class CallWorkflowNode(WorkflowNode):
|
||||||
|
"""Call workflow node - invoke an existing workflow"""
|
||||||
|
|
||||||
|
category = 'action'
|
||||||
|
|
||||||
|
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||||
|
if not self.ap:
|
||||||
|
raise RuntimeError('Application instance not available — cannot call workflow')
|
||||||
|
|
||||||
|
# Get workflow reference from config
|
||||||
|
workflow_ref = str(self.get_config('workflow_uuid', '') or '').strip()
|
||||||
|
if not workflow_ref:
|
||||||
|
raise ValueError('No workflow configured for call workflow node')
|
||||||
|
|
||||||
|
# Get workflow definition from service
|
||||||
|
workflow_data = await self.ap.workflow_service.get_workflow(workflow_ref)
|
||||||
|
if workflow_data is None:
|
||||||
|
raise ValueError(f'Workflow not found: {workflow_ref}')
|
||||||
|
|
||||||
|
workflow_uuid = str(workflow_data.get('uuid', '') or '')
|
||||||
|
if not workflow_uuid:
|
||||||
|
raise ValueError(f'Workflow UUID missing for: {workflow_ref}')
|
||||||
|
|
||||||
|
# Build variables to pass to the called workflow
|
||||||
|
variables = dict(inputs.get('variables', {}) or {})
|
||||||
|
|
||||||
|
# Inherit current workflow variables if configured
|
||||||
|
if self.get_config('inherit_variables', True):
|
||||||
|
for key, value in (context.variables or {}).items():
|
||||||
|
if key not in variables:
|
||||||
|
variables[key] = value
|
||||||
|
|
||||||
|
# Add context markers for debugging
|
||||||
|
variables['_called_from_workflow'] = True
|
||||||
|
variables['_parent_workflow_id'] = context.workflow_id
|
||||||
|
variables['_parent_execution_id'] = context.execution_id
|
||||||
|
|
||||||
|
# Execute the workflow
|
||||||
|
execution_id = await self.ap.workflow_service.execute_workflow(
|
||||||
|
workflow_uuid=workflow_uuid,
|
||||||
|
trigger_type='workflow_call',
|
||||||
|
trigger_data={
|
||||||
|
'variables': variables,
|
||||||
|
'parent_execution_id': context.execution_id,
|
||||||
|
},
|
||||||
|
session_id=context.session_id,
|
||||||
|
user_id=context.user_id,
|
||||||
|
bot_id=context.bot_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get execution result
|
||||||
|
execution = await self.ap.workflow_service.get_execution(execution_id)
|
||||||
|
if execution is None:
|
||||||
|
raise ValueError(f'Execution result not found: {execution_id}')
|
||||||
|
|
||||||
|
# Build result
|
||||||
|
result = {
|
||||||
|
'workflow_uuid': workflow_uuid,
|
||||||
|
'workflow_name': workflow_data.get('name', ''),
|
||||||
|
'execution_id': execution_id,
|
||||||
|
'status': execution.get('status', 'unknown'),
|
||||||
|
'variables': execution.get('variables', {}),
|
||||||
|
'error': execution.get('error'),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'result': result,
|
||||||
|
'status': execution.get('status', 'unknown'),
|
||||||
|
'error': execution.get('error'),
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
"""LLM Call Node - invoke large language model."""
|
"""LLM Call Node - invoke large language model with Agent capabilities.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- Primary model with fallback models
|
||||||
|
- Knowledge base retrieval with reranking
|
||||||
|
- Max round context control
|
||||||
|
- Streaming output
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -8,7 +15,7 @@ import re
|
|||||||
from typing import Any, AsyncGenerator
|
from typing import Any, AsyncGenerator
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
import langbot_plugin.api.entities.builtin.rag.context as rag_context
|
||||||
|
|
||||||
from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
|
from langbot_plugin.api.entities.builtin.workflow import ExecutionContext
|
||||||
from ..node import WorkflowNode, workflow_node
|
from ..node import WorkflowNode, workflow_node
|
||||||
@@ -133,29 +140,20 @@ class LLMCallNode(WorkflowNode):
|
|||||||
|
|
||||||
return text, False, ''
|
return text, False, ''
|
||||||
|
|
||||||
def _parse_tools_config(self, tools_config: Any) -> list[dict]:
|
# RAG combined prompt template (same as localagent.py)
|
||||||
"""Parse tools configuration from YAML config format."""
|
RAG_COMBINED_PROMPT_TEMPLATE = """
|
||||||
if not tools_config:
|
The following are relevant context entries retrieved from the knowledge base.
|
||||||
return []
|
Please use them to answer the user's message.
|
||||||
|
Respond in the same language as the user's input.
|
||||||
|
|
||||||
# If it's already a list, return as-is
|
<context>
|
||||||
if isinstance(tools_config, list):
|
{rag_context}
|
||||||
return tools_config
|
</context>
|
||||||
|
|
||||||
# If it's a string, try to parse as JSON
|
<user_message>
|
||||||
if isinstance(tools_config, str):
|
{user_message}
|
||||||
tools_config = tools_config.strip()
|
</user_message>
|
||||||
if not tools_config:
|
"""
|
||||||
return []
|
|
||||||
try:
|
|
||||||
parsed = json.loads(tools_config)
|
|
||||||
if isinstance(parsed, list):
|
|
||||||
return parsed
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
logger.warning(f'Failed to parse tools config as JSON: {tools_config}')
|
|
||||||
return []
|
|
||||||
|
|
||||||
return []
|
|
||||||
|
|
||||||
def _build_system_prompt_with_format(self, base_prompt: str, output_format: str, json_schema: str) -> str:
|
def _build_system_prompt_with_format(self, base_prompt: str, output_format: str, json_schema: str) -> str:
|
||||||
"""Build system prompt with output format instructions."""
|
"""Build system prompt with output format instructions."""
|
||||||
@@ -170,6 +168,220 @@ class LLMCallNode(WorkflowNode):
|
|||||||
|
|
||||||
return prompt
|
return prompt
|
||||||
|
|
||||||
|
def _build_messages_from_prompt_array(
|
||||||
|
self,
|
||||||
|
prompt_array: list[dict],
|
||||||
|
inputs: dict[str, Any],
|
||||||
|
context: ExecutionContext,
|
||||||
|
output_format: str,
|
||||||
|
json_schema: str,
|
||||||
|
) -> list[provider_message.Message]:
|
||||||
|
"""Build messages list from prompt array (same format as pipeline).
|
||||||
|
|
||||||
|
Each item in prompt_array is {role: str, content: str}.
|
||||||
|
Resolves template variables in content.
|
||||||
|
"""
|
||||||
|
messages: list[provider_message.Message] = []
|
||||||
|
|
||||||
|
for item in prompt_array:
|
||||||
|
role = item.get('role', 'user')
|
||||||
|
content = item.get('content', '')
|
||||||
|
|
||||||
|
# Resolve template variables in content
|
||||||
|
resolved_content = self._resolve_template(content, inputs, context)
|
||||||
|
|
||||||
|
# Apply format instructions to system prompt
|
||||||
|
if role == 'system':
|
||||||
|
resolved_content = self._build_system_prompt_with_format(
|
||||||
|
resolved_content, output_format, json_schema
|
||||||
|
)
|
||||||
|
|
||||||
|
messages.append(provider_message.Message(role=role, content=resolved_content))
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
async def _get_model_candidates(self, model_uuid: str, fallback_models: list) -> list:
|
||||||
|
"""Build ordered list of models to try: primary model + fallback models."""
|
||||||
|
candidates = []
|
||||||
|
|
||||||
|
# Primary model
|
||||||
|
if model_uuid:
|
||||||
|
try:
|
||||||
|
primary = await self.ap.model_mgr.get_model_by_uuid(model_uuid)
|
||||||
|
candidates.append(primary)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(f'[LLM:{self.node_id}] Primary model {model_uuid} not found')
|
||||||
|
|
||||||
|
# Fallback models
|
||||||
|
for fb_uuid in fallback_models:
|
||||||
|
try:
|
||||||
|
fb_model = await self.ap.model_mgr.get_model_by_uuid(fb_uuid)
|
||||||
|
candidates.append(fb_model)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(f'[LLM:{self.node_id}] Fallback model {fb_uuid} not found, skipping')
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
async def _invoke_with_fallback(
|
||||||
|
self,
|
||||||
|
candidates: list,
|
||||||
|
messages: list,
|
||||||
|
funcs: list | None,
|
||||||
|
extra_args: dict,
|
||||||
|
) -> tuple[Any, Any]:
|
||||||
|
"""Try non-streaming invocation with sequential fallback. Returns (message, model_used)."""
|
||||||
|
last_error = None
|
||||||
|
for model in candidates:
|
||||||
|
try:
|
||||||
|
msg = await model.provider.invoke_llm(
|
||||||
|
query=None,
|
||||||
|
model=model,
|
||||||
|
messages=messages,
|
||||||
|
funcs=funcs if model.model_entity.abilities.__contains__('func_call') else [],
|
||||||
|
extra_args=extra_args,
|
||||||
|
)
|
||||||
|
return msg, model
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
logger.warning(f'[LLM:{self.node_id}] Model {model.model_entity.name} failed: {e}, trying next...')
|
||||||
|
raise last_error or RuntimeError('No model candidates available')
|
||||||
|
|
||||||
|
async def _retrieve_knowledge(
|
||||||
|
self,
|
||||||
|
user_message_text: str,
|
||||||
|
knowledge_bases: list[str],
|
||||||
|
rerank_model_uuid: str,
|
||||||
|
rerank_top_k: int,
|
||||||
|
) -> str:
|
||||||
|
"""Retrieve from knowledge bases and optionally rerank results.
|
||||||
|
|
||||||
|
Returns the enhanced user message text with RAG context, or original text if no results.
|
||||||
|
"""
|
||||||
|
if not knowledge_bases or not user_message_text:
|
||||||
|
return user_message_text
|
||||||
|
|
||||||
|
all_results: list[rag_context.RetrievalResultEntry] = []
|
||||||
|
|
||||||
|
# Retrieve from each knowledge base
|
||||||
|
for kb_uuid in knowledge_bases:
|
||||||
|
try:
|
||||||
|
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
|
||||||
|
if not kb:
|
||||||
|
logger.warning(f'[LLM:{self.node_id}] Knowledge base {kb_uuid} not found, skipping')
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = await kb.retrieve(user_message_text, settings={})
|
||||||
|
if result:
|
||||||
|
all_results.extend(result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f'[LLM:{self.node_id}] Failed to retrieve from KB {kb_uuid}: {e}')
|
||||||
|
|
||||||
|
# Rerank step: re-score results using a rerank model if configured
|
||||||
|
if all_results and rerank_model_uuid:
|
||||||
|
try:
|
||||||
|
rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(rerank_model_uuid)
|
||||||
|
|
||||||
|
doc_texts = []
|
||||||
|
for entry in all_results:
|
||||||
|
text = ' '.join(c.text for c in entry.content if c.type == 'text' and c.text)
|
||||||
|
doc_texts.append(text)
|
||||||
|
|
||||||
|
doc_texts_capped = doc_texts[:64] # Cap for reranker input
|
||||||
|
scores = await rerank_model.provider.invoke_rerank(
|
||||||
|
model=rerank_model,
|
||||||
|
query=user_message_text,
|
||||||
|
documents=doc_texts_capped,
|
||||||
|
)
|
||||||
|
|
||||||
|
scored = sorted(scores, key=lambda x: x.get('relevance_score', 0), reverse=True)
|
||||||
|
top_indices = [s['index'] for s in scored[:rerank_top_k] if s['index'] < len(all_results)]
|
||||||
|
all_results = [all_results[i] for i in top_indices]
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f'[LLM:{self.node_id}] Rerank complete: {len(doc_texts)} docs -> top {len(all_results)} kept (top_k={rerank_top_k})'
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(f'[LLM:{self.node_id}] Rerank model {rerank_model_uuid} not found, skipping rerank')
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f'[LLM:{self.node_id}] Rerank failed, using original order: {e}')
|
||||||
|
|
||||||
|
# Build RAG context text
|
||||||
|
if all_results:
|
||||||
|
texts = []
|
||||||
|
idx = 1
|
||||||
|
for entry in all_results:
|
||||||
|
for content in entry.content:
|
||||||
|
if content.type == 'text' and content.text is not None:
|
||||||
|
texts.append(f'[{idx}] {content.text}')
|
||||||
|
idx += 1
|
||||||
|
rag_context_text = '\n\n'.join(texts)
|
||||||
|
return self.RAG_COMBINED_PROMPT_TEMPLATE.format(
|
||||||
|
rag_context=rag_context_text,
|
||||||
|
user_message=user_message_text,
|
||||||
|
)
|
||||||
|
|
||||||
|
return user_message_text
|
||||||
|
|
||||||
|
def _build_messages_with_history(
|
||||||
|
self,
|
||||||
|
system_prompt: str,
|
||||||
|
user_message_text: str,
|
||||||
|
context: ExecutionContext,
|
||||||
|
max_round: int,
|
||||||
|
) -> list[provider_message.Message]:
|
||||||
|
"""Build messages list with conversation history up to max_round."""
|
||||||
|
messages: list[provider_message.Message] = []
|
||||||
|
|
||||||
|
# Add system prompt
|
||||||
|
if system_prompt:
|
||||||
|
messages.append(provider_message.Message(role='system', content=system_prompt))
|
||||||
|
|
||||||
|
# Get conversation history from context
|
||||||
|
conversation_history = context.variables.get('_conversation_history', [])
|
||||||
|
|
||||||
|
# Apply max_round limit (each round = 1 user + 1 assistant message)
|
||||||
|
if max_round > 0 and conversation_history:
|
||||||
|
# Keep only the last max_round * 2 messages (user + assistant pairs)
|
||||||
|
max_messages = max_round * 2
|
||||||
|
if len(conversation_history) > max_messages:
|
||||||
|
conversation_history = conversation_history[-max_messages:]
|
||||||
|
|
||||||
|
# Add conversation history
|
||||||
|
for msg in conversation_history:
|
||||||
|
if isinstance(msg, dict):
|
||||||
|
role = msg.get('role', 'user')
|
||||||
|
content = msg.get('content', '')
|
||||||
|
messages.append(provider_message.Message(role=role, content=content))
|
||||||
|
elif hasattr(msg, 'role') and hasattr(msg, 'content'):
|
||||||
|
messages.append(provider_message.Message(role=msg.role, content=msg.content))
|
||||||
|
|
||||||
|
# Add current user message
|
||||||
|
messages.append(provider_message.Message(role='user', content=user_message_text))
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def _save_to_conversation_history(
|
||||||
|
self,
|
||||||
|
context: ExecutionContext,
|
||||||
|
user_message_text: str,
|
||||||
|
response_text: str,
|
||||||
|
max_round: int,
|
||||||
|
) -> None:
|
||||||
|
"""Save the exchange to conversation history."""
|
||||||
|
if max_round <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
history = context.variables.get('_conversation_history', [])
|
||||||
|
history.append({'role': 'user', 'content': user_message_text})
|
||||||
|
history.append({'role': 'assistant', 'content': response_text})
|
||||||
|
|
||||||
|
# Enforce max_round limit
|
||||||
|
max_messages = max_round * 2
|
||||||
|
if len(history) > max_messages:
|
||||||
|
history = history[-max_messages:]
|
||||||
|
|
||||||
|
context.variables['_conversation_history'] = history
|
||||||
|
|
||||||
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
async def execute(self, inputs: dict[str, Any], context: ExecutionContext) -> dict[str, Any]:
|
||||||
model_uuid = self.get_config('model', '')
|
model_uuid = self.get_config('model', '')
|
||||||
if not model_uuid:
|
if not model_uuid:
|
||||||
@@ -187,28 +399,90 @@ class LLMCallNode(WorkflowNode):
|
|||||||
output_format = self.get_config('output_format', 'text')
|
output_format = self.get_config('output_format', 'text')
|
||||||
json_schema = self.get_config('json_schema', '')
|
json_schema = self.get_config('json_schema', '')
|
||||||
|
|
||||||
# Get tools config
|
# Agent config: fallback models, knowledge bases, rerank, max_round
|
||||||
enable_tools = self.get_config('enable_tools', False)
|
fallback_models = self.get_config('fallback_models', [])
|
||||||
tools_config = self.get_config('tools', [])
|
knowledge_bases = self.get_config('knowledge_bases', [])
|
||||||
|
rerank_model = self.get_config('rerank_model', '')
|
||||||
|
rerank_top_k = self.get_config('rerank_top_k', 5)
|
||||||
|
max_round = self.get_config('max_round', 10)
|
||||||
|
|
||||||
# Resolve prompts
|
# Resolve prompts - support both new prompt array format and legacy format
|
||||||
system_prompt = self._resolve_template(self.get_config('system_prompt') or '', inputs, context)
|
prompt_array = self.get_config('prompt')
|
||||||
user_prompt_template = self.get_config('user_prompt_template')
|
user_prompt = '' # Initialize for later use in _save_to_conversation_history
|
||||||
if user_prompt_template is None:
|
|
||||||
user_prompt_template = '{{input}}'
|
if prompt_array and isinstance(prompt_array, list):
|
||||||
user_prompt = self._resolve_template(user_prompt_template, inputs, context)
|
# New format: prompt array like pipeline
|
||||||
|
messages = self._build_messages_from_prompt_array(
|
||||||
|
prompt_array, inputs, context, output_format, json_schema
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get user input text for knowledge retrieval
|
||||||
|
user_input = inputs.get('input', '')
|
||||||
|
|
||||||
|
# Knowledge retrieval: enhance user input with RAG context
|
||||||
|
user_input = await self._retrieve_knowledge(
|
||||||
|
user_message_text=user_input,
|
||||||
|
knowledge_bases=knowledge_bases,
|
||||||
|
rerank_model_uuid=rerank_model,
|
||||||
|
rerank_top_k=rerank_top_k,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track user_prompt for conversation history
|
||||||
|
user_prompt = user_input
|
||||||
|
|
||||||
|
# Add user input as last message
|
||||||
|
if user_input:
|
||||||
|
messages.append(provider_message.Message(role='user', content=user_input))
|
||||||
|
|
||||||
|
# Apply max_round to conversation history
|
||||||
|
conversation_history = context.variables.get('_conversation_history', [])
|
||||||
|
if max_round > 0 and conversation_history:
|
||||||
|
max_messages = max_round * 2
|
||||||
|
if len(conversation_history) > max_messages:
|
||||||
|
conversation_history = conversation_history[-max_messages:]
|
||||||
|
# Insert conversation history before user input
|
||||||
|
history_messages = []
|
||||||
|
for msg in conversation_history:
|
||||||
|
if isinstance(msg, dict):
|
||||||
|
role = msg.get('role', 'user')
|
||||||
|
content = msg.get('content', '')
|
||||||
|
history_messages.append(provider_message.Message(role=role, content=content))
|
||||||
|
elif hasattr(msg, 'role') and hasattr(msg, 'content'):
|
||||||
|
history_messages.append(provider_message.Message(role=msg.role, content=msg.content))
|
||||||
|
# Insert history before user message
|
||||||
|
if history_messages and len(messages) > 0:
|
||||||
|
messages = messages[:-1] + history_messages + [messages[-1]]
|
||||||
|
else:
|
||||||
|
# Legacy format: separate system_prompt and user_prompt_template
|
||||||
|
system_prompt = self._resolve_template(self.get_config('system_prompt') or '', inputs, context)
|
||||||
|
user_prompt_template = self.get_config('user_prompt_template')
|
||||||
|
if user_prompt_template is None:
|
||||||
|
user_prompt_template = '{{input}}'
|
||||||
|
user_prompt = self._resolve_template(user_prompt_template, inputs, context)
|
||||||
|
|
||||||
# Build system prompt with format instructions
|
# Build system prompt with format instructions
|
||||||
system_prompt = self._build_system_prompt_with_format(system_prompt, output_format, json_schema)
|
system_prompt = self._build_system_prompt_with_format(system_prompt, output_format, json_schema)
|
||||||
|
|
||||||
# Build messages
|
# Knowledge retrieval: enhance user prompt with RAG context
|
||||||
messages: list[provider_message.Message] = []
|
user_prompt = await self._retrieve_knowledge(
|
||||||
if system_prompt:
|
user_message_text=user_prompt,
|
||||||
messages.append(provider_message.Message(role='system', content=system_prompt))
|
knowledge_bases=knowledge_bases,
|
||||||
messages.append(provider_message.Message(role='user', content=user_prompt))
|
rerank_model_uuid=rerank_model,
|
||||||
|
rerank_top_k=rerank_top_k,
|
||||||
|
)
|
||||||
|
|
||||||
# Get model
|
# Build messages with conversation history
|
||||||
runtime_model = await self.ap.model_mgr.get_model_by_uuid(model_uuid)
|
messages = self._build_messages_with_history(
|
||||||
|
system_prompt=system_prompt,
|
||||||
|
user_message_text=user_prompt,
|
||||||
|
context=context,
|
||||||
|
max_round=max_round,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get model candidates (primary + fallbacks)
|
||||||
|
candidates = await self._get_model_candidates(model_uuid, fallback_models)
|
||||||
|
if not candidates:
|
||||||
|
raise ValueError('No valid model candidates available')
|
||||||
|
|
||||||
# Build extra args from config
|
# Build extra args from config
|
||||||
extra_args: dict[str, Any] = {}
|
extra_args: dict[str, Any] = {}
|
||||||
@@ -219,25 +493,12 @@ class LLMCallNode(WorkflowNode):
|
|||||||
if max_tokens and int(max_tokens) > 0:
|
if max_tokens and int(max_tokens) > 0:
|
||||||
extra_args['max_tokens'] = int(max_tokens)
|
extra_args['max_tokens'] = int(max_tokens)
|
||||||
|
|
||||||
# Build tools list if enabled
|
# Invoke LLM with fallback
|
||||||
funcs: list[resource_tool.LLMTool] | None = None
|
|
||||||
if enable_tools and tools_config:
|
|
||||||
try:
|
|
||||||
tool_names = self._parse_tools_config(tools_config)
|
|
||||||
if tool_names:
|
|
||||||
all_tools = await self.ap.tool_mgr.get_tools()
|
|
||||||
funcs = [t for t in all_tools if t.name in tool_names]
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f'[LLM:{self.node_id}] Failed to load tools: {e}')
|
|
||||||
funcs = None
|
|
||||||
|
|
||||||
# Invoke LLM
|
|
||||||
try:
|
try:
|
||||||
result_message = await runtime_model.provider.invoke_llm(
|
result_message, used_model = await self._invoke_with_fallback(
|
||||||
query=None,
|
candidates=candidates,
|
||||||
model=runtime_model,
|
|
||||||
messages=messages,
|
messages=messages,
|
||||||
funcs=funcs,
|
funcs=None,
|
||||||
extra_args=extra_args,
|
extra_args=extra_args,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -300,11 +561,6 @@ class LLMCallNode(WorkflowNode):
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Extract usage info
|
# Extract usage info
|
||||||
usage = {
|
|
||||||
'prompt_tokens': 0,
|
|
||||||
'completion_tokens': 0,
|
|
||||||
'total_tokens': 0,
|
|
||||||
}
|
|
||||||
if hasattr(result_message, 'usage') and result_message.usage:
|
if hasattr(result_message, 'usage') and result_message.usage:
|
||||||
u = result_message.usage
|
u = result_message.usage
|
||||||
usage = {
|
usage = {
|
||||||
@@ -320,10 +576,20 @@ class LLMCallNode(WorkflowNode):
|
|||||||
'total_tokens': getattr(u, 'total_tokens', 0) or 0,
|
'total_tokens': getattr(u, 'total_tokens', 0) or 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Save to conversation history
|
||||||
|
self._save_to_conversation_history(
|
||||||
|
context=context,
|
||||||
|
user_message_text=user_prompt,
|
||||||
|
response_text=response_text,
|
||||||
|
max_round=max_round,
|
||||||
|
)
|
||||||
|
|
||||||
# Build result
|
# Build result
|
||||||
result: dict[str, Any] = {
|
result: dict[str, Any] = {
|
||||||
'response': response_text,
|
'response': response_text,
|
||||||
'usage': usage,
|
'usage': usage,
|
||||||
|
'model_used': used_model.model_entity.name if used_model else None,
|
||||||
|
'model_uuid': used_model.model_entity.uuid if used_model else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse JSON output if format is json
|
# Parse JSON output if format is json
|
||||||
@@ -359,18 +625,31 @@ class LLMCallNode(WorkflowNode):
|
|||||||
exception_handling = self.get_config('exception_handling', 'show-error')
|
exception_handling = self.get_config('exception_handling', 'show-error')
|
||||||
failure_hint = self.get_config('failure_hint', 'Request failed.')
|
failure_hint = self.get_config('failure_hint', 'Request failed.')
|
||||||
|
|
||||||
# Resolve prompts
|
# Resolve prompts - support both new prompt array format and legacy format
|
||||||
system_prompt = self._resolve_template(self.get_config('system_prompt') or '', inputs, context)
|
prompt_array = self.get_config('prompt')
|
||||||
user_prompt_template = self.get_config('user_prompt_template')
|
if prompt_array and isinstance(prompt_array, list):
|
||||||
if user_prompt_template is None:
|
# New format: prompt array like pipeline
|
||||||
user_prompt_template = '{{input}}'
|
messages = self._build_messages_from_prompt_array(
|
||||||
user_prompt = self._resolve_template(user_prompt_template, inputs, context)
|
prompt_array, inputs, context, 'text', '' # No format instructions for streaming
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add user input
|
||||||
|
user_input = inputs.get('input', '')
|
||||||
|
if user_input:
|
||||||
|
messages.append(provider_message.Message(role='user', content=user_input))
|
||||||
|
else:
|
||||||
|
# Legacy format
|
||||||
|
system_prompt = self._resolve_template(self.get_config('system_prompt') or '', inputs, context)
|
||||||
|
user_prompt_template = self.get_config('user_prompt_template')
|
||||||
|
if user_prompt_template is None:
|
||||||
|
user_prompt_template = '{{input}}'
|
||||||
|
user_prompt = self._resolve_template(user_prompt_template, inputs, context)
|
||||||
|
|
||||||
# Build messages
|
# Build messages
|
||||||
messages: list[provider_message.Message] = []
|
messages = []
|
||||||
if system_prompt:
|
if system_prompt:
|
||||||
messages.append(provider_message.Message(role='system', content=system_prompt))
|
messages.append(provider_message.Message(role='system', content=system_prompt))
|
||||||
messages.append(provider_message.Message(role='user', content=user_prompt))
|
messages.append(provider_message.Message(role='user', content=user_prompt))
|
||||||
|
|
||||||
# Get model
|
# Get model
|
||||||
runtime_model = await self.ap.model_mgr.get_model_by_uuid(model_uuid)
|
runtime_model = await self.ap.model_mgr.get_model_by_uuid(model_uuid)
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# Call Workflow Node Configuration
|
||||||
|
name: call_workflow
|
||||||
|
label:
|
||||||
|
en_US: Call Workflow
|
||||||
|
zh_Hans: 调用工作流
|
||||||
|
category: action
|
||||||
|
icon: Workflow
|
||||||
|
color: '#8b5cf6'
|
||||||
|
description:
|
||||||
|
en_US: Invoke an existing Workflow for processing
|
||||||
|
zh_Hans: 调用现有的工作流进行处理
|
||||||
|
|
||||||
|
inputs:
|
||||||
|
- name: variables
|
||||||
|
type: object
|
||||||
|
label:
|
||||||
|
en_US: Variables
|
||||||
|
zh_Hans: 变量
|
||||||
|
description:
|
||||||
|
en_US: Variables to pass to the called workflow
|
||||||
|
zh_Hans: 传递给被调用工作流的变量
|
||||||
|
required: false
|
||||||
|
|
||||||
|
outputs:
|
||||||
|
- name: result
|
||||||
|
type: object
|
||||||
|
label:
|
||||||
|
en_US: Result
|
||||||
|
zh_Hans: 结果
|
||||||
|
description:
|
||||||
|
en_US: Workflow execution result (ExecutionContext)
|
||||||
|
zh_Hans: 工作流执行结果(执行上下文)
|
||||||
|
- name: status
|
||||||
|
type: string
|
||||||
|
label:
|
||||||
|
en_US: Status
|
||||||
|
zh_Hans: 状态
|
||||||
|
description:
|
||||||
|
en_US: Workflow execution status
|
||||||
|
zh_Hans: 工作流执行状态
|
||||||
|
- name: error
|
||||||
|
type: string
|
||||||
|
label:
|
||||||
|
en_US: Error
|
||||||
|
zh_Hans: 错误
|
||||||
|
description:
|
||||||
|
en_US: Error message if execution failed
|
||||||
|
zh_Hans: 执行失败时的错误信息
|
||||||
|
|
||||||
|
config:
|
||||||
|
- name: workflow_uuid
|
||||||
|
type: workflow-selector
|
||||||
|
required: true
|
||||||
|
label:
|
||||||
|
en_US: Workflow
|
||||||
|
zh_Hans: 工作流
|
||||||
|
description:
|
||||||
|
en_US: Workflow to call
|
||||||
|
zh_Hans: 要调用的工作流
|
||||||
|
|
||||||
|
- name: inherit_variables
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
label:
|
||||||
|
en_US: Inherit Variables
|
||||||
|
zh_Hans: 继承变量
|
||||||
|
description:
|
||||||
|
en_US: Whether to inherit current workflow variables
|
||||||
|
zh_Hans: 是否继承当前工作流的变量
|
||||||
|
|
||||||
|
- name: timeout
|
||||||
|
type: integer
|
||||||
|
default: 300
|
||||||
|
label:
|
||||||
|
en_US: Timeout (seconds)
|
||||||
|
zh_Hans: 超时时间(秒)
|
||||||
|
description:
|
||||||
|
en_US: Timeout in seconds
|
||||||
|
zh_Hans: 超时时间(秒)
|
||||||
@@ -70,25 +70,73 @@ config:
|
|||||||
en_US: Select the LLM model to use
|
en_US: Select the LLM model to use
|
||||||
zh_Hans: 选择要使用的 LLM 模型
|
zh_Hans: 选择要使用的 LLM 模型
|
||||||
|
|
||||||
- name: system_prompt
|
- name: fallback_models
|
||||||
type: textarea
|
type: model-fallback-selector
|
||||||
|
required: false
|
||||||
|
default: []
|
||||||
label:
|
label:
|
||||||
en_US: System Prompt
|
en_US: Fallback Models
|
||||||
zh_Hans: 系统提示词
|
zh_Hans: 备用模型
|
||||||
description:
|
description:
|
||||||
en_US: System prompt to set the model behavior
|
en_US: List of fallback models to try if the primary model fails
|
||||||
zh_Hans: 设置模型行为的系统提示词
|
zh_Hans: 主模型失败时尝试的备用模型列表
|
||||||
|
|
||||||
- name: user_prompt_template
|
- name: prompt
|
||||||
type: textarea
|
|
||||||
required: true
|
|
||||||
default: "{{input}}"
|
|
||||||
label:
|
label:
|
||||||
en_US: User Prompt Template
|
en_US: Prompt
|
||||||
zh_Hans: 用户提示词模板
|
zh_Hans: 提示词
|
||||||
description:
|
description:
|
||||||
en_US: User prompt template with variable placeholders
|
en_US: Unless you understand the message structure, please only use a single system prompt
|
||||||
zh_Hans: 带有变量占位符的用户提示词模板
|
zh_Hans: 除非您了解消息结构,否则请只使用 system 单提示词
|
||||||
|
type: prompt-editor
|
||||||
|
required: true
|
||||||
|
default:
|
||||||
|
- role: system
|
||||||
|
content: "You are a helpful assistant."
|
||||||
|
|
||||||
|
- name: max_round
|
||||||
|
type: integer
|
||||||
|
required: false
|
||||||
|
default: 10
|
||||||
|
label:
|
||||||
|
en_US: Max Round
|
||||||
|
zh_Hans: 最大回合数
|
||||||
|
description:
|
||||||
|
en_US: The maximum number of previous messages that the agent can remember
|
||||||
|
zh_Hans: 最大前文消息回合数
|
||||||
|
|
||||||
|
- name: knowledge_bases
|
||||||
|
type: knowledge-base-multi-selector
|
||||||
|
required: false
|
||||||
|
default: []
|
||||||
|
label:
|
||||||
|
en_US: Knowledge Bases
|
||||||
|
zh_Hans: 知识库
|
||||||
|
description:
|
||||||
|
en_US: Configure the knowledge bases to use for the agent, if not selected, the agent will directly use the LLM to reply
|
||||||
|
zh_Hans: 配置用于提升回复质量的知识库,若不选择,则直接使用大模型回复
|
||||||
|
|
||||||
|
- name: rerank_model
|
||||||
|
type: rerank-model-selector
|
||||||
|
required: false
|
||||||
|
default: ''
|
||||||
|
label:
|
||||||
|
en_US: Rerank Model
|
||||||
|
zh_Hans: 重排序模型
|
||||||
|
description:
|
||||||
|
en_US: Optional rerank model to improve retrieval quality by re-scoring retrieved chunks
|
||||||
|
zh_Hans: 可选的重排序模型,通过重新评分检索结果来提升检索质量
|
||||||
|
|
||||||
|
- name: rerank_top_k
|
||||||
|
type: integer
|
||||||
|
required: false
|
||||||
|
default: 5
|
||||||
|
label:
|
||||||
|
en_US: Rerank Top K
|
||||||
|
zh_Hans: 重排序保留数量
|
||||||
|
description:
|
||||||
|
en_US: Number of top results to keep after reranking
|
||||||
|
zh_Hans: 重排序后保留的最相关结果数量
|
||||||
|
|
||||||
- name: temperature
|
- name: temperature
|
||||||
type: number
|
type: number
|
||||||
@@ -134,26 +182,6 @@ config:
|
|||||||
en_US: JSON schema for structured output validation
|
en_US: JSON schema for structured output validation
|
||||||
zh_Hans: 用于结构化输出验证的 JSON Schema
|
zh_Hans: 用于结构化输出验证的 JSON Schema
|
||||||
|
|
||||||
- name: enable_tools
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
label:
|
|
||||||
en_US: Enable Tools
|
|
||||||
zh_Hans: 启用工具
|
|
||||||
description:
|
|
||||||
en_US: Allow the model to use function calling tools
|
|
||||||
zh_Hans: 允许模型使用函数调用工具
|
|
||||||
|
|
||||||
- name: tools
|
|
||||||
type: json
|
|
||||||
default: []
|
|
||||||
label:
|
|
||||||
en_US: Tools
|
|
||||||
zh_Hans: 工具
|
|
||||||
description:
|
|
||||||
en_US: Select tools that the model can use
|
|
||||||
zh_Hans: 选择模型可以使用的工具
|
|
||||||
|
|
||||||
- name: exception_handling
|
- name: exception_handling
|
||||||
type: select
|
type: select
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
|
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
|
||||||
|
import PromptEditorComponent from '@/app/home/components/dynamic-form/PromptEditorComponent';
|
||||||
|
|
||||||
const resolveOptionLabel = (label: unknown, fallback: string): string => {
|
const resolveOptionLabel = (label: unknown, fallback: string): string => {
|
||||||
if (!label || typeof label !== 'object') return fallback;
|
if (!label || typeof label !== 'object') return fallback;
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface PromptEntry {
|
||||||
|
role: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PromptEditorProps {
|
||||||
|
value: PromptEntry[];
|
||||||
|
onChange: (value: PromptEntry[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROLE_OPTIONS = [
|
||||||
|
{ value: 'system', label: 'System' },
|
||||||
|
{ value: 'user', label: 'User' },
|
||||||
|
{ value: 'assistant', label: 'Assistant' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PromptEditorComponent({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: PromptEditorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [entries, setEntries] = useState<PromptEntry[]>(
|
||||||
|
Array.isArray(value) && value.length > 0
|
||||||
|
? value
|
||||||
|
: [{ role: 'system', content: '' }],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync with external value changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (Array.isArray(value) && value.length > 0) {
|
||||||
|
setEntries(value);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const updateEntries = (newEntries: PromptEntry[]) => {
|
||||||
|
setEntries(newEntries);
|
||||||
|
onChange(newEntries);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoleChange = (index: number, role: string) => {
|
||||||
|
const newEntries = [...entries];
|
||||||
|
newEntries[index] = { ...newEntries[index], role };
|
||||||
|
updateEntries(newEntries);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContentChange = (index: number, content: string) => {
|
||||||
|
const newEntries = [...entries];
|
||||||
|
newEntries[index] = { ...newEntries[index], content };
|
||||||
|
updateEntries(newEntries);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddEntry = () => {
|
||||||
|
updateEntries([...entries, { role: 'system', content: '' }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveEntry = (index: number) => {
|
||||||
|
if (entries.length <= 1) return;
|
||||||
|
const newEntries = entries.filter((_, i) => i !== index);
|
||||||
|
updateEntries(newEntries);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 w-full">
|
||||||
|
{entries.map((entry, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex gap-2 items-start p-3 rounded-lg border bg-card"
|
||||||
|
>
|
||||||
|
<div className="w-32 flex-shrink-0">
|
||||||
|
<Select
|
||||||
|
value={entry.role}
|
||||||
|
onValueChange={(role) => handleRoleChange(index, role)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ROLE_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Textarea
|
||||||
|
value={entry.content}
|
||||||
|
onChange={(e) => handleContentChange(index, e.target.value)}
|
||||||
|
placeholder={t('workflows.promptContentPlaceholder', 'Enter prompt content...')}
|
||||||
|
className="min-h-[80px] resize-y"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 text-muted-foreground hover:text-destructive mt-1"
|
||||||
|
onClick={() => handleRemoveEntry(index)}
|
||||||
|
disabled={entries.length <= 1}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-dashed text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={handleAddEntry}
|
||||||
|
>
|
||||||
|
<Plus className="size-4 mr-1.5" />
|
||||||
|
{t('workflows.addPromptEntry', 'Add Prompt Entry')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -55,7 +55,6 @@ export default function WorkflowDetailContent({ id }: { id: string }) {
|
|||||||
|
|
||||||
const [activeTab, setActiveTab] = useState('editor');
|
const [activeTab, setActiveTab] = useState('editor');
|
||||||
const [workflow, setWorkflow] = useState<Workflow | null>(null);
|
const [workflow, setWorkflow] = useState<Workflow | null>(null);
|
||||||
const [createStep, setCreateStep] = useState<'basic' | 'editor'>('basic');
|
|
||||||
const [basicInfo, setBasicInfo] = useState<{
|
const [basicInfo, setBasicInfo] = useState<{
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -63,7 +62,7 @@ export default function WorkflowDetailContent({ id }: { id: string }) {
|
|||||||
}>({
|
}>({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
emoji: '🔄',
|
emoji: '💼',
|
||||||
});
|
});
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
|
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
|
||||||
@@ -136,8 +135,8 @@ export default function WorkflowDetailContent({ id }: { id: string }) {
|
|||||||
name: basicInfo.name || t('workflows.newWorkflow'),
|
name: basicInfo.name || t('workflows.newWorkflow'),
|
||||||
description: basicInfo.description,
|
description: basicInfo.description,
|
||||||
emoji: basicInfo.emoji,
|
emoji: basicInfo.emoji,
|
||||||
nodes,
|
nodes: [],
|
||||||
edges,
|
edges: [],
|
||||||
});
|
});
|
||||||
refreshWorkflows();
|
refreshWorkflows();
|
||||||
navigate(`/home/workflows?id=${encodeURIComponent(resp.uuid)}`);
|
navigate(`/home/workflows?id=${encodeURIComponent(resp.uuid)}`);
|
||||||
@@ -330,7 +329,7 @@ export default function WorkflowDetailContent({ id }: { id: string }) {
|
|||||||
}, [workflow, refreshWorkflows, navigate, t]);
|
}, [workflow, refreshWorkflows, navigate, t]);
|
||||||
|
|
||||||
// ==================== Create Mode ====================
|
// ==================== Create Mode ====================
|
||||||
if (isCreateMode && createStep === 'basic') {
|
if (isCreateMode) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||||
@@ -352,11 +351,8 @@ export default function WorkflowDetailContent({ id }: { id: string }) {
|
|||||||
<Upload className="size-4 mr-1" />
|
<Upload className="size-4 mr-1" />
|
||||||
{t('workflows.import')}
|
{t('workflows.import')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button onClick={handleSave} disabled={isSaving || !basicInfo.name.trim()}>
|
||||||
onClick={() => setCreateStep('editor')}
|
{isSaving ? t('common.saving') : t('common.create')}
|
||||||
disabled={!basicInfo.name.trim()}
|
|
||||||
>
|
|
||||||
{t('common.next')}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -417,30 +413,6 @@ export default function WorkflowDetailContent({ id }: { id: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isCreateMode) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
|
||||||
<h1 className="text-xl font-semibold">
|
|
||||||
{t('workflows.createWorkflow')}
|
|
||||||
</h1>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="outline" onClick={() => setCreateStep('basic')}>
|
|
||||||
{t('common.back')}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} disabled={isSaving}>
|
|
||||||
{isSaving ? t('common.saving') : t('common.create')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-h-0">
|
|
||||||
<WorkflowEditorComponent />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Edit Mode ====================
|
// ==================== Edit Mode ====================
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
|
|||||||
@@ -457,6 +457,7 @@ function WorkflowEditorInner() {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
deleteKeyCode={null} // We handle delete manually
|
deleteKeyCode={null} // We handle delete manually
|
||||||
|
// proOptions={{ hideAttribution: true }} Fack React Flow , we will never give you money, stop asking me to pay for this amazing library that I use for free and contribute to open source.
|
||||||
>
|
>
|
||||||
<Background
|
<Background
|
||||||
gap={15}
|
gap={15}
|
||||||
@@ -696,3 +697,4 @@ export default function WorkflowEditorComponent() {
|
|||||||
</ReactFlowProvider>
|
</ReactFlowProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user