File size: 53,389 Bytes
6da685f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60d32b2
6da685f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
"""
N8N-Inspired AI Automation Workflow Builder using Streamlit and LangGraph
"""

import streamlit as st
import uuid
import os
import re
from typing import List, Dict, Any, Optional, Sequence, TypedDict as TypingTypedDict # Use typing's TypedDict
from datetime import datetime
import json

# --- LangGraph & Tool Imports ---
try:
    from langgraph.graph import StateGraph, START, END
    from langchain_openai import ChatOpenAI
    from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, SystemMessage
    from langchain_core.tools import BaseTool

    LANGGRAPH_AVAILABLE = True
except ImportError as e:
    st.error(
        f"Required libraries not found: {e}. Please install them: \n"
        "`pip install streamlit streamlit-agraph langchain-openai langgraph typing-extensions regex langchain-core`"
    )
    LANGGRAPH_AVAILABLE = False
    # --- DUMMY DEFINITIONS ---
    class BaseMessage: pass
    class HumanMessage(BaseMessage): pass
    class AIMessage(BaseMessage): pass
    class SystemMessage(BaseMessage): pass
    class TypedDict(TypingTypedDict): pass
    def StateGraph(state): return None
    class BaseTool:
        name: str = "dummy_tool"; description: str = "A dummy tool"
        def _run(self, *args: Any, **kwargs: Any) -> Any: pass
        async def _arun(self, *args: Any, **kwargs: Any) -> Any: pass
    START, END = "START", "END"
    # --- END DUMMY DEFINITIONS ---
    st.stop()

# --- Streamlit Agraph Imports ---
try:
    from streamlit_agraph import agraph, Node, Edge, Config
    AGRAPH_AVAILABLE = True
except ImportError:
    st.warning("`streamlit-agraph` not found. Install: `pip install streamlit-agraph`")
    AGRAPH_AVAILABLE = False
    def agraph(*args, **kwargs): pass
    class Node:
        def __init__(self, id, label, **kwargs): self.id = id; self.label = label
    class Edge:
        def __init__(self, source, target, **kwargs): self.source = source; self.target = target
    class Config:
        def __init__(self, **kwargs): pass

# --- Constants ---
ROUTING_KEY_MARKER = "ROUTING_KEY:"
DEFAULT_ROUTING_KEY = "__DEFAULT__"
START_NODE_ID = "__START__"
END_NODE_ID = END

# --- Tool Definition (Using web_search_preview dictionary) ---
web_search_tool_dict = {"type": "web_search_preview"}
tools_list_for_binding = [web_search_tool_dict]

# --- LangGraph State Definition (Dictionary Based) ---
class WorkflowState(TypingTypedDict):
    input: str
    node_outputs: Dict[str, str]
    last_response_content: str
    current_node_id: str

# --- LLM Initialization ---
llm: Optional[ChatOpenAI] = None
llm_with_search: Optional[Any] = None

def initialize_llm() -> bool:
    global llm, llm_with_search
    openai_api_key = os.environ.get("OPENAI_API_KEY")
    openai_ready = bool(openai_api_key)
    if openai_ready and (llm is None or getattr(llm, 'openai_api_key', None) != openai_api_key):
        try:
            base_llm = ChatOpenAI(model_name="gpt-4o", openai_api_key=openai_api_key, temperature=0.2)
            llm = base_llm; llm_with_search = base_llm.bind_tools(tools_list_for_binding)
            print(f"LLM initialized (gpt-4o). Bound: {web_search_tool_dict}")
            return True
        except Exception as e: st.error(f"LLM Init Error: {e}", icon="πŸ”₯"); llm = None; llm_with_search = None; return False
    elif not openai_ready:
        if llm is not None: print("Clearing LLM."); llm = None; llm_with_search = None
        return False
    return llm is not None

# --- Helper Function ---
def get_node_display_name(node_id: str) -> str:
    if node_id == START_NODE_ID: return "⏹️ START"
    if node_id == END_NODE_ID: return "🏁 END"
    if "nodes" in st.session_state and isinstance(st.session_state.nodes, list):
        for i, node in enumerate(st.session_state.nodes):
            if isinstance(node, dict) and node.get("id") == node_id: return f"{i+1}. {node.get('name', f'Unk ({node_id})')}"
    return f"Unknown ({node_id})"


