diff --git a/src/langbot/pkg/api/http/controller/groups/workflows/workflows.py b/src/langbot/pkg/api/http/controller/groups/workflows/workflows.py index ccce2a87..6f9ac036 100644 --- a/src/langbot/pkg/api/http/controller/groups/workflows/workflows.py +++ b/src/langbot/pkg/api/http/controller/groups/workflows/workflows.py @@ -323,18 +323,16 @@ class WorkflowsRouterGroup(group.RouterGroup): "model_uuid": "uuid-of-model", "system_prompt": "optional system prompt", "user_prompt": "test message", - "enable_tools": false, - "tools": [], "temperature": 0.7, "max_tokens": 100 } Response includes timing for each step: - model_fetch: Time to get model from model_mgr - - tool_fetch: Time to load tools (if enabled) - prompt_build: Time to build messages - llm_call: Time for actual LLM invocation - total: Total time + - usage: Token usage information """ import time @@ -348,8 +346,6 @@ class WorkflowsRouterGroup(group.RouterGroup): user_prompt = json_data.get('user_prompt', 'test') 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') max_tokens = json_data.get('max_tokens', 0) @@ -372,24 +368,7 @@ class WorkflowsRouterGroup(group.RouterGroup): 'timings': timings, }) - # Step 2: Tool fetch (if enabled) - 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 + # Step 2: Build messages t_start = time.perf_counter() import langbot_plugin.api.entities.builtin.provider.message as provider_message messages = [] @@ -398,21 +377,21 @@ class WorkflowsRouterGroup(group.RouterGroup): messages.append(provider_message.Message(role='user', content=user_prompt)) timings['prompt_build_ms'] = round((time.perf_counter() - t_start) * 1000, 2) - # Step 4: Build extra args + # Step 3: Build extra args extra_args = {} if temperature is not None: extra_args['temperature'] = float(temperature) if max_tokens and int(max_tokens) > 0: extra_args['max_tokens'] = int(max_tokens) - # Step 5: LLM call + # Step 4: LLM call t_start = time.perf_counter() try: result_message = await runtime_model.provider.invoke_llm( query=None, model=runtime_model, messages=messages, - funcs=funcs if enable_tools else None, + funcs=None, extra_args=extra_args, ) timings['llm_call_ms'] = round((time.perf_counter() - t_start) * 1000, 2) @@ -451,7 +430,6 @@ class WorkflowsRouterGroup(group.RouterGroup): # Calculate total timings['total_ms'] = round(sum([ timings.get('model_fetch_ms', 0), - timings.get('tool_fetch_ms', 0), timings.get('prompt_build_ms', 0), timings.get('llm_call_ms', 0), ]), 2) @@ -460,7 +438,6 @@ class WorkflowsRouterGroup(group.RouterGroup): if timings['total_ms'] > 0: timings['breakdown'] = { '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), 'llm_call_pct': round(timings.get('llm_call_ms', 0) / timings['total_ms'] * 100, 1), } diff --git a/src/langbot/pkg/api/http/service/workflow.py b/src/langbot/pkg/api/http/service/workflow.py index 4da916d9..77297fa6 100644 --- a/src/langbot/pkg/api/http/service/workflow.py +++ b/src/langbot/pkg/api/http/service/workflow.py @@ -117,7 +117,7 @@ class WorkflowService: 'uuid': workflow_uuid, 'name': workflow_data.get('name', 'New Workflow'), 'description': workflow_data.get('description', ''), - 'emoji': workflow_data.get('emoji', '🔄'), + 'emoji': workflow_data.get('emoji', '💼'), 'version': 1, 'is_enabled': workflow_data.get('is_enabled', True), 'definition': workflow_data.get( diff --git a/src/langbot/pkg/workflow/entities.py b/src/langbot/pkg/workflow/entities.py index bda59a27..93d6a8f4 100644 --- a/src/langbot/pkg/workflow/entities.py +++ b/src/langbot/pkg/workflow/entities.py @@ -120,7 +120,7 @@ class WorkflowDefinition(pydantic.BaseModel): uuid: str name: str description: str = '' - emoji: str = '🔄' + emoji: str = '💼' version: int = 1 # Workflow graph diff --git a/src/langbot/pkg/workflow/nodes/__init__.py b/src/langbot/pkg/workflow/nodes/__init__.py index 03f82cbf..51798995 100644 --- a/src/langbot/pkg/workflow/nodes/__init__.py +++ b/src/langbot/pkg/workflow/nodes/__init__.py @@ -30,6 +30,7 @@ from . import variable_aggregator from . import send_message from . import reply_message from . import call_pipeline +from . import call_workflow from . import store_data from . import set_variable from . import opening_statement @@ -74,6 +75,7 @@ __all__ = [ 'send_message', 'reply_message', 'call_pipeline', + 'call_workflow', 'store_data', 'set_variable', 'opening_statement', diff --git a/src/langbot/pkg/workflow/nodes/call_workflow.py b/src/langbot/pkg/workflow/nodes/call_workflow.py new file mode 100644 index 00000000..e3c21f9f --- /dev/null +++ b/src/langbot/pkg/workflow/nodes/call_workflow.py @@ -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'), + } diff --git a/src/langbot/pkg/workflow/nodes/http_request.py b/src/langbot/pkg/workflow/nodes/http_request.py index 10cd9460..0bfde443 100644 --- a/src/langbot/pkg/workflow/nodes/http_request.py +++ b/src/langbot/pkg/workflow/nodes/http_request.py @@ -7,7 +7,6 @@ from __future__ import annotations import ipaddress import logging -import re from typing import Any from urllib.parse import urlparse diff --git a/src/langbot/pkg/workflow/nodes/llm_call.py b/src/langbot/pkg/workflow/nodes/llm_call.py index df449b70..512b34de 100644 --- a/src/langbot/pkg/workflow/nodes/llm_call.py +++ b/src/langbot/pkg/workflow/nodes/llm_call.py @@ -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 @@ -8,7 +15,7 @@ import re from typing import Any, AsyncGenerator 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 ..node import WorkflowNode, workflow_node @@ -133,29 +140,20 @@ class LLMCallNode(WorkflowNode): return text, False, '' - def _parse_tools_config(self, tools_config: Any) -> list[dict]: - """Parse tools configuration from YAML config format.""" - if not tools_config: - return [] + # RAG combined prompt template (same as localagent.py) + RAG_COMBINED_PROMPT_TEMPLATE = """ +The following are relevant context entries retrieved from the knowledge base. +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 - if isinstance(tools_config, list): - return tools_config + +{rag_context} + - # If it's a string, try to parse as JSON - if isinstance(tools_config, str): - tools_config = tools_config.strip() - 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 [] + +{user_message} + +""" def _build_system_prompt_with_format(self, base_prompt: str, output_format: str, json_schema: str) -> str: """Build system prompt with output format instructions.""" @@ -170,6 +168,220 @@ class LLMCallNode(WorkflowNode): 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]: model_uuid = self.get_config('model', '') if not model_uuid: @@ -187,28 +399,90 @@ class LLMCallNode(WorkflowNode): output_format = self.get_config('output_format', 'text') json_schema = self.get_config('json_schema', '') - # Get tools config - enable_tools = self.get_config('enable_tools', False) - tools_config = self.get_config('tools', []) + # Agent config: fallback models, knowledge bases, rerank, max_round + fallback_models = self.get_config('fallback_models', []) + 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 - 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) + # Resolve prompts - support both new prompt array format and legacy format + prompt_array = self.get_config('prompt') + user_prompt = '' # Initialize for later use in _save_to_conversation_history + + if prompt_array and isinstance(prompt_array, list): + # 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 - system_prompt = self._build_system_prompt_with_format(system_prompt, output_format, json_schema) + # Build system prompt with format instructions + system_prompt = self._build_system_prompt_with_format(system_prompt, output_format, json_schema) - # Build messages - messages: list[provider_message.Message] = [] - if system_prompt: - messages.append(provider_message.Message(role='system', content=system_prompt)) - messages.append(provider_message.Message(role='user', content=user_prompt)) + # Knowledge retrieval: enhance user prompt with RAG context + user_prompt = await self._retrieve_knowledge( + user_message_text=user_prompt, + knowledge_bases=knowledge_bases, + rerank_model_uuid=rerank_model, + rerank_top_k=rerank_top_k, + ) - # Get model - runtime_model = await self.ap.model_mgr.get_model_by_uuid(model_uuid) + # Build messages with conversation history + 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 extra_args: dict[str, Any] = {} @@ -219,25 +493,12 @@ class LLMCallNode(WorkflowNode): if max_tokens and int(max_tokens) > 0: extra_args['max_tokens'] = int(max_tokens) - # Build tools list if enabled - 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 + # Invoke LLM with fallback try: - result_message = await runtime_model.provider.invoke_llm( - query=None, - model=runtime_model, + result_message, used_model = await self._invoke_with_fallback( + candidates=candidates, messages=messages, - funcs=funcs, + funcs=None, extra_args=extra_args, ) except Exception as e: @@ -300,11 +561,6 @@ class LLMCallNode(WorkflowNode): } # Extract usage info - usage = { - 'prompt_tokens': 0, - 'completion_tokens': 0, - 'total_tokens': 0, - } if hasattr(result_message, 'usage') and result_message.usage: u = result_message.usage usage = { @@ -320,10 +576,20 @@ class LLMCallNode(WorkflowNode): '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 result: dict[str, Any] = { 'response': response_text, '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 @@ -359,18 +625,31 @@ class LLMCallNode(WorkflowNode): exception_handling = self.get_config('exception_handling', 'show-error') failure_hint = self.get_config('failure_hint', 'Request failed.') - # Resolve prompts - 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) + # Resolve prompts - support both new prompt array format and legacy format + prompt_array = self.get_config('prompt') + if prompt_array and isinstance(prompt_array, list): + # New format: prompt array like pipeline + messages = self._build_messages_from_prompt_array( + 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 - messages: list[provider_message.Message] = [] - if system_prompt: - messages.append(provider_message.Message(role='system', content=system_prompt)) - messages.append(provider_message.Message(role='user', content=user_prompt)) + # Build messages + messages = [] + if system_prompt: + messages.append(provider_message.Message(role='system', content=system_prompt)) + messages.append(provider_message.Message(role='user', content=user_prompt)) # Get model runtime_model = await self.ap.model_mgr.get_model_by_uuid(model_uuid) diff --git a/src/langbot/templates/metadata/nodes/call_workflow.yaml b/src/langbot/templates/metadata/nodes/call_workflow.yaml new file mode 100644 index 00000000..62e79e86 --- /dev/null +++ b/src/langbot/templates/metadata/nodes/call_workflow.yaml @@ -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: 超时时间(秒) diff --git a/src/langbot/templates/metadata/nodes/llm_call.yaml b/src/langbot/templates/metadata/nodes/llm_call.yaml index b3f7f3ec..06872f0b 100644 --- a/src/langbot/templates/metadata/nodes/llm_call.yaml +++ b/src/langbot/templates/metadata/nodes/llm_call.yaml @@ -70,25 +70,73 @@ config: en_US: Select the LLM model to use zh_Hans: 选择要使用的 LLM 模型 - - name: system_prompt - type: textarea + - name: fallback_models + type: model-fallback-selector + required: false + default: [] label: - en_US: System Prompt - zh_Hans: 系统提示词 + en_US: Fallback Models + zh_Hans: 备用模型 description: - en_US: System prompt to set the model behavior - zh_Hans: 设置模型行为的系统提示词 + en_US: List of fallback models to try if the primary model fails + zh_Hans: 主模型失败时尝试的备用模型列表 - - name: user_prompt_template - type: textarea - required: true - default: "{{input}}" + - name: prompt label: - en_US: User Prompt Template - zh_Hans: 用户提示词模板 + en_US: Prompt + zh_Hans: 提示词 description: - en_US: User prompt template with variable placeholders - zh_Hans: 带有变量占位符的用户提示词模板 + en_US: Unless you understand the message structure, please only use a single system prompt + 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 type: number @@ -134,26 +182,6 @@ config: en_US: JSON schema for structured output validation 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 type: select required: true diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx index ec7ffd66..c7e59a48 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx @@ -68,6 +68,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; 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 => { if (!label || typeof label !== 'object') return fallback; diff --git a/web/src/app/home/components/dynamic-form/PromptEditorComponent.tsx b/web/src/app/home/components/dynamic-form/PromptEditorComponent.tsx new file mode 100644 index 00000000..d62bfa46 --- /dev/null +++ b/web/src/app/home/components/dynamic-form/PromptEditorComponent.tsx @@ -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( + 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 ( +
+ {entries.map((entry, index) => ( +
+
+ +
+
+