# --- Node Execution Function Factory (FIXED CONTENT EXTRACTION) ---
def create_agent_node_function(node_id: str, node_name: str, node_prompt: str, possible_keys: List[str]):
    """ Node function (dict state) with corrected content extraction. """
    def agent_node_function(state: WorkflowState) -> WorkflowState:
        print(f"\n--- Executing Node: {node_name} ({node_id}) ---")
        if "execution_log" not in st.session_state: st.session_state.execution_log = []
        st.session_state.execution_log.append(f"βš™οΈ Executing Node: **{node_name}**")

        if not llm_with_search: # Error handling for LLM init
            error_msg = f"ERROR: LLM not initialized."; st.session_state.execution_log.append(f"  -> ❌ Error: {error_msg}")
            updated_state = state.copy(); updated_state["last_response_content"] = f"{error_msg} {ROUTING_KEY_MARKER} error"; updated_state["current_node_id"] = node_id; return updated_state

        # --- Prepare Context ---
        context_input = ""; is_first_node = not state.get("last_response_content")
        if is_first_node:
            context_input = state.get("input", ""); print(f"DEBUG: First node, input: '{context_input[:100]}...'")
        else:
            prev_content = state.get("last_response_content", ""); print(f"DEBUG: Prev content type: {type(prev_content)}")
            if isinstance(prev_content, str): context_input = re.sub(rf"\s*{ROUTING_KEY_MARKER}\s*\w+\s*$", "", prev_content).strip()
            else: print("ERROR: Prev content not str"); context_input = str(prev_content) # Fallback
            print(f"DEBUG: Context input: '{context_input[:100]}...'")

        # --- Prepare Prompt ---
        prompt_with_context = node_prompt
        if '{input_text}' in node_prompt: prompt_with_context = node_prompt.replace('{input_text}', context_input)
        elif context_input: prompt_with_context += f"\n\nInput Context:\n{context_input}"
        current_task_prompt = f"Current Task ({node_name}):\n{prompt_with_context}\n(Search web if needed)."
        key_options_text = ", ".join(f"'{k}'" for k in possible_keys if k and k != DEFAULT_ROUTING_KEY)
        routing_instruction = f"\n\n--- ROUTING ---\nAfter response, MUST end with '{ROUTING_KEY_MARKER} <key>' (e.g., from [{key_options_text}]).\n--- END ROUTING ---"
        full_prompt = current_task_prompt + routing_instruction
        print(f"DEBUG: Full prompt type: {type(full_prompt)}"); print(f"Node '{node_name}' sending prompt: {full_prompt[:300]}..."); st.session_state.execution_log.append(f"  πŸ“ Prompt Snippet: {prompt_with_context[:100]}...")

        try:
            # --- Invoke LLM ---
            result = llm_with_search.invoke(full_prompt)
            response_content = "" # Default empty

            # --- *** CORRECTED CONTENT EXTRACTION *** ---
            if hasattr(result, 'content'):
                raw_content = result.content
                if isinstance(raw_content, str):
                    response_content = raw_content # It was already a string
                elif isinstance(raw_content, list) and len(raw_content) > 0 and isinstance(raw_content[0], dict) and 'text' in raw_content[0]:
                    # Standard case for gpt-4o list output: extract text from first block
                    response_content = raw_content[0].get('text', '') # Use .get for safety
                    print("DEBUG: Extracted text from result.content[0]['text']")
                else: # Handle unexpected formats
                    print(f"WARNING: Unexpected format for result.content: {type(raw_content)}. Trying str().")
                    response_content = str(raw_content) # Fallback
            else: print("WARNING: result has no 'content' attribute.")
            # --- *** END CORRECTION *** ---

            # --- Debugging & Logging ---
            print(f"\nDEBUG: Raw result type: {type(result)}")
            # print(f"DEBUG: Raw result value: {result}") # Can be very verbose
            print(f"DEBUG: response_content type: {type(response_content)}") # Should be str now
            print(f"DEBUG: response_content value: '{str(response_content)[:500]}...'")
            log_snippet = str(response_content)[:100] if response_content is not None else "[No text content]"
            st.session_state.execution_log.append(f"  πŸ€– LLM Response Snippet: {log_snippet}...")

            # --- Tool Call Warning (if they still appear) ---
            if hasattr(result, 'tool_calls') and result.tool_calls:
                 calls = result.tool_calls; call_details = [f"{call.get('name', '?')}({call.get('args', {})})" for call in calls]
                 warning_msg = f"  ⚠️ WARNING: LLM response included 'tool_calls' ({call_details}). Graph not handling them."; print(warning_msg); st.session_state.execution_log.append(warning_msg); st.warning(warning_msg)

            # --- Routing Key Check (Should work on extracted string now) ---
            if isinstance(response_content, str):
                match = re.search(rf"{ROUTING_KEY_MARKER}\s*(\w+)\s*$", response_content)
                if match: st.session_state.execution_log.append(f"  πŸ”‘ Detected key: '{match.group(1)}'.")
                else: st.session_state.execution_log.append(f"  ⚠️ WARNING: No routing key found."); response_content += f" {ROUTING_KEY_MARKER} {DEFAULT_ROUTING_KEY}"; st.session_state.execution_log.append(f"  πŸ”§ Appended default key.")
            else: # Should not happen now, but keep as safeguard
                 st.session_state.execution_log.append(f"  ⚠️ ERROR: Extracted content not str ({type(response_content)}).")
                 response_content = f"Error: Invalid type {ROUTING_KEY_MARKER} {DEFAULT_ROUTING_KEY}"

            # --- Update State ---
            updated_state = state.copy()
            if "node_outputs" not in updated_state or not isinstance(updated_state["node_outputs"], dict): updated_state["node_outputs"] = {}
            updated_state["node_outputs"][node_id] = str(response_content) # Store string
            updated_state["last_response_content"] = str(response_content) # Store string
            updated_state["current_node_id"] = node_id
            return updated_state

        # --- Exception Handling ---
        except Exception as e: # Catch any other errors during invoke/processing
            error_msg = f"Error in node {node_name} ({node_id}): {e}"
            print(error_msg); st.session_state.execution_log.append(f"  -> ❌ Error: {e}")
            updated_state = state.copy(); updated_state["last_response_content"] = f"ERROR: {error_msg} {ROUTING_KEY_MARKER} error"; updated_state["current_node_id"] = node_id
            import traceback; traceback.print_exc(); return updated_state

    return agent_node_function


# --- Generic Router Function (Dictionary State Version - CORRECTED FORMATTING) ---
def generic_router(state: WorkflowState) -> str:
    """ Determines route based on key in state['last_response_content']. """
    print("\n--- Routing Check ---")
    routing_key = DEFAULT_ROUTING_KEY # Default if no key found
    last_content = state.get("last_response_content", "")

    if last_content:
        # Ensure last_content is treated as a string
        if isinstance(last_content, str):
             match = re.search(rf"{ROUTING_KEY_MARKER}\s*(\w+)\s*$", last_content)
             if match:
                 # Key found, use it
                 routing_key = match.group(1).strip()
                 print(f"  Extracted key: '{routing_key}'")
             else:
                 # String content, but no key found at the end
                 print(f"  No routing key found in last response: '...{last_content[-50:]}'")
                 print(f"  -> Using default routing ('{DEFAULT_ROUTING_KEY}').")
        else:
             # Content is not a string (shouldn't happen often with current node logic, but good practice)
             print(f"  Last response content type is {type(last_content)}, not string.")
             print(f"  -> Using default routing ('{DEFAULT_ROUTING_KEY}').")
    else:
        # No previous response content found in state
        print(f"  No previous response content found.")
        print(f"  -> Using default routing ('{DEFAULT_ROUTING_KEY}').")

    # Log and return the determined routing key
    log_decision_msg = f"🚦 Routing decision: '{routing_key}'"
    print(f"  -> {log_decision_msg}")
    if "execution_log" not in st.session_state: st.session_state.execution_log = []
    st.session_state.execution_log.append(log_decision_msg)
    return routing_key

# --- Graph Compilation Function (Dictionary State Version - No Change) ---
def compile_graph() -> bool:
    if not llm: st.error("LLM not initialized.", icon="πŸ”₯"); return False
    if not st.session_state.nodes: st.warning("No nodes defined.", icon="⚠️"); return False
    print("\n--- Compiling Graph (Dictionary State) ---")
    try:
        graph_builder = StateGraph(WorkflowState); valid_nodes = [n for n in st.session_state.nodes if isinstance(n, dict) and all(k in n for k in ["id", "name", "prompt"])];
        if not valid_nodes: st.warning("No valid nodes.", icon="⚠️"); return False
        node_ids = {node['id'] for node in valid_nodes}; start_node_id_actual = valid_nodes[0]["id"]; print("  Adding Nodes:")
        possible_keys_per_node = {}
        for node_data in valid_nodes:
            node_id, node_name, node_prompt = node_data["id"], node_data["name"], node_data["prompt"]; print(f"    - ID: {node_id}, Name: '{node_name}'")
            routing_rules = node_data.get("routing_rules", {}); cond_keys = {rule.get("output_key", "").strip() for rule in routing_rules.get("conditional_targets", []) if rule.get("output_key")}
            all_keys = cond_keys.union({DEFAULT_ROUTING_KEY, "error"}); possible_keys_per_node[node_id] = list(all_keys); agent_func = create_agent_node_function(node_id, node_name, node_prompt, possible_keys_per_node[node_id])
            graph_builder.add_node(node_id, agent_func)
        print("  Adding Edges:"); graph_builder.add_edge(START, start_node_id_actual); print(f"    - START -> {get_node_display_name(start_node_id_actual)}")
        all_targets_valid = True
        for node_data in valid_nodes:
            node_id, node_name = node_data["id"], node_data["name"]; routing_rules = node_data.get("routing_rules", {}); default_target = routing_rules.get("default_target", END_NODE_ID); conditional_targets = routing_rules.get("conditional_targets", [])
            path_map = {}; print(f"    - Edges from '{node_name}' ({node_id}):"); seen_keys_for_node = set(); node_targets_valid = True
            for rule_idx, rule in enumerate(conditional_targets):
                key, target_id = rule.get("output_key", "").strip(), rule.get("target_node_id")
                if key and target_id:
                    if target_id != END_NODE_ID and target_id not in node_ids: st.error(f"❌ Invalid Target: {node_name} '{key}'->'{get_node_display_name(target_id)}'", icon="πŸ”₯"); all_targets_valid=False; node_targets_valid=False; continue
                    if key in seen_keys_for_node: st.warning(f"⚠️ Duplicate key '{key}' in '{node_name}'.", icon="⚠️")
                    path_map[key] = target_id; seen_keys_for_node.add(key); print(f"      - If key '{key}' -> {get_node_display_name(target_id)}")
                elif key or target_id: st.warning(f"Node '{node_name}' incomplete rule #{rule_idx+1}. Ignored.", icon="⚠️")
            if DEFAULT_ROUTING_KEY not in path_map:
                 if default_target != END_NODE_ID and default_target not in node_ids: st.error(f"❌ Invalid Default Target: {node_name}->'{get_node_display_name(default_target)}'", icon="πŸ”₯"); all_targets_valid=False; node_targets_valid=False
                 else: path_map[DEFAULT_ROUTING_KEY] = default_target; print(f"      - If key '{DEFAULT_ROUTING_KEY}' -> {get_node_display_name(default_target)}")
            if "error" not in path_map: path_map["error"] = END_NODE_ID; print(f"      - If key 'error' -> {get_node_display_name(END_NODE_ID)} (Implicit)")
            if node_targets_valid: graph_builder.add_conditional_edges(node_id, generic_router, path_map)
            else: print(f"      -> Skipping edges for '{node_name}'.")
        if not all_targets_valid: st.error("Compilation failed.", icon="πŸ”₯"); return False
        recursion_limit = len(valid_nodes) * 3 + 10; print(f"  Setting recursion limit to: {recursion_limit}")
        st.session_state.compiled_graph = graph_builder.compile(checkpointer=None); st.session_state.recursion_limit = recursion_limit
        print("βœ… Graph compiled successfully!"); st.toast("Workflow compiled!", icon="βœ…"); return True
    except Exception as e: st.error(f"Compile error: {e}", icon="πŸ”₯"); print(f"❌ Compile Error: {e}"); import traceback; traceback.print_exc(); st.session_state.compiled_graph = None; return False

# --- Graph Visualization Data Generation (No Change) ---
def generate_agraph_data(nodes_data: List[Dict[str, Any]]) -> tuple[List[Node], List[Edge]]:
    agraph_nodes: List[Node] = []; agraph_edges: List[Edge] = []
    agraph_nodes.append(Node(id=START_NODE_ID, label="START", shape="ellipse", color="#4CAF50", title="Workflow Entry Point"))
    valid_nodes_vis = [node for node in nodes_data if isinstance(node, dict) and 'id' in node]
    node_ids_vis = {node['id'] for node in valid_nodes_vis}; node_indices = {node['id']: i for i, node in enumerate(valid_nodes_vis)}
    for i, node in enumerate(valid_nodes_vis):
        node_id = node['id']; node_name = node.get('name', 'Unnamed'); node_prompt_snippet = node.get('prompt', '')[:100] + "..."
        is_selected = st.session_state.get('selected_node_id') == node_id; border_width = 3 if is_selected else 1; node_color = "#FFC107" if is_selected else "#90CAF9"
        agraph_nodes.append(Node(id=node_id, label=f"{i+1}. {node_name}", shape="box", color=node_color, borderWidth=border_width, title=f"ID: {node_id}\nPrompt: {node_prompt_snippet}"))
        if i == 0: agraph_edges.append(Edge(source=START_NODE_ID, target=node_id, label="Start Flow", color="#4CAF50", width=2))
        routing_rules = node.get("routing_rules", {}); default_target = routing_rules.get("default_target", END_NODE_ID); conditional_targets = routing_rules.get("conditional_targets", [])
        added_vis_edges = set()
        if default_target == END_NODE_ID or default_target in node_ids_vis:
            is_overridden = any(r.get("output_key") == DEFAULT_ROUTING_KEY for r in conditional_targets); edge_id = (node_id, default_target, DEFAULT_ROUTING_KEY)
            if not is_overridden and edge_id not in added_vis_edges: agraph_edges.append(Edge(source=node_id, target=default_target, label=DEFAULT_ROUTING_KEY, color="#9E9E9E", dashes=True, arrows="to", font={'align': 'middle'})); added_vis_edges.add(edge_id)
        for rule in conditional_targets:
            key, target_id = rule.get("output_key", "").strip(), rule.get("target_node_id")
            if key and target_id and (target_id == END_NODE_ID or target_id in node_ids_vis):
                edge_id = (node_id, target_id, key)
                if edge_id not in added_vis_edges:
                    is_loopback = False
                    if target_id in node_indices and node_id in node_indices:
                         if node_indices[target_id] < node_indices[node_id]: is_loopback = True
                    edge_color = "#FF5722" if is_loopback else "#2196F3"; edge_label = f"LOOP: {key}" if is_loopback else key
                    agraph_edges.append(Edge(source=node_id, target=target_id, label=edge_label, color=edge_color, arrows="to", font={'align': 'middle'})); added_vis_edges.add(edge_id)
    if any(hasattr(edge, 'target') and edge.target == END_NODE_ID for edge in agraph_edges):
        agraph_nodes.append(Node(id=END_NODE_ID, label="END", shape="ellipse", color="#F44336", title="Workflow End"))
    return agraph_nodes, agraph_edges

# --- Example Workflow Definitions (No Change) ---
def get_simple_summarizer_workflow(): node_id = f"summarize_node_{uuid.uuid4().hex[:4]}"; return [ { "id": node_id, "name": "Summarize Input", "prompt": f"Summarize the input text concisely (1-2 sentences).", "type": "llm_call", "routing_rules": {"default_target": END_NODE_ID, "conditional_targets": [{"output_key":"done", "target_node_id": END_NODE_ID}]} } ]
def get_sentiment_workflow(): ids = {name: f"sentiment_node_{uuid.uuid4().hex[:4]}" for name in ["analyzer", "positive", "negative", "neutral"]}; nodes = [ { "id": ids["analyzer"], "name": "Analyze Sentiment", "prompt": f"Analyze sentiment (Positive, Negative, Neutral) of the input. Respond ONLY with the result.", "type": "llm_call", "routing_rules": { "default_target": ids["neutral"], "conditional_targets": [ {"output_key": "Positive", "target_node_id": ids["positive"]}, {"output_key": "Negative", "target_node_id": ids["negative"]}, {"output_key": "Neutral", "target_node_id": ids["neutral"]} ]}}, { "id": ids["positive"], "name": "Handle Positive", "prompt": f"Sentiment was positive. Respond cheerfully.", "type": "llm_call", "routing_rules": {"default_target": END_NODE_ID, "conditional_targets": [{"output_key":"done", "target_node_id": END_NODE_ID}]}}, { "id": ids["negative"], "name": "Handle Negative", "prompt": f"Sentiment was negative. Respond empathetically.", "type": "llm_call", "routing_rules": {"default_target": END_NODE_ID, "conditional_targets": [{"output_key":"done", "target_node_id": END_NODE_ID}]}}, { "id": ids["neutral"], "name": "Handle Neutral", "prompt": f"Sentiment was neutral. Acknowledge receipt.", "type": "llm_call", "routing_rules": {"default_target": END_NODE_ID, "conditional_targets": [{"output_key":"done", "target_node_id": END_NODE_ID}]}} ]; return nodes
def get_classification_workflow(): ids = {name: f"classify_node_{uuid.uuid4().hex[:4]}" for name in ["classify", "complaint", "query", "compliment", "general"]}; nodes = [ { "id": ids["classify"], "name": "Extract & Classify Intent", "prompt": f"From input, extract product/person (or 'None'). Classify intent: Complaint, Query, Compliment. Respond STRICTLY:\nExtracted Info: [Info]\nIntent: [IntentWord]", "type": "llm_call", "routing_rules": { "default_target": ids["general"], "conditional_targets": [ {"output_key": "Complaint", "target_node_id": ids["complaint"]}, {"output_key": "Query", "target_node_id": ids["query"]}, {"output_key": "Compliment", "target_node_id": ids["compliment"]} ]}}, { "id": ids["complaint"], "name": "Handle Complaint", "prompt": f"Complaint received. Respond empathetically, use extracted info.", "type": "llm_call", "routing_rules": {"default_target": END_NODE_ID, "conditional_targets": [{"output_key":"done", "target_node_id": END_NODE_ID}]}}, { "id": ids["query"], "name": "Answer Query", "prompt": f"Query received. Answer based on context/info. Use web search if needed.", "type": "llm_call", "routing_rules": {"default_target": END_NODE_ID, "conditional_targets": [{"output_key":"done", "target_node_id": END_NODE_ID}]}}, { "id": ids["compliment"], "name": "Handle Compliment", "prompt": f"Compliment received. Respond thankfully.", "type": "llm_call", "routing_rules": {"default_target": END_NODE_ID, "conditional_targets": [{"output_key":"done", "target_node_id": END_NODE_ID}]}}, { "id": ids["general"], "name": "General Response", "prompt": f"Intent unclear. Provide generic response.", "type": "llm_call", "routing_rules": {"default_target": END_NODE_ID, "conditional_targets": [{"output_key":"done", "target_node_id": END_NODE_ID}]}} ]; return nodes
def get_deep_research_workflow(): ids = {name: f"dr_node_{uuid.uuid4().hex[:4]}" for name in [ "planner", "search_A", "search_B", "cross_reference", "synthesize", "final_report" ]}; nodes = [ { "id": ids["planner"], "name": "πŸ“ Research Planner", "prompt": f"Analyze the research goal. Break into 1-3 angles (A, B). State plan. Decide if single/multi angle needed.", "type": "llm_call", "routing_rules": { "default_target": ids["search_A"], "conditional_targets": [ {"output_key": "single_angle", "target_node_id": ids["search_A"]}, {"output_key": "multi_angle", "target_node_id": ids["search_A"]} ] } }, { "id": ids["search_A"], "name": "πŸ” Research Angle A", "prompt": f"Research Angle A based on plan. Use web search if necessary. Summarize findings for Angle A.", "type": "llm_call", "routing_rules": { "default_target": ids["search_B"], "conditional_targets": [ {"output_key": "synthesize_direct", "target_node_id": ids["synthesize"]}, {"output_key": "next_angle", "target_node_id": ids["search_B"]} ] } }, { "id": ids["search_B"], "name": "πŸ”Ž Research Angle B", "prompt": f"Research Angle B based on plan. Use web search if necessary. Summarize findings for Angle B.", "type": "llm_call", "routing_rules": { "default_target": ids["cross_reference"], "conditional_targets": [ {"output_key": "cross_reference", "target_node_id": ids["cross_reference"]} ] } }, { "id": ids["cross_reference"], "name": "πŸ”„ Cross-Reference & Validate", "prompt": f"Review findings from Angle A & B. Identify agreements/contradictions/gaps. Use web search to verify if needed.", "type": "llm_call", "routing_rules": { "default_target": ids["synthesize"], "conditional_targets": [ {"output_key": "revisit_A", "target_node_id": ids["search_A"]}, {"output_key": "revisit_B", "target_node_id": ids["search_B"]}, {"output_key": "synthesize", "target_node_id": ids["synthesize"]} ] } }, { "id": ids["synthesize"], "name": "🧩 Synthesize Findings", "prompt": f"Combine validated findings from research angles. Create concise summary for original goal.", "type": "llm_call", "routing_rules": { "default_target": ids["final_report"], "conditional_targets": [ {"output_key": "refine_synthesis", "target_node_id": ids["cross_reference"]}, {"output_key": "final_report", "target_node_id": ids["final_report"]} ] } }, { "id": ids["final_report"], "name": "πŸ“„ Generate Final Report", "prompt": f"Format synthesized findings into clear final report.", "type": "llm_call", "routing_rules": {"default_target": END_NODE_ID, "conditional_targets": [{"output_key": "done", "target_node_id": END_NODE_ID}]} } ]; return nodes
def get_enhanced_hedge_fund_workflow(): ids = {name: f"ehf_node_{uuid.uuid4().hex[:4]}" for name in [ "goal_risk", "planner", "macro", "sector", "company", "risk_assess", "strategist" ]}; nodes = [ { "id": ids["goal_risk"], "name": "🎯 Goal & Risk Profiler", "prompt": f"Analyze investment goal from input. Clarify timeframe, expectations, infer risk tolerance (Conservative, Balanced, Aggressive). State profile.", "type": "llm_call", "routing_rules": { "default_target": ids["planner"], "conditional_targets": [ {"output_key": "plan_research", "target_node_id": ids["planner"]} ] } }, { "id": ids["planner"], "name": "πŸ—ΊοΈ Advanced Research Planner", "prompt": f"Based on Goal/Risk, determine necessary research steps & sequence (Macro, Sector, Company). State plan. Decide FIRST step.", "type": "llm_call", "routing_rules": { "default_target": ids["risk_assess"], "conditional_targets": [ {"output_key": "research_macro", "target_node_id": ids["macro"]}, {"output_key": "research_sector", "target_node_id": ids["sector"]}, {"output_key": "research_company", "target_node_id": ids["company"]}, {"output_key": "go_strategy", "target_node_id": ids["strategist"]} ] } }, { "id": ids["macro"], "name": "πŸ“ˆ Macro Researcher", "prompt": f"Perform macro analysis relevant to Goal/Risk. Use web search if needed (GDP, rates, inflation). Summarize findings & impacts. Determine NEXT planned step.", "type": "llm_call", "routing_rules": { "default_target": ids["risk_assess"], "conditional_targets": [ {"output_key": "research_sector", "target_node_id": ids["sector"]}, {"output_key": "research_company", "target_node_id": ids["company"]}, {"output_key": "assess_risk", "target_node_id": ids["risk_assess"]} ] } }, { "id": ids["sector"], "name": "🏭 Sector Researcher", "prompt": f"Perform sector analysis relevant to Goal/Risk (guided by macro). Use web search if needed (trends/competitors). Summarize findings & impacts. Determine NEXT planned step.", "type": "llm_call", "routing_rules": { "default_target": ids["risk_assess"], "conditional_targets": [ {"output_key": "research_macro", "target_node_id": ids["macro"]}, {"output_key": "research_company", "target_node_id": ids["company"]}, {"output_key": "assess_risk", "target_node_id": ids["risk_assess"]} ] } }, { "id": ids["company"], "name": "🏒 Company Researcher", "prompt": f"Perform company analysis relevant to Goal/Risk (guided by macro/sector). Use web search if needed (news, financials). Summarize findings (valuation, risks) & impacts. Determine NEXT planned step.", "type": "llm_call", "routing_rules": { "default_target": ids["risk_assess"], "conditional_targets": [ {"output_key": "research_macro", "target_node_id": ids["macro"]}, {"output_key": "research_sector", "target_node_id": ids["sector"]}, {"output_key": "assess_risk", "target_node_id": ids["risk_assess"]} ] } }, { "id": ids["risk_assess"], "name": "⚠️ Risk Assessor & Validator", "prompt": f"Review ALL research findings against Goal/Risk. Identify key risks, inconsistencies, gaps. Use web search to verify if needed.", "type": "llm_call", "routing_rules": { "default_target": ids["strategist"], "conditional_targets": [ {"output_key": "revisit_macro", "target_node_id": ids["macro"]}, {"output_key": "revisit_sector", "target_node_id": ids["sector"]}, {"output_key": "revisit_company", "target_node_id": ids["company"]}, {"output_key": "create_strategy", "target_node_id": ids["strategist"]} ] } }, { "id": ids["strategist"], "name": "πŸ’° Portfolio Strategist", "prompt": f"Synthesize validated research. Develop specific, diversified portfolio allocation strategy tailored to Goal/Risk. Justify strategy.", "type": "llm_call", "routing_rules": {"default_target": END_NODE_ID, "conditional_targets": [{"output_key":"portfolio_ready", "target_node_id": END_NODE_ID}]} } ]; return nodes

# --- Helper Function to Load Workflow (No Change) ---
def load_workflow(workflow_func):
     try: st.session_state.nodes = workflow_func(); st.session_state.compiled_graph = None; st.session_state.selected_node_id = st.session_state.nodes[0]['id'] if st.session_state.nodes else None; st.session_state.execution_log = []; st.session_state.final_state = None; st.session_state.recursion_limit = None; wf_name = workflow_func.__name__.replace('get_','').replace('_workflow','').replace('_', ' ').title(); st.toast(f"{wf_name} loaded!", icon="πŸ“„"); st.rerun()
     except Exception as e: st.error(f"Load Error: {e}"); print(f"Load Error: {e}"); import traceback; traceback.print_exc()

# --- Session State Initialization (WITH SYNTAX FIX) ---
default_values = { "compiled_graph": None, "execution_log": [], "final_state": None, "selected_node_id": None, "recursion_limit": None, "openai_api_key_provided": False }
for key, value in default_values.items(): st.session_state.setdefault(key, value)
if "nodes" not in st.session_state or not st.session_state.nodes:
    try:
        print("Loading default workflow"); st.session_state.nodes = get_enhanced_hedge_fund_workflow()
        if st.session_state.nodes and isinstance(st.session_state.nodes, list): st.session_state.selected_node_id = st.session_state.nodes[0].get('id')
    except Exception as e: # Corrected Block
        print(f"Default load error: {e}"); st.session_state.nodes = []
    # --- END CORRECTION ---

# --- Helper UI Functions (Readable Versions with Syntax Fix) ---
def get_node_options_for_select(include_end=True, exclude_node_id: Optional[str] = None) -> List[tuple[str, str]]:
    options = []; node_list = st.session_state.get("nodes", [])
    if isinstance(node_list, list):
        options.extend([(node.get("id"), f"{i+1}. {node.get('name', 'Unnamed')}") for i, node in enumerate(node_list) if isinstance(node, dict) and node.get("id") != exclude_node_id])
    if include_end and END_NODE_ID != exclude_node_id: options.append((END_NODE_ID, get_node_display_name(END_NODE_ID)))
    return options
def select_node(node_id: Optional[str]): st.session_state.selected_node_id = node_id
def update_node_data_ui(node_id: str, data: Dict[str, Any]):
    updated = False; node_list = st.session_state.get("nodes", [])
    if isinstance(node_list, list):
        for i, node in enumerate(node_list):
            if isinstance(node, dict) and node.get("id") == node_id:
                try: new_rules = json.loads(json.dumps(data.get("routing_rules", {})))
                except Exception: st.error("Serialize Error."); return
                node["name"] = data.get("name", node.get("name")); node["prompt"] = data.get("prompt", node.get("prompt")); node["routing_rules"] = new_rules; updated = True; break
    if updated: st.session_state.compiled_graph = None; st.toast(f"Node '{data.get('name', node_id)}' updated.", icon="πŸ’Ύ"); st.session_state.selected_node_id = node_id
    else: st.error(f"Node {node_id} not found.")
def delete_node_ui(node_id_to_delete: str):
    node_list = st.session_state.get("nodes", [])
    if isinstance(node_list, list):
        original_len = len(node_list); node_name_deleted = get_node_display_name(node_id_to_delete)
        st.session_state.nodes = [n for n in node_list if not (isinstance(n, dict) and n.get("id") == node_id_to_delete)]
        if len(st.session_state.nodes) < original_len:
            st.session_state.compiled_graph = None
            if st.session_state.selected_node_id == node_id_to_delete: st.session_state.selected_node_id = st.session_state.nodes[0]['id'] if st.session_state.nodes else None
            st.toast(f"Deleted {node_name_deleted}", icon="πŸ—‘οΈ"); st.rerun()
        else: st.warning(f"Node {node_id_to_delete} not found.")
def move_node_ui(node_id_to_move: str, direction: int): # Readable Version with Syntax Fix
    node_list = st.session_state.get("nodes", [])
    if isinstance(node_list, list):
        try:
            index = next(i for i, n in enumerate(node_list) if isinstance(n, dict) and n.get("id") == node_id_to_move)
            new_index = index + direction
            if 0 <= new_index < len(node_list):
                node = node_list.pop(index)
                node_list.insert(new_index, node)
                st.session_state.compiled_graph = None
                st.session_state.selected_node_id = node_id_to_move
                st.rerun()
        except StopIteration: # Correctly placed except block
            st.warning(f"Node {node_id_to_move} not found for moving.")
def add_conditional_rule_ui(node_id: str):
    node_list = st.session_state.get("nodes", [])
    if isinstance(node_list, list):
        for i, node in enumerate(node_list):
            if isinstance(node, dict) and node.get("id") == node_id:
                if not isinstance(node.get("routing_rules"), dict): node["routing_rules"] = {}
                if not isinstance(node["routing_rules"].get("conditional_targets"), list): node["routing_rules"]["conditional_targets"] = []
                node["routing_rules"]["conditional_targets"].append({"output_key": "", "target_node_id": END_NODE_ID}); st.session_state.compiled_graph = None; st.session_state.selected_node_id = node_id; st.rerun(); return
    st.error(f"Node {node_id} not found.")
def delete_conditional_rule_ui(node_id: str, rule_index: int):
    node_list = st.session_state.get("nodes", [])
    if isinstance(node_list, list):
         for i, node in enumerate(node_list):
             if isinstance(node, dict) and node.get("id") == node_id:
                 rules_list = node.get("routing_rules", {}).get("conditional_targets")
                 if isinstance(rules_list, list) and 0 <= rule_index < len(rules_list):
                     del node["routing_rules"]["conditional_targets"][rule_index]; st.session_state.compiled_graph = None; st.session_state.selected_node_id = node_id; st.rerun(); return
                 else: st.warning(f"Invalid rule index {rule_index}."); return
         st.error(f"Node {node_id} not found.")

# --- Streamlit App UI (No Change) ---
st.set_page_config(layout="wide", page_title="Visual AI Automation Builder ", page_icon="πŸ€–")
# st.image("logo.png", width=100)  # <--- Add logo here
# st.image("logo.png", width=300, height=200)
# Or, let it automatically stretch to the column width
st.image("static/logo.png", use_container_width=True)
st.title("πŸ€–πŸ§  Visual AI Automation Builder")
now = datetime.now(); current_timestamp = now.strftime("%Y-%m-%d %H:%M:%S")
st.caption(f"Uses Dictionary State. LLM: `gpt-4o` (bound: `{web_search_tool_dict}`). Refresh: {current_timestamp}")
with st.sidebar: # Sidebar UI (No Change)
    st.header("πŸ”‘ OpenAI API Key"); openai_api_key_input = st.text_input("API Key", type="password", value=os.environ.get("OPENAI_API_KEY", ""), help="Needed for LLM nodes.", key="api_key_input_sidebar"); api_key_in_env = os.environ.get("OPENAI_API_KEY"); api_key_changed = (openai_api_key_input != api_key_in_env if api_key_in_env else bool(openai_api_key_input))
    if api_key_changed:
        if openai_api_key_input: os.environ["OPENAI_API_KEY"] = openai_api_key_input
        elif "OPENAI_API_KEY" in os.environ: del os.environ["OPENAI_API_KEY"]
        llm = None; llm_with_search = None; llm_ready = initialize_llm(); st.session_state.openai_api_key_provided = bool(openai_api_key_input)
        if api_key_changed: st.session_state.compiled_graph = None; st.rerun()
    else: llm_ready = initialize_llm(); st.session_state.openai_api_key_provided = bool(os.environ.get("OPENAI_API_KEY"))
    if llm_ready: st.success("LLM Initialized.", icon="βœ…")
    elif st.session_state.get("openai_api_key_provided"): st.error("LLM Init Failed.", icon="πŸ”₯")
    else: st.warning("LLM needs API Key.", icon="⚠️")
    st.caption(f"Tool binding: `{web_search_tool_dict}`"); st.divider()
    st.header("🧩 Node Palette");
    with st.form("add_node_form"):
        new_node_name_input = st.text_input("New Node Name", placeholder="e.g., 'Summarize Input'", key="new_node_name_palette_main"); add_node_submitted = st.form_submit_button("βž• Add LLM Node", use_container_width=True)
        if add_node_submitted:
            if new_node_name_input:
                if any(isinstance(n, dict) and n.get('name') == new_node_name_input for n in st.session_state.nodes): st.warning(f"Name exists.", icon="⚠️")
                else: node_id = f"node_{uuid.uuid4().hex[:6]}"; st.session_state.nodes.append({"id": node_id, "name": new_node_name_input, "type": "llm_call", "prompt": f"Task: {new_node_name_input}\nInput: {{input_text}}\n(Use web search if needed.)", "routing_rules": {"default_target": END_NODE_ID, "conditional_targets": []}}); st.session_state.compiled_graph = None; st.session_state.selected_node_id = node_id; st.toast(f"Added: {new_node_name_input}", icon="βž•"); st.rerun()
            else: st.error("Node Name required.", icon="❗")
    st.divider(); st.header("πŸ“œ Example Workflows"); ex_cols = st.columns(2)
    with ex_cols[0]: st.button("πŸ“„ Summarizer", use_container_width=True, key="load_summarizer_btn", on_click=load_workflow, args=(get_simple_summarizer_workflow,)); st.button("🎭 Sentiment", use_container_width=True, key="load_sentiment_btn", on_click=load_workflow, args=(get_sentiment_workflow,)); st.button("πŸ”¬ Deep Research", use_container_width=True, key="load_deep_research_btn", on_click=load_workflow, args=(get_deep_research_workflow,))
    with ex_cols[1]: st.button("🏷️ Classify", use_container_width=True, key="load_classify_btn", on_click=load_workflow, args=(get_classification_workflow,)); st.button("πŸ“ˆ Adv. Hedge Fund", use_container_width=True, key="load_adv_hedge_btn", on_click=load_workflow, args=(get_enhanced_hedge_fund_workflow,))
    st.divider(); st.header("βš™οΈ Workflow Control"); compile_disabled = not llm_ready or not st.session_state.nodes; tooltip_compile = "Requires API Key & nodes." if compile_disabled else "Compile workflow.";
    if st.button("πŸ”„ Compile Workflow", type="primary", use_container_width=True, disabled=compile_disabled, help=tooltip_compile, key="compile_workflow_btn"):
        if compile_graph(): st.rerun()
    tooltip_reset = "Clear all nodes and reset."; st.button("πŸ—‘οΈ Reset Workflow", use_container_width=True, help=tooltip_reset, key="reset_workflow_btn", on_click=lambda: setattr(st.session_state, 'nodes', []) or st.rerun())
top_cols = st.columns([0.6, 0.4])
with top_cols[0]: # Graph Vis (No Change)
    st.subheader("πŸ“Š Workflow Graph")
    if not st.session_state.nodes: st.info("Add nodes or load an example.")
    elif not AGRAPH_AVAILABLE: st.warning("Install `streamlit-agraph` for visualization.")
    else:
        try:
            agraph_nodes, agraph_edges = generate_agraph_data(st.session_state.nodes); agraph_config = Config(width='100%', height=500, directed=True, physics={'enabled': True, 'solver': 'forceAtlas2Based', 'forceAtlas2Based': {'gravitationalConstant': -60, 'centralGravity': 0.01, 'springLength': 120, 'springConstant': 0.1, 'damping': 0.3}}, interaction={'navigationButtons': True, 'tooltipDelay': 300, 'hover': True}, nodes={'font': {'size': 14}}, edges={'font': {'size': 12, 'align': 'middle'}}, layout={'hierarchical': False}, manipulation=False )
            clicked_node_id = agraph(nodes=agraph_nodes, edges=agraph_edges, config=agraph_config); valid_node_ids = {n['id'] for n in st.session_state.nodes if isinstance(n, dict)}
            if clicked_node_id and clicked_node_id in valid_node_ids and clicked_node_id != st.session_state.selected_node_id: select_node(clicked_node_id); st.rerun()
        except Exception as e: st.error(f"Graph Error: {e}", icon="πŸ”₯"); print(f"Graph Error: {e}"); import traceback; traceback.print_exc()
with top_cols[1]: # Node Config (No Change)
    st.subheader("βš™οΈ Node Configuration"); valid_node_ids = [n.get('id') for n in st.session_state.nodes if isinstance(n, dict)]
    if not st.session_state.selected_node_id or st.session_state.selected_node_id not in valid_node_ids: st.session_state.selected_node_id = valid_node_ids[0] if valid_node_ids else None
    if not st.session_state.nodes: st.info("Add nodes or load example.")
    else:
        node_options_display = get_node_options_for_select(include_end=False); node_ids_only = [opt[0] for opt in node_options_display]; current_selection_index=0
        try: current_selection_index = node_ids_only.index(st.session_state.selected_node_id) if st.session_state.selected_node_id in node_ids_only else 0
        except: pass
        if not node_ids_only: current_selection_index = 0
        selected_id_from_dropdown = st.selectbox( "Select Node:", options=node_options_display, index=current_selection_index, format_func=lambda x: x[1], key="node_selector_config", label_visibility="collapsed" )
        if selected_id_from_dropdown and selected_id_from_dropdown[0] != st.session_state.selected_node_id: select_node(selected_id_from_dropdown[0]); st.rerun()
        st.divider()
    selected_node_data = next((n for n in st.session_state.nodes if isinstance(n, dict) and n.get("id") == st.session_state.selected_node_id), None)
    if selected_node_data:
         node_id, node_name, node_prompt = selected_node_data["id"], selected_node_data.get("name", ""), selected_node_data.get("prompt", ""); routing_rules = selected_node_data.get("routing_rules", {}); default_target = routing_rules.get("default_target", END_NODE_ID); conditional_targets = routing_rules.get("conditional_targets", [])
         if not isinstance(routing_rules, dict): routing_rules = {"default_target": END_NODE_ID, "conditional_targets": []}
         if not isinstance(conditional_targets, list): conditional_targets = []
         with st.container(border=True):
             form_key = f"config_form_{node_id}"
             with st.form(key=form_key):
                 st.markdown(f"**Editing: {node_name}** (`{node_id}`)"); edited_name = st.text_input("Node Name", value=node_name, key=f"cfg_name_{node_id}"); edited_prompt = st.text_area("LLM Prompt", value=node_prompt, height=150, key=f"cfg_prompt_{node_id}", help=f"Define task. Use '{{input_text}}' if needed. End response with '{ROUTING_KEY_MARKER} <key>'.")
                 st.markdown("**🚦 Routing Rules**"); node_options = get_node_options_for_select(include_end=True, exclude_node_id=node_id); current_default_idx=0;
                 try: current_default_idx = [i for i, (opt_id, _) in enumerate(node_options) if opt_id == default_target][0]
                 except: current_default_idx = next((i for i, (opt_id, _) in enumerate(node_options) if opt_id == END_NODE_ID), 0)
                 selected_default_option = st.selectbox("Default Target", options=node_options, index=current_default_idx, format_func=lambda x: x[1], key=f"cfg_default_{node_id}"); edited_default_target_id = selected_default_option[0] if selected_default_option else END_NODE_ID
                 st.markdown(f"**Conditional Targets (based on {ROUTING_KEY_MARKER} output):**"); edited_conditional_targets = []; current_conditional_targets = list(conditional_targets)
                 for rule_idx, rule in enumerate(current_conditional_targets):
                     st.caption(f"Rule {rule_idx+1}"); rule_cols = st.columns([0.5, 0.5]);
                     with rule_cols[0]: output_key = st.text_input(f"If Key Is", value=rule.get("output_key", ""), placeholder="e.g., success", key=f"cfg_key_{node_id}_{rule_idx}", label_visibility="collapsed")
                     with rule_cols[1]:
                          current_target_idx=0;
                          try: current_target_idx = [i for i, (opt_id, _) in enumerate(node_options) if opt_id == rule.get("target_node_id")][0]
                          except: current_target_idx = next((i for i, (opt_id, _) in enumerate(node_options) if opt_id == END_NODE_ID), 0)
                          selected_target_option = st.selectbox(f"Then Go To", options=node_options, index=current_target_idx, format_func=lambda x: x[1], key=f"cfg_target_{node_id}_{rule_idx}", label_visibility="collapsed"); target_node_id = selected_target_option[0] if selected_target_option else END_NODE_ID
                     edited_conditional_targets.append({"output_key": output_key.strip(), "target_node_id": target_node_id})
                 st.divider(); submitted = st.form_submit_button("πŸ’Ύ Save Changes", type="primary", use_container_width=True)
                 if submitted:
                     final_conditional_targets = [r for r in edited_conditional_targets if r.get("output_key")]; new_data = {"name": edited_name.strip(), "prompt": edited_prompt, "routing_rules": {"default_target": edited_default_target_id, "conditional_targets": final_conditional_targets}}
                     if not edited_name.strip(): st.warning("Node Name cannot be empty.")
                     elif any(isinstance(n, dict) and n.get('id') != node_id and n.get('name','').strip() == edited_name.strip() for n in st.session_state.nodes): st.warning(f"Name exists.")
                     else: update_node_data_ui(node_id, new_data); st.rerun()
             st.markdown("**Manage Rules & Node:**"); action_cols = st.columns([0.5, 0.5])
             with action_cols[0]: st.button("βž• Add Rule", key=f"add_rule_btn_{node_id}", on_click=add_conditional_rule_ui, args=(node_id,), use_container_width=True)
             if conditional_targets: rule_opts = [(idx, f"Rule {idx+1}: '{rule.get('output_key', '')[:10]}...'") for idx, rule in enumerate(conditional_targets)]; rule_opts.insert(0, (-1, "Delete Rule...")); selected_rule_idx_to_del = st.selectbox("Delete Rule", options=rule_opts, format_func=lambda x: x[1], label_visibility="collapsed", index=0, key=f"del_rule_sel_{node_id}")
             if 'selected_rule_idx_to_del' in locals() and selected_rule_idx_to_del and selected_rule_idx_to_del[0] != -1: rule_idx_del = selected_rule_idx_to_del[0]; st.button(f"πŸ—‘οΈ Confirm Del Rule {rule_idx_del+1}", key=f"del_rule_btn_{node_id}_{rule_idx_del}", on_click=delete_conditional_rule_ui, args=(node_id, rule_idx_del), use_container_width=True, type="secondary")
             with action_cols[1]: current_node_index = next((i for i, n in enumerate(st.session_state.nodes) if isinstance(n, dict) and n.get("id") == node_id), -1); st.button("⬆️ Up", key=f"mv_up_{node_id}", on_click=move_node_ui, args=(node_id, -1), disabled=(current_node_index <= 0), use_container_width=True); st.button("⬇️ Down", key=f"mv_dn_{node_id}", on_click=move_node_ui, args=(node_id, 1), disabled=(current_node_index < 0 or current_node_index >= len(st.session_state.nodes) - 1), use_container_width=True); st.button("❌ Delete Node", key=f"del_nd_{node_id}", on_click=delete_node_ui, args=(node_id,), use_container_width=True, type="secondary", help="Delete this node.")
    elif st.session_state.nodes: st.info("Select node.");
    if st.session_state.selected_node_id and st.session_state.nodes and st.session_state.selected_node_id not in [n.get('id') for n in st.session_state.nodes if isinstance(n, dict)]: st.session_state.selected_node_id = st.session_state.nodes[0].get('id'); st.rerun()
    elif not st.session_state.nodes: st.session_state.selected_node_id = None

# --- Execution Section (Adapted for Dict State - No Change) ---
st.divider(); st.header("πŸš€ Execute Workflow")
run_tooltip = ""; run_disabled = True
if st.session_state.compiled_graph and llm_ready: st.success("Workflow compiled.", icon="βœ…"); run_tooltip = "Run workflow."; run_disabled = False
elif not llm_ready: st.warning("LLM not ready.", icon="⚠️"); run_tooltip = "LLM needs API Key."
elif st.session_state.nodes: st.warning("Compile Workflow first.", icon="⚠️"); run_tooltip = "Compile first."
else: st.info("Workflow empty."); run_tooltip = "Workflow empty/not compiled."
initial_message = st.text_area( "Enter initial message:", height=80, key="initial_input_exec", value="I want moderate capital appreciation over 5-7 years, willing to accept some market volatility but avoid highly speculative assets. Focus on tech and renewable energy sectors.", help="Input for the first node." )
if not initial_message.strip(): run_disabled = True; run_tooltip += " Initial message required."
if st.button("▢️ Run Workflow", disabled=run_disabled, type="primary", help=run_tooltip):
    st.session_state.execution_log = ["**πŸš€ Starting Workflow...**"]; st.session_state.final_state = None
    initial_state = WorkflowState(input=initial_message, node_outputs={}, last_response_content="", current_node_id="")
    st.session_state.execution_log.append(f"πŸ“₯ Input: {initial_message[:150]}{'...' if len(initial_message)>150 else ''}")
    log_placeholder = st.empty(); log_placeholder.info("⏳ Running workflow...")
    with st.spinner("Executing workflow..."):
        try:
            rec_limit = st.session_state.get('recursion_limit', 25); print(f"Invoking graph with limit: {rec_limit}")
            final_state_result: WorkflowState = st.session_state.compiled_graph.invoke(initial_state, config={"recursion_limit": rec_limit})
            st.session_state.final_state = final_state_result; st.session_state.execution_log.append("**🏁 Workflow Finished**"); st.toast("Finished!", icon="🏁")
        except Exception as e:
            err_msg = f"{e}"
            if "must be followed by tool messages" in str(e): err_msg = f"LLM generated 'tool_calls' which graph cannot handle. Error: {e}."; st.error(f"{err_msg}", icon="πŸ”₯")
            elif isinstance(e, RecursionError) or "recursion limit" in str(e).lower(): err_msg = f"Recursion limit ({rec_limit}) reached. Error: {e}"; st.error(f"{err_msg}", icon="πŸ”₯")
            else: st.error(f"Exec failed: {err_msg}", icon="πŸ”₯");
            st.session_state.execution_log.append(f"**πŸ’₯ WORKFLOW ERROR:** {err_msg}"); print(f"Exec Error: {e}"); import traceback; traceback.print_exc(); st.toast("Failed!", icon="❌")
    log_placeholder.empty(); st.rerun()

# --- Results Display Section (Adapted for Dict State - No Change) ---
st.subheader("πŸ“Š Execution Results"); results_cols = st.columns(2)
with results_cols[0]:
    st.markdown("**Execution Log**");
    if st.session_state.execution_log: log_text = "\n".join(st.session_state.execution_log); st.text_area("Log Details:", value=log_text, height=300, disabled=True, key="log_display_final_main")
    else: st.caption("Run workflow to see log.")
with results_cols[1]:
    st.markdown("**Final Output Message**"); final_message_content = None
    if st.session_state.final_state and isinstance(st.session_state.final_state, dict):
        final_content_raw = st.session_state.final_state.get('last_response_content', '')
        if final_content_raw and isinstance(final_content_raw, str): final_message_content = re.sub(rf"\s*{ROUTING_KEY_MARKER}\s*\w+\s*$", "", final_content_raw).strip()
    if final_message_content:
        message_container = st.container(height=300, border=False); avatar = "🏁";
        if any("WORKFLOW ERROR" in log for log in st.session_state.execution_log): avatar = "πŸ’₯"
        elif "ERROR:" in final_message_content: avatar = "⚠️"
        message_container.chat_message("assistant", avatar=avatar).write(final_message_content)
    elif any("WORKFLOW ERROR" in log for log in st.session_state.execution_log): st.caption("Ended with error.")
    else: st.caption("Run workflow.")

# --- End of App ---