cowork-dash 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cowork_dash/__init__.py +35 -0
- cowork_dash/__main__.py +9 -0
- cowork_dash/agent.py +117 -0
- cowork_dash/app.py +1776 -0
- cowork_dash/assets/app.js +237 -0
- cowork_dash/assets/favicon.svg +1 -0
- cowork_dash/assets/styles.css +915 -0
- cowork_dash/canvas.py +318 -0
- cowork_dash/cli.py +273 -0
- cowork_dash/components.py +568 -0
- cowork_dash/config.py +91 -0
- cowork_dash/file_utils.py +226 -0
- cowork_dash/layout.py +250 -0
- cowork_dash/tools.py +699 -0
- cowork_dash-0.1.2.dist-info/METADATA +238 -0
- cowork_dash-0.1.2.dist-info/RECORD +19 -0
- cowork_dash-0.1.2.dist-info/WHEEL +4 -0
- cowork_dash-0.1.2.dist-info/entry_points.txt +2 -0
- cowork_dash-0.1.2.dist-info/licenses/LICENSE +21 -0
cowork_dash/app.py
ADDED
|
@@ -0,0 +1,1776 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import uuid
|
|
3
|
+
import sys
|
|
4
|
+
import json
|
|
5
|
+
import base64
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import platform
|
|
9
|
+
import subprocess
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
import argparse
|
|
13
|
+
import importlib.util
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import Optional, Dict, Any, List
|
|
17
|
+
from dotenv import load_dotenv
|
|
18
|
+
load_dotenv()
|
|
19
|
+
|
|
20
|
+
from dash import Dash, html, Input, Output, State, callback_context, no_update, ALL, clientside_callback
|
|
21
|
+
from dash.exceptions import PreventUpdate
|
|
22
|
+
import dash_mantine_components as dmc
|
|
23
|
+
from dash_iconify import DashIconify
|
|
24
|
+
|
|
25
|
+
# Import custom modules
|
|
26
|
+
from .canvas import parse_canvas_object, export_canvas_to_markdown, load_canvas_from_markdown
|
|
27
|
+
from .file_utils import build_file_tree, render_file_tree, read_file_content, get_file_download_data, load_folder_contents
|
|
28
|
+
from .components import (
|
|
29
|
+
format_message, format_loading, format_thinking, format_todos,
|
|
30
|
+
format_todos_inline, render_canvas_items, format_tool_calls_inline,
|
|
31
|
+
format_interrupt
|
|
32
|
+
)
|
|
33
|
+
from .layout import create_layout as create_layout_component
|
|
34
|
+
|
|
35
|
+
# Import configuration defaults
|
|
36
|
+
from . import config
|
|
37
|
+
|
|
38
|
+
# Generate thread ID
|
|
39
|
+
thread_id = str(uuid.uuid4())
|
|
40
|
+
|
|
41
|
+
# Parse command-line arguments early
|
|
42
|
+
def parse_args():
|
|
43
|
+
"""Parse command-line arguments."""
|
|
44
|
+
parser = argparse.ArgumentParser(
|
|
45
|
+
description="FastDash Browser - AI Agent Web Interface",
|
|
46
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
47
|
+
epilog="""
|
|
48
|
+
Examples:
|
|
49
|
+
# Use defaults from config.py
|
|
50
|
+
python app.py
|
|
51
|
+
|
|
52
|
+
# Override workspace and port
|
|
53
|
+
python app.py --workspace ~/my-workspace --port 8080
|
|
54
|
+
|
|
55
|
+
# Use custom agent from file
|
|
56
|
+
python app.py --agent my_agents.py:my_agent
|
|
57
|
+
|
|
58
|
+
# Production mode
|
|
59
|
+
python app.py --host 0.0.0.0 --port 80 --no-debug
|
|
60
|
+
|
|
61
|
+
# Debug mode with custom workspace
|
|
62
|
+
python app.py --debug --workspace /tmp/test-workspace
|
|
63
|
+
"""
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--workspace",
|
|
68
|
+
type=str,
|
|
69
|
+
help="Workspace directory path (default: from config.py)"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--agent",
|
|
74
|
+
type=str,
|
|
75
|
+
metavar="PATH:OBJECT",
|
|
76
|
+
help='Agent specification as "path/to/file.py:object_name" (e.g., "agent.py:agent" or "my_agents.py:custom_agent")'
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"--port",
|
|
81
|
+
type=int,
|
|
82
|
+
help="Port to run on (default: from config.py)"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--host",
|
|
87
|
+
type=str,
|
|
88
|
+
help="Host to bind to (default: from config.py)"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--debug",
|
|
93
|
+
action="store_true",
|
|
94
|
+
help="Enable debug mode"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
parser.add_argument(
|
|
98
|
+
"--no-debug",
|
|
99
|
+
action="store_true",
|
|
100
|
+
help="Disable debug mode"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
parser.add_argument(
|
|
104
|
+
"--title",
|
|
105
|
+
type=str,
|
|
106
|
+
help="Application title (default: from config.py)"
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
parser.add_argument(
|
|
110
|
+
"--subtitle",
|
|
111
|
+
type=str,
|
|
112
|
+
help="Application subtitle (default: from config.py)"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
"--config",
|
|
117
|
+
type=str,
|
|
118
|
+
help="Path to configuration file (default: config.py)"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return parser.parse_args()
|
|
122
|
+
|
|
123
|
+
def load_agent_from_spec(agent_spec: str):
|
|
124
|
+
"""
|
|
125
|
+
Load agent from specification string in format "path/to/file.py:object_name".
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
agent_spec: String like "agent.py:agent" or "my_agents.py:custom_agent"
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
tuple: (agent_object, error_message)
|
|
132
|
+
"""
|
|
133
|
+
try:
|
|
134
|
+
# Parse the spec
|
|
135
|
+
if ":" not in agent_spec:
|
|
136
|
+
return None, f"Invalid agent spec '{agent_spec}'. Expected format: 'path/to/file.py:object_name'"
|
|
137
|
+
|
|
138
|
+
file_path, object_name = agent_spec.rsplit(":", 1)
|
|
139
|
+
file_path = Path(file_path).resolve()
|
|
140
|
+
|
|
141
|
+
if not file_path.exists():
|
|
142
|
+
return None, f"Agent file not found: {file_path}"
|
|
143
|
+
|
|
144
|
+
# Load the module
|
|
145
|
+
spec = importlib.util.spec_from_file_location("custom_agent_module", file_path)
|
|
146
|
+
if spec is None or spec.loader is None:
|
|
147
|
+
return None, f"Failed to load module from {file_path}"
|
|
148
|
+
|
|
149
|
+
module = importlib.util.module_from_spec(spec)
|
|
150
|
+
sys.modules["custom_agent_module"] = module
|
|
151
|
+
spec.loader.exec_module(module)
|
|
152
|
+
|
|
153
|
+
# Get the object
|
|
154
|
+
if not hasattr(module, object_name):
|
|
155
|
+
return None, f"Object '{object_name}' not found in {file_path}"
|
|
156
|
+
|
|
157
|
+
agent = getattr(module, object_name)
|
|
158
|
+
return agent, None
|
|
159
|
+
|
|
160
|
+
except Exception as e:
|
|
161
|
+
return None, f"Failed to load agent from {agent_spec}: {e}"
|
|
162
|
+
|
|
163
|
+
# Module-level configuration (uses config defaults)
|
|
164
|
+
WORKSPACE_ROOT = config.WORKSPACE_ROOT
|
|
165
|
+
APP_TITLE = config.APP_TITLE
|
|
166
|
+
APP_SUBTITLE = config.APP_SUBTITLE
|
|
167
|
+
PORT = config.PORT
|
|
168
|
+
HOST = config.HOST
|
|
169
|
+
DEBUG = config.DEBUG
|
|
170
|
+
|
|
171
|
+
# Ensure workspace exists
|
|
172
|
+
WORKSPACE_ROOT.mkdir(exist_ok=True, parents=True)
|
|
173
|
+
|
|
174
|
+
# Initialize agent from config
|
|
175
|
+
agent, AGENT_ERROR = load_agent_from_spec(config.AGENT_SPEC)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# =============================================================================
|
|
179
|
+
# STYLING
|
|
180
|
+
# =============================================================================
|
|
181
|
+
|
|
182
|
+
COLORS_LIGHT = {
|
|
183
|
+
"bg_primary": "#ffffff",
|
|
184
|
+
"bg_secondary": "#f8f9fa",
|
|
185
|
+
"bg_tertiary": "#f1f3f4",
|
|
186
|
+
"bg_hover": "#e8eaed",
|
|
187
|
+
"accent": "#1a73e8",
|
|
188
|
+
"accent_light": "#e8f0fe",
|
|
189
|
+
"accent_dark": "#1557b0",
|
|
190
|
+
"text_primary": "#202124",
|
|
191
|
+
"text_secondary": "#5f6368",
|
|
192
|
+
"text_muted": "#80868b",
|
|
193
|
+
"border": "#dadce0",
|
|
194
|
+
"border_light": "#e8eaed",
|
|
195
|
+
"success": "#1e8e3e",
|
|
196
|
+
"warning": "#f9ab00",
|
|
197
|
+
"error": "#d93025",
|
|
198
|
+
"thinking": "#7c4dff",
|
|
199
|
+
"todo": "#00897b",
|
|
200
|
+
"canvas_bg": "#ffffff",
|
|
201
|
+
"interrupt_bg": "#fffbeb",
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
COLORS_DARK = {
|
|
205
|
+
"bg_primary": "#1e1e1e",
|
|
206
|
+
"bg_secondary": "#252526",
|
|
207
|
+
"bg_tertiary": "#2d2d2d",
|
|
208
|
+
"bg_hover": "#3c3c3c",
|
|
209
|
+
"accent": "#4fc3f7",
|
|
210
|
+
"accent_light": "#1e3a5f",
|
|
211
|
+
"accent_dark": "#81d4fa",
|
|
212
|
+
"text_primary": "#e0e0e0",
|
|
213
|
+
"text_secondary": "#b0b0b0",
|
|
214
|
+
"text_muted": "#808080",
|
|
215
|
+
"border": "#404040",
|
|
216
|
+
"border_light": "#333333",
|
|
217
|
+
"success": "#4caf50",
|
|
218
|
+
"warning": "#ffb74d",
|
|
219
|
+
"error": "#ef5350",
|
|
220
|
+
"thinking": "#b388ff",
|
|
221
|
+
"todo": "#26a69a",
|
|
222
|
+
"canvas_bg": "#2d2d2d",
|
|
223
|
+
"interrupt_bg": "#3d3520",
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# Default to light theme
|
|
227
|
+
COLORS = COLORS_LIGHT.copy()
|
|
228
|
+
|
|
229
|
+
STYLES = {
|
|
230
|
+
"shadow": "0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)",
|
|
231
|
+
"transition": "all 0.15s ease",
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
def get_colors(theme: str = "light") -> dict:
|
|
235
|
+
"""Get color scheme based on theme."""
|
|
236
|
+
return COLORS_DARK if theme == "dark" else COLORS_LIGHT
|
|
237
|
+
|
|
238
|
+
# Note: File utilities imported from file_utils module
|
|
239
|
+
# No local wrappers needed - file_utils functions will be called with WORKSPACE_ROOT
|
|
240
|
+
|
|
241
|
+
# =============================================================================
|
|
242
|
+
# AGENT INTERACTION - WITH REAL-TIME STREAMING
|
|
243
|
+
# =============================================================================
|
|
244
|
+
|
|
245
|
+
# Global state for streaming updates
|
|
246
|
+
_agent_state = {
|
|
247
|
+
"running": False,
|
|
248
|
+
"thinking": "",
|
|
249
|
+
"todos": [],
|
|
250
|
+
"tool_calls": [], # Current turn's tool calls (reset each turn)
|
|
251
|
+
"canvas": load_canvas_from_markdown(WORKSPACE_ROOT), # Load from canvas.md if exists
|
|
252
|
+
"response": "",
|
|
253
|
+
"error": None,
|
|
254
|
+
"interrupt": None, # Track interrupt requests for human-in-the-loop
|
|
255
|
+
"last_update": time.time(),
|
|
256
|
+
"start_time": None, # Track when agent started for response time calculation
|
|
257
|
+
}
|
|
258
|
+
_agent_state_lock = threading.Lock()
|
|
259
|
+
|
|
260
|
+
def _run_agent_stream(message: str, resume_data: Dict = None):
|
|
261
|
+
"""Run agent in background thread and update global state in real-time.
|
|
262
|
+
|
|
263
|
+
Args:
|
|
264
|
+
message: User message to send to agent
|
|
265
|
+
resume_data: Optional dict with 'decisions' to resume from interrupt
|
|
266
|
+
"""
|
|
267
|
+
if not agent:
|
|
268
|
+
with _agent_state_lock:
|
|
269
|
+
_agent_state["response"] = f"⚠️ {_agent_state['error']}\n\nPlease check your setup and try again."
|
|
270
|
+
_agent_state["running"] = False
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
# Track tool calls by their ID for updating status
|
|
274
|
+
tool_call_map = {}
|
|
275
|
+
|
|
276
|
+
def _serialize_tool_call(tc) -> Dict:
|
|
277
|
+
"""Serialize a tool call to a dictionary."""
|
|
278
|
+
if isinstance(tc, dict):
|
|
279
|
+
return {
|
|
280
|
+
"id": tc.get("id"),
|
|
281
|
+
"name": tc.get("name"),
|
|
282
|
+
"args": tc.get("args", {}),
|
|
283
|
+
"status": "running",
|
|
284
|
+
"result": None
|
|
285
|
+
}
|
|
286
|
+
else:
|
|
287
|
+
return {
|
|
288
|
+
"id": getattr(tc, 'id', None),
|
|
289
|
+
"name": getattr(tc, 'name', None),
|
|
290
|
+
"args": getattr(tc, 'args', {}),
|
|
291
|
+
"status": "running",
|
|
292
|
+
"result": None
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
def _update_tool_call_result(tool_call_id: str, result: Any, status: str = "success"):
|
|
296
|
+
"""Update a tool call with its result."""
|
|
297
|
+
with _agent_state_lock:
|
|
298
|
+
for tc in _agent_state["tool_calls"]:
|
|
299
|
+
if tc.get("id") == tool_call_id:
|
|
300
|
+
tc["result"] = result
|
|
301
|
+
tc["status"] = status
|
|
302
|
+
break
|
|
303
|
+
_agent_state["last_update"] = time.time()
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
# Prepare input based on whether we're resuming or starting fresh
|
|
307
|
+
stream_config = dict(configurable=dict(thread_id=thread_id))
|
|
308
|
+
|
|
309
|
+
if message == "__RESUME__":
|
|
310
|
+
# Resume from interrupt
|
|
311
|
+
from langgraph.types import Command
|
|
312
|
+
agent_input = Command(resume=resume_data)
|
|
313
|
+
else:
|
|
314
|
+
agent_input = {"messages": [{"role": "user", "content": message}]}
|
|
315
|
+
|
|
316
|
+
for update in agent.stream(agent_input, stream_mode="updates", config=stream_config):
|
|
317
|
+
# Check for interrupt
|
|
318
|
+
if isinstance(update, dict) and "__interrupt__" in update:
|
|
319
|
+
interrupt_value = update["__interrupt__"]
|
|
320
|
+
interrupt_data = _process_interrupt(interrupt_value)
|
|
321
|
+
with _agent_state_lock:
|
|
322
|
+
_agent_state["interrupt"] = interrupt_data
|
|
323
|
+
_agent_state["running"] = False # Pause until user responds
|
|
324
|
+
_agent_state["last_update"] = time.time()
|
|
325
|
+
return # Exit stream, wait for user to resume
|
|
326
|
+
|
|
327
|
+
if isinstance(update, dict):
|
|
328
|
+
for _, state_data in update.items():
|
|
329
|
+
if isinstance(state_data, dict) and "messages" in state_data:
|
|
330
|
+
msgs = state_data["messages"]
|
|
331
|
+
if msgs:
|
|
332
|
+
last_msg = msgs[-1] if isinstance(msgs, list) else msgs
|
|
333
|
+
msg_type = last_msg.__class__.__name__ if hasattr(last_msg, '__class__') else None
|
|
334
|
+
|
|
335
|
+
# Capture AIMessage tool_calls
|
|
336
|
+
if msg_type == 'AIMessage' and hasattr(last_msg, 'tool_calls') and last_msg.tool_calls:
|
|
337
|
+
new_tool_calls = []
|
|
338
|
+
for tc in last_msg.tool_calls:
|
|
339
|
+
serialized = _serialize_tool_call(tc)
|
|
340
|
+
tool_call_map[serialized["id"]] = serialized
|
|
341
|
+
new_tool_calls.append(serialized)
|
|
342
|
+
|
|
343
|
+
with _agent_state_lock:
|
|
344
|
+
_agent_state["tool_calls"].extend(new_tool_calls)
|
|
345
|
+
_agent_state["last_update"] = time.time()
|
|
346
|
+
|
|
347
|
+
elif msg_type == 'ToolMessage' and hasattr(last_msg, 'name'):
|
|
348
|
+
# Update tool call status when we get the result
|
|
349
|
+
tool_call_id = getattr(last_msg, 'tool_call_id', None)
|
|
350
|
+
if tool_call_id:
|
|
351
|
+
# Determine status based on content
|
|
352
|
+
content = last_msg.content
|
|
353
|
+
status = "success"
|
|
354
|
+
if isinstance(content, str) and ("error" in content.lower() or "Error:" in content):
|
|
355
|
+
status = "error"
|
|
356
|
+
elif isinstance(content, dict) and content.get("error"):
|
|
357
|
+
status = "error"
|
|
358
|
+
|
|
359
|
+
# Truncate result for display
|
|
360
|
+
result_display = str(content)
|
|
361
|
+
if len(result_display) > 1000:
|
|
362
|
+
result_display = result_display[:1000] + "..."
|
|
363
|
+
|
|
364
|
+
_update_tool_call_result(tool_call_id, result_display, status)
|
|
365
|
+
|
|
366
|
+
# Handle specific tool messages
|
|
367
|
+
if last_msg.name == 'think_tool':
|
|
368
|
+
content = last_msg.content
|
|
369
|
+
thinking_text = ""
|
|
370
|
+
if isinstance(content, str):
|
|
371
|
+
try:
|
|
372
|
+
parsed = json.loads(content)
|
|
373
|
+
thinking_text = parsed.get('reflection', content)
|
|
374
|
+
except:
|
|
375
|
+
thinking_text = content
|
|
376
|
+
elif isinstance(content, dict):
|
|
377
|
+
thinking_text = content.get('reflection', str(content))
|
|
378
|
+
|
|
379
|
+
# Update state immediately
|
|
380
|
+
with _agent_state_lock:
|
|
381
|
+
_agent_state["thinking"] = thinking_text
|
|
382
|
+
_agent_state["last_update"] = time.time()
|
|
383
|
+
|
|
384
|
+
elif last_msg.name == 'write_todos':
|
|
385
|
+
content = last_msg.content
|
|
386
|
+
todos = []
|
|
387
|
+
if isinstance(content, str):
|
|
388
|
+
import ast
|
|
389
|
+
match = re.search(r'\[.*\]', content, re.DOTALL)
|
|
390
|
+
if match:
|
|
391
|
+
try:
|
|
392
|
+
todos = ast.literal_eval(match.group(0))
|
|
393
|
+
except:
|
|
394
|
+
try:
|
|
395
|
+
todos = json.loads(match.group(0))
|
|
396
|
+
except:
|
|
397
|
+
pass
|
|
398
|
+
elif isinstance(content, list):
|
|
399
|
+
todos = content
|
|
400
|
+
|
|
401
|
+
# Update state immediately
|
|
402
|
+
with _agent_state_lock:
|
|
403
|
+
_agent_state["todos"] = todos
|
|
404
|
+
_agent_state["last_update"] = time.time()
|
|
405
|
+
|
|
406
|
+
elif last_msg.name == 'add_to_canvas':
|
|
407
|
+
content = last_msg.content
|
|
408
|
+
# Canvas tool returns the parsed canvas object
|
|
409
|
+
if isinstance(content, str):
|
|
410
|
+
try:
|
|
411
|
+
parsed = json.loads(content)
|
|
412
|
+
canvas_item = parsed
|
|
413
|
+
except:
|
|
414
|
+
# If not JSON, treat as markdown
|
|
415
|
+
canvas_item = {"type": "markdown", "data": content}
|
|
416
|
+
elif isinstance(content, dict):
|
|
417
|
+
canvas_item = content
|
|
418
|
+
else:
|
|
419
|
+
canvas_item = {"type": "markdown", "data": str(content)}
|
|
420
|
+
|
|
421
|
+
# Update state immediately - append to canvas
|
|
422
|
+
with _agent_state_lock:
|
|
423
|
+
_agent_state["canvas"].append(canvas_item)
|
|
424
|
+
_agent_state["last_update"] = time.time()
|
|
425
|
+
|
|
426
|
+
# Also export to markdown file
|
|
427
|
+
try:
|
|
428
|
+
export_canvas_to_markdown(_agent_state["canvas"], WORKSPACE_ROOT)
|
|
429
|
+
except Exception as e:
|
|
430
|
+
print(f"Failed to export canvas: {e}")
|
|
431
|
+
|
|
432
|
+
elif last_msg.name in ('execute_cell', 'execute_all_cells'):
|
|
433
|
+
# Extract canvas_items from cell execution results
|
|
434
|
+
content = last_msg.content
|
|
435
|
+
canvas_items_to_add = []
|
|
436
|
+
|
|
437
|
+
if isinstance(content, str):
|
|
438
|
+
try:
|
|
439
|
+
parsed = json.loads(content)
|
|
440
|
+
# execute_cell returns a dict, execute_all_cells returns a list
|
|
441
|
+
if isinstance(parsed, dict):
|
|
442
|
+
canvas_items_to_add = parsed.get('canvas_items', [])
|
|
443
|
+
elif isinstance(parsed, list):
|
|
444
|
+
# execute_all_cells returns list of results
|
|
445
|
+
for result in parsed:
|
|
446
|
+
if isinstance(result, dict):
|
|
447
|
+
canvas_items_to_add.extend(result.get('canvas_items', []))
|
|
448
|
+
except:
|
|
449
|
+
pass
|
|
450
|
+
elif isinstance(content, dict):
|
|
451
|
+
canvas_items_to_add = content.get('canvas_items', [])
|
|
452
|
+
elif isinstance(content, list):
|
|
453
|
+
for result in content:
|
|
454
|
+
if isinstance(result, dict):
|
|
455
|
+
canvas_items_to_add.extend(result.get('canvas_items', []))
|
|
456
|
+
|
|
457
|
+
# Add any canvas items found
|
|
458
|
+
if canvas_items_to_add:
|
|
459
|
+
with _agent_state_lock:
|
|
460
|
+
for item in canvas_items_to_add:
|
|
461
|
+
if isinstance(item, dict) and item.get('type'):
|
|
462
|
+
_agent_state["canvas"].append(item)
|
|
463
|
+
_agent_state["last_update"] = time.time()
|
|
464
|
+
|
|
465
|
+
# Export to markdown file
|
|
466
|
+
try:
|
|
467
|
+
export_canvas_to_markdown(_agent_state["canvas"], WORKSPACE_ROOT)
|
|
468
|
+
except Exception as e:
|
|
469
|
+
print(f"Failed to export canvas: {e}")
|
|
470
|
+
|
|
471
|
+
elif hasattr(last_msg, 'content'):
|
|
472
|
+
content = last_msg.content
|
|
473
|
+
response_text = ""
|
|
474
|
+
if isinstance(content, str):
|
|
475
|
+
response_text = re.sub(
|
|
476
|
+
r"\{'id':\s*'[^']+',\s*'input':\s*\{.*?\},\s*'name':\s*'[^']+',\s*'type':\s*'tool_use'\}",
|
|
477
|
+
'', content, flags=re.DOTALL
|
|
478
|
+
).strip()
|
|
479
|
+
elif isinstance(content, list):
|
|
480
|
+
text_parts = [
|
|
481
|
+
block.get("text", "") if isinstance(block, dict) else str(block)
|
|
482
|
+
for block in content
|
|
483
|
+
]
|
|
484
|
+
response_text = " ".join(text_parts).strip()
|
|
485
|
+
|
|
486
|
+
if response_text:
|
|
487
|
+
with _agent_state_lock:
|
|
488
|
+
_agent_state["response"] = response_text
|
|
489
|
+
_agent_state["last_update"] = time.time()
|
|
490
|
+
|
|
491
|
+
except Exception as e:
|
|
492
|
+
with _agent_state_lock:
|
|
493
|
+
_agent_state["error"] = str(e)
|
|
494
|
+
_agent_state["response"] = f"Error: {str(e)}"
|
|
495
|
+
|
|
496
|
+
finally:
|
|
497
|
+
with _agent_state_lock:
|
|
498
|
+
_agent_state["running"] = False
|
|
499
|
+
_agent_state["last_update"] = time.time()
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _process_interrupt(interrupt_value: Any) -> Dict[str, Any]:
|
|
503
|
+
"""Process a LangGraph interrupt value and convert to serializable format.
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
interrupt_value: The interrupt value from LangGraph
|
|
507
|
+
|
|
508
|
+
Returns:
|
|
509
|
+
Dict with 'message' and 'action_requests' for UI display
|
|
510
|
+
"""
|
|
511
|
+
interrupt_data = {
|
|
512
|
+
"message": "The agent needs your input to continue.",
|
|
513
|
+
"action_requests": [],
|
|
514
|
+
"raw": None
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
# Handle different interrupt formats
|
|
518
|
+
if isinstance(interrupt_value, (list, tuple)) and len(interrupt_value) > 0:
|
|
519
|
+
first_item = interrupt_value[0]
|
|
520
|
+
|
|
521
|
+
# Check if it's an Interrupt object (from deepagents interrupt_on)
|
|
522
|
+
if hasattr(first_item, 'value'):
|
|
523
|
+
# This is a LangGraph Interrupt object
|
|
524
|
+
for item in interrupt_value:
|
|
525
|
+
value = getattr(item, 'value', None)
|
|
526
|
+
|
|
527
|
+
# deepagents interrupt_on stores tool call info in a specific format:
|
|
528
|
+
# {'action_requests': [{'name': 'bash', 'args': {...}, 'description': '...'}], 'review_configs': [...]}
|
|
529
|
+
if value is not None and isinstance(value, dict):
|
|
530
|
+
# Check for deepagents format with action_requests
|
|
531
|
+
action_requests = value.get('action_requests', [])
|
|
532
|
+
if action_requests:
|
|
533
|
+
for action_req in action_requests:
|
|
534
|
+
tool_name = action_req.get('name', 'unknown')
|
|
535
|
+
tool_args = action_req.get('args', {})
|
|
536
|
+
interrupt_data["action_requests"].append({
|
|
537
|
+
"type": "tool_call",
|
|
538
|
+
"tool": tool_name,
|
|
539
|
+
"args": tool_args,
|
|
540
|
+
})
|
|
541
|
+
interrupt_data["message"] = f"The agent wants to execute: {tool_name}"
|
|
542
|
+
else:
|
|
543
|
+
# Fallback: direct tool call format
|
|
544
|
+
tool_name = value.get('name', value.get('tool', 'unknown'))
|
|
545
|
+
tool_args = value.get('args', value.get('arguments', {}))
|
|
546
|
+
if tool_name != 'unknown':
|
|
547
|
+
interrupt_data["action_requests"].append({
|
|
548
|
+
"type": "tool_call",
|
|
549
|
+
"tool": tool_name,
|
|
550
|
+
"args": tool_args,
|
|
551
|
+
})
|
|
552
|
+
interrupt_data["message"] = f"The agent wants to execute: {tool_name}"
|
|
553
|
+
else:
|
|
554
|
+
interrupt_data["message"] = str(value)
|
|
555
|
+
elif value is not None:
|
|
556
|
+
interrupt_data["message"] = str(value)
|
|
557
|
+
|
|
558
|
+
# Check if it's an ActionRequest or similar
|
|
559
|
+
elif hasattr(first_item, 'action'):
|
|
560
|
+
for item in interrupt_value:
|
|
561
|
+
action = getattr(item, 'action', None)
|
|
562
|
+
if action:
|
|
563
|
+
interrupt_data["action_requests"].append({
|
|
564
|
+
"type": getattr(action, 'type', 'unknown'),
|
|
565
|
+
"tool": getattr(action, 'name', getattr(action, 'tool', '')),
|
|
566
|
+
"args": getattr(action, 'args', {}),
|
|
567
|
+
})
|
|
568
|
+
elif isinstance(first_item, dict):
|
|
569
|
+
# Check if it's a tool call dict
|
|
570
|
+
if 'name' in first_item or 'tool' in first_item:
|
|
571
|
+
for item in interrupt_value:
|
|
572
|
+
tool_name = item.get('name', item.get('tool', 'unknown'))
|
|
573
|
+
tool_args = item.get('args', item.get('arguments', {}))
|
|
574
|
+
interrupt_data["action_requests"].append({
|
|
575
|
+
"type": "tool_call",
|
|
576
|
+
"tool": tool_name,
|
|
577
|
+
"args": tool_args,
|
|
578
|
+
})
|
|
579
|
+
interrupt_data["message"] = f"The agent wants to execute: {tool_name}"
|
|
580
|
+
else:
|
|
581
|
+
interrupt_data["action_requests"] = list(interrupt_value)
|
|
582
|
+
else:
|
|
583
|
+
interrupt_data["message"] = str(first_item)
|
|
584
|
+
elif isinstance(interrupt_value, str):
|
|
585
|
+
interrupt_data["message"] = interrupt_value
|
|
586
|
+
elif isinstance(interrupt_value, dict):
|
|
587
|
+
interrupt_data["message"] = interrupt_value.get("message", str(interrupt_value))
|
|
588
|
+
interrupt_data["action_requests"] = interrupt_value.get("action_requests", [])
|
|
589
|
+
|
|
590
|
+
# Store raw value for resume
|
|
591
|
+
try:
|
|
592
|
+
interrupt_data["raw"] = interrupt_value
|
|
593
|
+
except:
|
|
594
|
+
pass
|
|
595
|
+
|
|
596
|
+
return interrupt_data
|
|
597
|
+
|
|
598
|
+
def call_agent(message: str, resume_data: Dict = None):
|
|
599
|
+
"""Start agent execution in background thread.
|
|
600
|
+
|
|
601
|
+
Args:
|
|
602
|
+
message: User message to send to agent
|
|
603
|
+
resume_data: Optional dict with decisions to resume from interrupt
|
|
604
|
+
"""
|
|
605
|
+
# Reset state but preserve canvas - do it all atomically
|
|
606
|
+
with _agent_state_lock:
|
|
607
|
+
existing_canvas = _agent_state.get("canvas", []).copy()
|
|
608
|
+
|
|
609
|
+
_agent_state.clear()
|
|
610
|
+
_agent_state.update({
|
|
611
|
+
"running": True,
|
|
612
|
+
"thinking": "",
|
|
613
|
+
"todos": [],
|
|
614
|
+
"tool_calls": [], # Reset tool calls for this turn
|
|
615
|
+
"canvas": existing_canvas, # Preserve existing canvas
|
|
616
|
+
"response": "",
|
|
617
|
+
"error": None,
|
|
618
|
+
"interrupt": None, # Clear any previous interrupt
|
|
619
|
+
"last_update": time.time(),
|
|
620
|
+
"start_time": time.time(), # Track when agent started
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
# Start background thread
|
|
624
|
+
thread = threading.Thread(target=_run_agent_stream, args=(message, resume_data))
|
|
625
|
+
thread.daemon = True
|
|
626
|
+
thread.start()
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def resume_agent_from_interrupt(decision: str, action: str = "approve", action_requests: List[Dict] = None):
|
|
630
|
+
"""Resume agent from an interrupt with the user's decision.
|
|
631
|
+
|
|
632
|
+
Args:
|
|
633
|
+
decision: User's response/decision text
|
|
634
|
+
action: One of 'approve', 'reject', 'edit'
|
|
635
|
+
action_requests: List of action requests from the interrupt (for edit mode)
|
|
636
|
+
"""
|
|
637
|
+
with _agent_state_lock:
|
|
638
|
+
interrupt_data = _agent_state.get("interrupt")
|
|
639
|
+
if not interrupt_data:
|
|
640
|
+
return
|
|
641
|
+
|
|
642
|
+
# Get action requests from interrupt data if not provided
|
|
643
|
+
if action_requests is None:
|
|
644
|
+
action_requests = interrupt_data.get("action_requests", [])
|
|
645
|
+
|
|
646
|
+
# Clear interrupt and set running, but preserve tool_calls and canvas
|
|
647
|
+
existing_tool_calls = _agent_state.get("tool_calls", []).copy()
|
|
648
|
+
existing_canvas = _agent_state.get("canvas", []).copy()
|
|
649
|
+
|
|
650
|
+
_agent_state["interrupt"] = None
|
|
651
|
+
_agent_state["running"] = True
|
|
652
|
+
_agent_state["response"] = "" # Clear any previous response
|
|
653
|
+
_agent_state["error"] = None # Clear any previous error
|
|
654
|
+
_agent_state["tool_calls"] = existing_tool_calls # Keep existing tool calls
|
|
655
|
+
_agent_state["canvas"] = existing_canvas # Keep canvas
|
|
656
|
+
_agent_state["last_update"] = time.time()
|
|
657
|
+
|
|
658
|
+
# Build decisions list in the format expected by deepagents HITL middleware
|
|
659
|
+
# Format: {"decisions": [{"type": "approve"}, {"type": "reject", "message": "..."}, ...]}
|
|
660
|
+
decisions = []
|
|
661
|
+
|
|
662
|
+
if action == "approve":
|
|
663
|
+
# Approve all action requests
|
|
664
|
+
for _ in action_requests:
|
|
665
|
+
decisions.append({"type": "approve"})
|
|
666
|
+
# If no action requests, still add one approve decision
|
|
667
|
+
if not decisions:
|
|
668
|
+
decisions.append({"type": "approve"})
|
|
669
|
+
elif action == "reject":
|
|
670
|
+
# When user rejects, stop the agent immediately instead of resuming
|
|
671
|
+
# Set the response to indicate the action was rejected
|
|
672
|
+
reject_message = decision or "User rejected the action"
|
|
673
|
+
|
|
674
|
+
# Get tool info for the rejection message
|
|
675
|
+
tool_info = ""
|
|
676
|
+
if action_requests:
|
|
677
|
+
tool_names = [ar.get("tool", "unknown") for ar in action_requests]
|
|
678
|
+
tool_info = f" ({', '.join(tool_names)})"
|
|
679
|
+
|
|
680
|
+
with _agent_state_lock:
|
|
681
|
+
_agent_state["running"] = False
|
|
682
|
+
_agent_state["response"] = f"Action rejected{tool_info}: {reject_message}"
|
|
683
|
+
_agent_state["last_update"] = time.time()
|
|
684
|
+
|
|
685
|
+
return # Don't resume the agent
|
|
686
|
+
else: # edit - provide edited action
|
|
687
|
+
# For edit, we need to provide the edited tool call
|
|
688
|
+
# The decision text should contain the edited command/args
|
|
689
|
+
for action_req in action_requests:
|
|
690
|
+
tool_name = action_req.get("tool", "")
|
|
691
|
+
|
|
692
|
+
# If this is a bash command and user provided new command text
|
|
693
|
+
if tool_name == "bash" and decision:
|
|
694
|
+
decisions.append({
|
|
695
|
+
"type": "edit",
|
|
696
|
+
"edited_action": {
|
|
697
|
+
"name": tool_name,
|
|
698
|
+
"args": {"command": decision}
|
|
699
|
+
}
|
|
700
|
+
})
|
|
701
|
+
else:
|
|
702
|
+
# For other tools or no input, just approve
|
|
703
|
+
decisions.append({"type": "approve"})
|
|
704
|
+
|
|
705
|
+
if not decisions:
|
|
706
|
+
decisions.append({"type": "approve"})
|
|
707
|
+
|
|
708
|
+
# Resume value in deepagents format
|
|
709
|
+
resume_value = {"decisions": decisions}
|
|
710
|
+
|
|
711
|
+
# Start background thread with resume value
|
|
712
|
+
# Pass a special marker to indicate this is a resume operation
|
|
713
|
+
thread = threading.Thread(target=_run_agent_stream, args=("__RESUME__", resume_value))
|
|
714
|
+
thread.daemon = True
|
|
715
|
+
thread.start()
|
|
716
|
+
|
|
717
|
+
def get_agent_state() -> Dict[str, Any]:
|
|
718
|
+
"""Get current agent state (thread-safe)."""
|
|
719
|
+
with _agent_state_lock:
|
|
720
|
+
return _agent_state.copy()
|
|
721
|
+
|
|
722
|
+
# =============================================================================
|
|
723
|
+
# DASH APP
|
|
724
|
+
# =============================================================================
|
|
725
|
+
|
|
726
|
+
app = Dash(
|
|
727
|
+
__name__,
|
|
728
|
+
suppress_callback_exceptions=True,
|
|
729
|
+
title=APP_TITLE,
|
|
730
|
+
external_stylesheets=dmc.styles.ALL,
|
|
731
|
+
external_scripts=[
|
|
732
|
+
"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js",
|
|
733
|
+
],
|
|
734
|
+
assets_folder=str(Path(__file__).parent / "assets"),
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
# Custom index string for SVG favicon support
|
|
738
|
+
app.index_string = '''<!DOCTYPE html>
|
|
739
|
+
<html>
|
|
740
|
+
<head>
|
|
741
|
+
{%metas%}
|
|
742
|
+
<title>{%title%}</title>
|
|
743
|
+
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg">
|
|
744
|
+
{%css%}
|
|
745
|
+
</head>
|
|
746
|
+
<body>
|
|
747
|
+
{%app_entry%}
|
|
748
|
+
<footer>
|
|
749
|
+
{%config%}
|
|
750
|
+
{%scripts%}
|
|
751
|
+
{%renderer%}
|
|
752
|
+
</footer>
|
|
753
|
+
</body>
|
|
754
|
+
</html>'''
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
# =============================================================================
|
|
758
|
+
# LAYOUT
|
|
759
|
+
# =============================================================================
|
|
760
|
+
|
|
761
|
+
def create_layout():
|
|
762
|
+
"""Create the app layout with current configuration."""
|
|
763
|
+
return create_layout_component(
|
|
764
|
+
workspace_root=WORKSPACE_ROOT,
|
|
765
|
+
app_title=APP_TITLE,
|
|
766
|
+
app_subtitle=APP_SUBTITLE,
|
|
767
|
+
colors=COLORS,
|
|
768
|
+
styles=STYLES,
|
|
769
|
+
agent=agent
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
# Set layout as a function so it uses current WORKSPACE_ROOT
|
|
773
|
+
app.layout = create_layout
|
|
774
|
+
|
|
775
|
+
# Note: Component rendering functions imported from components module
|
|
776
|
+
# These are used in callbacks below with COLORS and STYLES passed as parameters
|
|
777
|
+
|
|
778
|
+
# =============================================================================
|
|
779
|
+
# CALLBACKS
|
|
780
|
+
# =============================================================================
|
|
781
|
+
|
|
782
|
+
# Initial message display
|
|
783
|
+
@app.callback(
|
|
784
|
+
Output("chat-messages", "children"),
|
|
785
|
+
[Input("chat-history", "data")],
|
|
786
|
+
[State("theme-store", "data")],
|
|
787
|
+
prevent_initial_call=False
|
|
788
|
+
)
|
|
789
|
+
def display_initial_messages(history, theme):
|
|
790
|
+
"""Display initial welcome message or chat history."""
|
|
791
|
+
if not history:
|
|
792
|
+
return []
|
|
793
|
+
|
|
794
|
+
colors = get_colors(theme or "light")
|
|
795
|
+
messages = []
|
|
796
|
+
for msg in history:
|
|
797
|
+
msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
|
|
798
|
+
messages.append(format_message(msg["role"], msg["content"], colors, STYLES, is_new=False, response_time=msg_response_time))
|
|
799
|
+
# Render tool calls stored with this message
|
|
800
|
+
if msg.get("tool_calls"):
|
|
801
|
+
tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
|
|
802
|
+
if tool_calls_block:
|
|
803
|
+
messages.append(tool_calls_block)
|
|
804
|
+
# Render todos stored with this message
|
|
805
|
+
if msg.get("todos"):
|
|
806
|
+
todos_block = format_todos_inline(msg["todos"], colors)
|
|
807
|
+
if todos_block:
|
|
808
|
+
messages.append(todos_block)
|
|
809
|
+
return messages
|
|
810
|
+
|
|
811
|
+
# Chat callbacks
|
|
812
|
+
@app.callback(
|
|
813
|
+
[Output("chat-messages", "children", allow_duplicate=True),
|
|
814
|
+
Output("chat-history", "data", allow_duplicate=True),
|
|
815
|
+
Output("chat-input", "value"),
|
|
816
|
+
Output("pending-message", "data"),
|
|
817
|
+
Output("poll-interval", "disabled")],
|
|
818
|
+
[Input("send-btn", "n_clicks"),
|
|
819
|
+
Input("chat-input", "n_submit")],
|
|
820
|
+
[State("chat-input", "value"),
|
|
821
|
+
State("chat-history", "data"),
|
|
822
|
+
State("theme-store", "data")],
|
|
823
|
+
prevent_initial_call=True
|
|
824
|
+
)
|
|
825
|
+
def handle_send_immediate(n_clicks, n_submit, message, history, theme):
|
|
826
|
+
"""Phase 1: Immediately show user message and start agent."""
|
|
827
|
+
if not message or not message.strip():
|
|
828
|
+
raise PreventUpdate
|
|
829
|
+
|
|
830
|
+
colors = get_colors(theme or "light")
|
|
831
|
+
message = message.strip()
|
|
832
|
+
history = history or []
|
|
833
|
+
history.append({"role": "user", "content": message})
|
|
834
|
+
|
|
835
|
+
# Render all history messages including tool calls and todos
|
|
836
|
+
messages = []
|
|
837
|
+
for i, m in enumerate(history):
|
|
838
|
+
is_new = (i == len(history) - 1)
|
|
839
|
+
msg_response_time = m.get("response_time") if m["role"] == "assistant" else None
|
|
840
|
+
messages.append(format_message(m["role"], m["content"], colors, STYLES, is_new=is_new, response_time=msg_response_time))
|
|
841
|
+
# Render tool calls stored with this message
|
|
842
|
+
if m.get("tool_calls"):
|
|
843
|
+
tool_calls_block = format_tool_calls_inline(m["tool_calls"], colors)
|
|
844
|
+
if tool_calls_block:
|
|
845
|
+
messages.append(tool_calls_block)
|
|
846
|
+
# Render todos stored with this message
|
|
847
|
+
if m.get("todos"):
|
|
848
|
+
todos_block = format_todos_inline(m["todos"], colors)
|
|
849
|
+
if todos_block:
|
|
850
|
+
messages.append(todos_block)
|
|
851
|
+
|
|
852
|
+
messages.append(format_loading(colors))
|
|
853
|
+
|
|
854
|
+
# Start agent in background
|
|
855
|
+
call_agent(message)
|
|
856
|
+
|
|
857
|
+
# Enable polling
|
|
858
|
+
return messages, history, "", message, False
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
@app.callback(
|
|
862
|
+
[Output("chat-messages", "children", allow_duplicate=True),
|
|
863
|
+
Output("chat-history", "data", allow_duplicate=True),
|
|
864
|
+
Output("poll-interval", "disabled", allow_duplicate=True)],
|
|
865
|
+
Input("poll-interval", "n_intervals"),
|
|
866
|
+
[State("chat-history", "data"),
|
|
867
|
+
State("pending-message", "data"),
|
|
868
|
+
State("theme-store", "data")],
|
|
869
|
+
prevent_initial_call=True
|
|
870
|
+
)
|
|
871
|
+
def poll_agent_updates(n_intervals, history, pending_message, theme):
|
|
872
|
+
"""Poll for agent updates and display them in real-time.
|
|
873
|
+
|
|
874
|
+
Tool calls are stored in history and persist across turns.
|
|
875
|
+
History items can be:
|
|
876
|
+
- {"role": "user", "content": "..."} - user message
|
|
877
|
+
- {"role": "assistant", "content": "...", "tool_calls": [...]} - assistant message with tool calls
|
|
878
|
+
"""
|
|
879
|
+
state = get_agent_state()
|
|
880
|
+
history = history or []
|
|
881
|
+
colors = get_colors(theme or "light")
|
|
882
|
+
|
|
883
|
+
def render_history_messages(history_items):
|
|
884
|
+
"""Render all history items including tool calls and todos."""
|
|
885
|
+
messages = []
|
|
886
|
+
for msg in history_items:
|
|
887
|
+
msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
|
|
888
|
+
messages.append(format_message(msg["role"], msg["content"], colors, STYLES, response_time=msg_response_time))
|
|
889
|
+
# Render tool calls stored with this message
|
|
890
|
+
if msg.get("tool_calls"):
|
|
891
|
+
tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
|
|
892
|
+
if tool_calls_block:
|
|
893
|
+
messages.append(tool_calls_block)
|
|
894
|
+
# Render todos stored with this message
|
|
895
|
+
if msg.get("todos"):
|
|
896
|
+
todos_block = format_todos_inline(msg["todos"], colors)
|
|
897
|
+
if todos_block:
|
|
898
|
+
messages.append(todos_block)
|
|
899
|
+
return messages
|
|
900
|
+
|
|
901
|
+
# Check for interrupt (human-in-the-loop)
|
|
902
|
+
if state.get("interrupt"):
|
|
903
|
+
# Agent is paused waiting for user input
|
|
904
|
+
messages = render_history_messages(history)
|
|
905
|
+
|
|
906
|
+
# Add current turn's thinking/tool_calls/todos before interrupt
|
|
907
|
+
if state["thinking"]:
|
|
908
|
+
thinking_block = format_thinking(state["thinking"], colors)
|
|
909
|
+
if thinking_block:
|
|
910
|
+
messages.append(thinking_block)
|
|
911
|
+
|
|
912
|
+
if state.get("tool_calls"):
|
|
913
|
+
tool_calls_block = format_tool_calls_inline(state["tool_calls"], colors)
|
|
914
|
+
if tool_calls_block:
|
|
915
|
+
messages.append(tool_calls_block)
|
|
916
|
+
|
|
917
|
+
if state["todos"]:
|
|
918
|
+
todos_block = format_todos_inline(state["todos"], colors)
|
|
919
|
+
if todos_block:
|
|
920
|
+
messages.append(todos_block)
|
|
921
|
+
|
|
922
|
+
# Add interrupt UI
|
|
923
|
+
interrupt_block = format_interrupt(state["interrupt"], colors)
|
|
924
|
+
if interrupt_block:
|
|
925
|
+
messages.append(interrupt_block)
|
|
926
|
+
|
|
927
|
+
# Disable polling - wait for user to respond to interrupt
|
|
928
|
+
return messages, no_update, True
|
|
929
|
+
|
|
930
|
+
# Check if agent is done
|
|
931
|
+
if not state["running"]:
|
|
932
|
+
# Calculate response time
|
|
933
|
+
response_time = None
|
|
934
|
+
if state.get("start_time"):
|
|
935
|
+
response_time = time.time() - state["start_time"]
|
|
936
|
+
|
|
937
|
+
# Agent finished - store tool calls and todos with the USER message (they appear after user msg)
|
|
938
|
+
if history:
|
|
939
|
+
# Find the last user message and attach tool calls and todos to it
|
|
940
|
+
for i in range(len(history) - 1, -1, -1):
|
|
941
|
+
if history[i]["role"] == "user":
|
|
942
|
+
if state.get("tool_calls"):
|
|
943
|
+
history[i]["tool_calls"] = state["tool_calls"]
|
|
944
|
+
if state.get("todos"):
|
|
945
|
+
history[i]["todos"] = state["todos"]
|
|
946
|
+
break
|
|
947
|
+
|
|
948
|
+
# Add assistant response to history (with response time)
|
|
949
|
+
assistant_msg = {
|
|
950
|
+
"role": "assistant",
|
|
951
|
+
"content": state["response"] if state["response"] else f"Error: {state['error']}",
|
|
952
|
+
"response_time": response_time,
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
history.append(assistant_msg)
|
|
956
|
+
|
|
957
|
+
# Render all history (tool calls and todos are now part of history)
|
|
958
|
+
final_messages = []
|
|
959
|
+
for i, msg in enumerate(history):
|
|
960
|
+
is_new = (i >= len(history) - 1)
|
|
961
|
+
msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
|
|
962
|
+
final_messages.append(format_message(msg["role"], msg["content"], colors, STYLES, is_new=is_new, response_time=msg_response_time))
|
|
963
|
+
# Render tool calls stored with this message
|
|
964
|
+
if msg.get("tool_calls"):
|
|
965
|
+
tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
|
|
966
|
+
if tool_calls_block:
|
|
967
|
+
final_messages.append(tool_calls_block)
|
|
968
|
+
# Render todos stored with this message
|
|
969
|
+
if msg.get("todos"):
|
|
970
|
+
todos_block = format_todos_inline(msg["todos"], colors)
|
|
971
|
+
if todos_block:
|
|
972
|
+
final_messages.append(todos_block)
|
|
973
|
+
|
|
974
|
+
# Disable polling
|
|
975
|
+
return final_messages, history, True
|
|
976
|
+
else:
|
|
977
|
+
# Agent still running - show loading with current thinking/tool_calls/todos
|
|
978
|
+
messages = render_history_messages(history)
|
|
979
|
+
|
|
980
|
+
# Add current thinking if available
|
|
981
|
+
if state["thinking"]:
|
|
982
|
+
thinking_block = format_thinking(state["thinking"], colors)
|
|
983
|
+
if thinking_block:
|
|
984
|
+
messages.append(thinking_block)
|
|
985
|
+
|
|
986
|
+
# Add current tool calls if available
|
|
987
|
+
if state.get("tool_calls"):
|
|
988
|
+
tool_calls_block = format_tool_calls_inline(state["tool_calls"], colors)
|
|
989
|
+
if tool_calls_block:
|
|
990
|
+
messages.append(tool_calls_block)
|
|
991
|
+
|
|
992
|
+
# Add current todos if available
|
|
993
|
+
if state["todos"]:
|
|
994
|
+
todos_block = format_todos_inline(state["todos"], colors)
|
|
995
|
+
if todos_block:
|
|
996
|
+
messages.append(todos_block)
|
|
997
|
+
|
|
998
|
+
# Add loading indicator
|
|
999
|
+
messages.append(format_loading(colors))
|
|
1000
|
+
|
|
1001
|
+
# Continue polling
|
|
1002
|
+
return messages, no_update, False
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
# Interrupt handling callbacks
|
|
1006
|
+
@app.callback(
|
|
1007
|
+
[Output("chat-messages", "children", allow_duplicate=True),
|
|
1008
|
+
Output("poll-interval", "disabled", allow_duplicate=True)],
|
|
1009
|
+
[Input("interrupt-approve-btn", "n_clicks"),
|
|
1010
|
+
Input("interrupt-reject-btn", "n_clicks"),
|
|
1011
|
+
Input("interrupt-edit-btn", "n_clicks")],
|
|
1012
|
+
[State("interrupt-input", "value"),
|
|
1013
|
+
State("chat-history", "data"),
|
|
1014
|
+
State("theme-store", "data")],
|
|
1015
|
+
prevent_initial_call=True
|
|
1016
|
+
)
|
|
1017
|
+
def handle_interrupt_response(approve_clicks, reject_clicks, edit_clicks, input_value, history, theme):
|
|
1018
|
+
"""Handle user response to an interrupt.
|
|
1019
|
+
|
|
1020
|
+
Note: Click parameters are required for Dash callback inputs but we use
|
|
1021
|
+
ctx.triggered to determine which button was clicked.
|
|
1022
|
+
"""
|
|
1023
|
+
ctx = callback_context
|
|
1024
|
+
if not ctx.triggered:
|
|
1025
|
+
raise PreventUpdate
|
|
1026
|
+
|
|
1027
|
+
triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
|
|
1028
|
+
triggered_value = ctx.triggered[0].get("value")
|
|
1029
|
+
|
|
1030
|
+
# Only proceed if there was an actual click (value > 0)
|
|
1031
|
+
if not triggered_value or triggered_value <= 0:
|
|
1032
|
+
raise PreventUpdate
|
|
1033
|
+
|
|
1034
|
+
colors = get_colors(theme or "light")
|
|
1035
|
+
history = history or []
|
|
1036
|
+
|
|
1037
|
+
# Determine action based on which button was clicked
|
|
1038
|
+
if triggered_id == "interrupt-approve-btn":
|
|
1039
|
+
if not approve_clicks or approve_clicks <= 0:
|
|
1040
|
+
raise PreventUpdate
|
|
1041
|
+
action = "approve"
|
|
1042
|
+
decision = input_value or "approved"
|
|
1043
|
+
elif triggered_id == "interrupt-reject-btn":
|
|
1044
|
+
if not reject_clicks or reject_clicks <= 0:
|
|
1045
|
+
raise PreventUpdate
|
|
1046
|
+
action = "reject"
|
|
1047
|
+
decision = input_value or "rejected"
|
|
1048
|
+
elif triggered_id == "interrupt-edit-btn":
|
|
1049
|
+
if not edit_clicks or edit_clicks <= 0:
|
|
1050
|
+
raise PreventUpdate
|
|
1051
|
+
action = "edit"
|
|
1052
|
+
decision = input_value or ""
|
|
1053
|
+
if not decision:
|
|
1054
|
+
raise PreventUpdate # Need input for edit action
|
|
1055
|
+
else:
|
|
1056
|
+
raise PreventUpdate
|
|
1057
|
+
|
|
1058
|
+
# Resume the agent with the user's decision
|
|
1059
|
+
resume_agent_from_interrupt(decision, action)
|
|
1060
|
+
|
|
1061
|
+
# Show loading state while agent resumes
|
|
1062
|
+
messages = []
|
|
1063
|
+
for msg in history:
|
|
1064
|
+
msg_response_time = msg.get("response_time") if msg["role"] == "assistant" else None
|
|
1065
|
+
messages.append(format_message(msg["role"], msg["content"], colors, STYLES, response_time=msg_response_time))
|
|
1066
|
+
# Render tool calls stored with this message
|
|
1067
|
+
if msg.get("tool_calls"):
|
|
1068
|
+
tool_calls_block = format_tool_calls_inline(msg["tool_calls"], colors)
|
|
1069
|
+
if tool_calls_block:
|
|
1070
|
+
messages.append(tool_calls_block)
|
|
1071
|
+
# Render todos stored with this message
|
|
1072
|
+
if msg.get("todos"):
|
|
1073
|
+
todos_block = format_todos_inline(msg["todos"], colors)
|
|
1074
|
+
if todos_block:
|
|
1075
|
+
messages.append(todos_block)
|
|
1076
|
+
|
|
1077
|
+
messages.append(format_loading(colors))
|
|
1078
|
+
|
|
1079
|
+
# Re-enable polling
|
|
1080
|
+
return messages, False
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
# Folder toggle callback
|
|
1084
|
+
@app.callback(
|
|
1085
|
+
[Output({"type": "folder-children", "path": ALL}, "style"),
|
|
1086
|
+
Output({"type": "folder-icon", "path": ALL}, "style"),
|
|
1087
|
+
Output({"type": "folder-children", "path": ALL}, "children")],
|
|
1088
|
+
Input({"type": "folder-header", "path": ALL}, "n_clicks"),
|
|
1089
|
+
[State({"type": "folder-header", "path": ALL}, "data-realpath"),
|
|
1090
|
+
State({"type": "folder-children", "path": ALL}, "id"),
|
|
1091
|
+
State({"type": "folder-icon", "path": ALL}, "id"),
|
|
1092
|
+
State({"type": "folder-children", "path": ALL}, "style"),
|
|
1093
|
+
State({"type": "folder-icon", "path": ALL}, "style"),
|
|
1094
|
+
State({"type": "folder-children", "path": ALL}, "children"),
|
|
1095
|
+
State("theme-store", "data")],
|
|
1096
|
+
prevent_initial_call=True
|
|
1097
|
+
)
|
|
1098
|
+
def toggle_folder(n_clicks, real_paths, children_ids, icon_ids, children_styles, icon_styles, children_content, theme):
|
|
1099
|
+
"""Toggle folder expansion and lazy load contents if needed."""
|
|
1100
|
+
ctx = callback_context
|
|
1101
|
+
if not ctx.triggered or not any(n_clicks):
|
|
1102
|
+
raise PreventUpdate
|
|
1103
|
+
|
|
1104
|
+
colors = get_colors(theme or "light")
|
|
1105
|
+
triggered = ctx.triggered[0]["prop_id"]
|
|
1106
|
+
try:
|
|
1107
|
+
id_str = triggered.rsplit(".", 1)[0]
|
|
1108
|
+
id_dict = json.loads(id_str)
|
|
1109
|
+
clicked_path = id_dict.get("path")
|
|
1110
|
+
except:
|
|
1111
|
+
raise PreventUpdate
|
|
1112
|
+
|
|
1113
|
+
# Find the index of the clicked folder to get its real path
|
|
1114
|
+
clicked_idx = None
|
|
1115
|
+
for i, icon_id in enumerate(icon_ids):
|
|
1116
|
+
if icon_id["path"] == clicked_path:
|
|
1117
|
+
clicked_idx = i
|
|
1118
|
+
break
|
|
1119
|
+
|
|
1120
|
+
if clicked_idx is None:
|
|
1121
|
+
raise PreventUpdate
|
|
1122
|
+
|
|
1123
|
+
folder_rel_path = real_paths[clicked_idx] if clicked_idx < len(real_paths) else None
|
|
1124
|
+
if not folder_rel_path:
|
|
1125
|
+
raise PreventUpdate
|
|
1126
|
+
|
|
1127
|
+
new_children_styles = []
|
|
1128
|
+
new_icon_styles = []
|
|
1129
|
+
new_children_content = []
|
|
1130
|
+
|
|
1131
|
+
# Process all folder-children elements
|
|
1132
|
+
for i, child_id in enumerate(children_ids):
|
|
1133
|
+
path = child_id["path"]
|
|
1134
|
+
current_style = children_styles[i] if i < len(children_styles) else {"display": "none"}
|
|
1135
|
+
current_content = children_content[i] if i < len(children_content) else []
|
|
1136
|
+
|
|
1137
|
+
if path == clicked_path:
|
|
1138
|
+
# Toggle this folder
|
|
1139
|
+
is_expanded = current_style.get("display") != "none"
|
|
1140
|
+
new_children_styles.append({"display": "none" if is_expanded else "block"})
|
|
1141
|
+
|
|
1142
|
+
# If expanding and content is just "Loading...", load the actual contents
|
|
1143
|
+
if not is_expanded and current_content:
|
|
1144
|
+
# Check if content is the loading placeholder
|
|
1145
|
+
if (isinstance(current_content, list) and len(current_content) == 1 and
|
|
1146
|
+
isinstance(current_content[0], dict) and
|
|
1147
|
+
current_content[0].get("props", {}).get("children") == "Loading..."):
|
|
1148
|
+
# Load folder contents using real path
|
|
1149
|
+
try:
|
|
1150
|
+
folder_items = load_folder_contents(folder_rel_path, WORKSPACE_ROOT)
|
|
1151
|
+
loaded_content = render_file_tree(folder_items, colors, STYLES,
|
|
1152
|
+
level=folder_rel_path.count("/") + 1,
|
|
1153
|
+
parent_path=folder_rel_path)
|
|
1154
|
+
new_children_content.append(loaded_content if loaded_content else current_content)
|
|
1155
|
+
except Exception as e:
|
|
1156
|
+
print(f"Error loading folder {folder_rel_path}: {e}")
|
|
1157
|
+
new_children_content.append(current_content)
|
|
1158
|
+
else:
|
|
1159
|
+
new_children_content.append(current_content)
|
|
1160
|
+
else:
|
|
1161
|
+
new_children_content.append(current_content)
|
|
1162
|
+
else:
|
|
1163
|
+
new_children_styles.append(current_style)
|
|
1164
|
+
new_children_content.append(current_content)
|
|
1165
|
+
|
|
1166
|
+
# Process all folder-icon elements
|
|
1167
|
+
for i, icon_id in enumerate(icon_ids):
|
|
1168
|
+
path = icon_id["path"]
|
|
1169
|
+
current_icon_style = icon_styles[i] if i < len(icon_styles) else {}
|
|
1170
|
+
|
|
1171
|
+
if path == clicked_path:
|
|
1172
|
+
# Find corresponding children style to check if expanded
|
|
1173
|
+
children_idx = next((idx for idx, cid in enumerate(children_ids) if cid["path"] == path), None)
|
|
1174
|
+
if children_idx is not None:
|
|
1175
|
+
current_children_style = children_styles[children_idx] if children_idx < len(children_styles) else {"display": "none"}
|
|
1176
|
+
is_expanded = current_children_style.get("display") != "none"
|
|
1177
|
+
new_icon_styles.append({
|
|
1178
|
+
"marginRight": "8px",
|
|
1179
|
+
"fontSize": "10px",
|
|
1180
|
+
"color": colors["text_muted"],
|
|
1181
|
+
"transition": "transform 0.2s",
|
|
1182
|
+
"display": "inline-block",
|
|
1183
|
+
"transform": "rotate(0deg)" if is_expanded else "rotate(90deg)",
|
|
1184
|
+
})
|
|
1185
|
+
else:
|
|
1186
|
+
new_icon_styles.append(current_icon_style)
|
|
1187
|
+
else:
|
|
1188
|
+
new_icon_styles.append(current_icon_style)
|
|
1189
|
+
|
|
1190
|
+
return new_children_styles, new_icon_styles, new_children_content
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
# File click - open modal
|
|
1194
|
+
@app.callback(
|
|
1195
|
+
[Output("file-modal", "opened"),
|
|
1196
|
+
Output("file-modal", "title"),
|
|
1197
|
+
Output("modal-content", "children"),
|
|
1198
|
+
Output("file-to-view", "data"),
|
|
1199
|
+
Output("file-click-tracker", "data")],
|
|
1200
|
+
Input({"type": "file-item", "path": ALL}, "n_clicks"),
|
|
1201
|
+
[State({"type": "file-item", "path": ALL}, "id"),
|
|
1202
|
+
State("file-click-tracker", "data"),
|
|
1203
|
+
State("theme-store", "data")],
|
|
1204
|
+
prevent_initial_call=True
|
|
1205
|
+
)
|
|
1206
|
+
def open_file_modal(all_n_clicks, all_ids, click_tracker, theme):
|
|
1207
|
+
"""Open file in modal - only on actual new clicks."""
|
|
1208
|
+
ctx = callback_context
|
|
1209
|
+
|
|
1210
|
+
if not ctx.triggered_id:
|
|
1211
|
+
raise PreventUpdate
|
|
1212
|
+
|
|
1213
|
+
# ctx.triggered_id is the dict {"type": "file-item", "path": "..."}
|
|
1214
|
+
if not isinstance(ctx.triggered_id, dict):
|
|
1215
|
+
raise PreventUpdate
|
|
1216
|
+
|
|
1217
|
+
if ctx.triggered_id.get("type") != "file-item":
|
|
1218
|
+
raise PreventUpdate
|
|
1219
|
+
|
|
1220
|
+
file_path = ctx.triggered_id.get("path")
|
|
1221
|
+
if not file_path:
|
|
1222
|
+
raise PreventUpdate
|
|
1223
|
+
|
|
1224
|
+
# Find the index of the triggered item to get its click count
|
|
1225
|
+
clicked_idx = None
|
|
1226
|
+
for i, item_id in enumerate(all_ids):
|
|
1227
|
+
if item_id.get("path") == file_path:
|
|
1228
|
+
clicked_idx = i
|
|
1229
|
+
break
|
|
1230
|
+
|
|
1231
|
+
if clicked_idx is None:
|
|
1232
|
+
raise PreventUpdate
|
|
1233
|
+
|
|
1234
|
+
# Get current click count for this file
|
|
1235
|
+
current_clicks = all_n_clicks[clicked_idx] if clicked_idx < len(all_n_clicks) else None
|
|
1236
|
+
|
|
1237
|
+
# Must be an actual click (not None, not 0)
|
|
1238
|
+
if not current_clicks:
|
|
1239
|
+
raise PreventUpdate
|
|
1240
|
+
|
|
1241
|
+
# Check if this is a NEW click vs a re-render with existing clicks
|
|
1242
|
+
click_tracker = click_tracker or {}
|
|
1243
|
+
prev_clicks = click_tracker.get(file_path, 0)
|
|
1244
|
+
|
|
1245
|
+
# Update tracker regardless of whether we open modal
|
|
1246
|
+
new_tracker = click_tracker.copy()
|
|
1247
|
+
new_tracker[file_path] = current_clicks
|
|
1248
|
+
|
|
1249
|
+
if current_clicks <= prev_clicks:
|
|
1250
|
+
# Not a new click - component was re-rendered or this click was already processed
|
|
1251
|
+
# Still need to return updated tracker to avoid stale state
|
|
1252
|
+
raise PreventUpdate
|
|
1253
|
+
|
|
1254
|
+
# Verify file exists and is a file
|
|
1255
|
+
full_path = WORKSPACE_ROOT / file_path
|
|
1256
|
+
if not full_path.exists() or not full_path.is_file():
|
|
1257
|
+
raise PreventUpdate
|
|
1258
|
+
|
|
1259
|
+
colors = get_colors(theme or "light")
|
|
1260
|
+
content, is_text, error = read_file_content(WORKSPACE_ROOT, file_path)
|
|
1261
|
+
filename = Path(file_path).name
|
|
1262
|
+
|
|
1263
|
+
if is_text and content:
|
|
1264
|
+
modal_content = html.Pre(
|
|
1265
|
+
content,
|
|
1266
|
+
style={
|
|
1267
|
+
"background": colors["bg_tertiary"],
|
|
1268
|
+
"padding": "16px",
|
|
1269
|
+
"fontSize": "12px",
|
|
1270
|
+
"fontFamily": "'IBM Plex Mono', monospace",
|
|
1271
|
+
"overflow": "auto",
|
|
1272
|
+
"maxHeight": "60vh",
|
|
1273
|
+
"whiteSpace": "pre-wrap",
|
|
1274
|
+
"wordBreak": "break-word",
|
|
1275
|
+
"margin": "0",
|
|
1276
|
+
"color": colors["text_primary"],
|
|
1277
|
+
}
|
|
1278
|
+
)
|
|
1279
|
+
else:
|
|
1280
|
+
modal_content = html.Div([
|
|
1281
|
+
html.P(error or "Cannot display file", style={
|
|
1282
|
+
"color": colors["text_muted"],
|
|
1283
|
+
"textAlign": "center",
|
|
1284
|
+
"padding": "40px",
|
|
1285
|
+
}),
|
|
1286
|
+
html.P("Click Download to save the file.", style={
|
|
1287
|
+
"color": colors["text_muted"],
|
|
1288
|
+
"textAlign": "center",
|
|
1289
|
+
"fontSize": "13px",
|
|
1290
|
+
})
|
|
1291
|
+
])
|
|
1292
|
+
|
|
1293
|
+
return True, filename, modal_content, file_path, new_tracker
|
|
1294
|
+
|
|
1295
|
+
# Modal download button
|
|
1296
|
+
@app.callback(
|
|
1297
|
+
Output("file-download", "data", allow_duplicate=True),
|
|
1298
|
+
Input("modal-download-btn", "n_clicks"),
|
|
1299
|
+
State("file-to-view", "data"),
|
|
1300
|
+
prevent_initial_call=True
|
|
1301
|
+
)
|
|
1302
|
+
def download_from_modal(n_clicks, file_path):
|
|
1303
|
+
"""Download file from modal."""
|
|
1304
|
+
ctx = callback_context
|
|
1305
|
+
if not ctx.triggered:
|
|
1306
|
+
raise PreventUpdate
|
|
1307
|
+
|
|
1308
|
+
# Verify this callback was actually triggered by the download button
|
|
1309
|
+
triggered_id = ctx.triggered[0]["prop_id"].split(".")[0]
|
|
1310
|
+
if triggered_id != "modal-download-btn":
|
|
1311
|
+
raise PreventUpdate
|
|
1312
|
+
|
|
1313
|
+
if not n_clicks or not file_path:
|
|
1314
|
+
raise PreventUpdate
|
|
1315
|
+
|
|
1316
|
+
b64, filename, mime = get_file_download_data(WORKSPACE_ROOT, file_path)
|
|
1317
|
+
if not b64:
|
|
1318
|
+
raise PreventUpdate
|
|
1319
|
+
|
|
1320
|
+
return dict(content=b64, filename=filename, base64=True, type=mime)
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
# Open terminal
|
|
1324
|
+
@app.callback(
|
|
1325
|
+
Output("open-terminal-btn", "n_clicks"),
|
|
1326
|
+
Input("open-terminal-btn", "n_clicks"),
|
|
1327
|
+
prevent_initial_call=True
|
|
1328
|
+
)
|
|
1329
|
+
def open_terminal(n_clicks):
|
|
1330
|
+
"""Open system terminal at workspace directory."""
|
|
1331
|
+
if not n_clicks:
|
|
1332
|
+
raise PreventUpdate
|
|
1333
|
+
|
|
1334
|
+
workspace_path = str(WORKSPACE_ROOT)
|
|
1335
|
+
system = platform.system()
|
|
1336
|
+
|
|
1337
|
+
try:
|
|
1338
|
+
if system == "Darwin": # macOS
|
|
1339
|
+
subprocess.Popen(["open", "-a", "Terminal", workspace_path])
|
|
1340
|
+
elif system == "Windows":
|
|
1341
|
+
subprocess.Popen(["cmd", "/c", "start", "cmd", "/K", f"cd /d {workspace_path}"], shell=True)
|
|
1342
|
+
else: # Linux
|
|
1343
|
+
# Try common terminal emulators
|
|
1344
|
+
terminals = [
|
|
1345
|
+
["gnome-terminal", f"--working-directory={workspace_path}"],
|
|
1346
|
+
["konsole", f"--workdir={workspace_path}"],
|
|
1347
|
+
["xfce4-terminal", f"--working-directory={workspace_path}"],
|
|
1348
|
+
["xterm", "-e", f"cd {workspace_path} && $SHELL"],
|
|
1349
|
+
]
|
|
1350
|
+
for term_cmd in terminals:
|
|
1351
|
+
try:
|
|
1352
|
+
subprocess.Popen(term_cmd)
|
|
1353
|
+
break
|
|
1354
|
+
except FileNotFoundError:
|
|
1355
|
+
continue
|
|
1356
|
+
except Exception as e:
|
|
1357
|
+
print(f"Failed to open terminal: {e}")
|
|
1358
|
+
|
|
1359
|
+
raise PreventUpdate
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
# Refresh both file tree and canvas content
|
|
1363
|
+
@app.callback(
|
|
1364
|
+
[Output("file-tree", "children"),
|
|
1365
|
+
Output("canvas-content", "children", allow_duplicate=True)],
|
|
1366
|
+
Input("refresh-btn", "n_clicks"),
|
|
1367
|
+
[State("theme-store", "data")],
|
|
1368
|
+
prevent_initial_call=True
|
|
1369
|
+
)
|
|
1370
|
+
def refresh_sidebar(n_clicks, theme):
|
|
1371
|
+
"""Refresh both file tree and canvas content."""
|
|
1372
|
+
global _agent_state
|
|
1373
|
+
colors = get_colors(theme or "light")
|
|
1374
|
+
|
|
1375
|
+
# Refresh file tree
|
|
1376
|
+
file_tree = render_file_tree(build_file_tree(WORKSPACE_ROOT, WORKSPACE_ROOT), colors, STYLES)
|
|
1377
|
+
|
|
1378
|
+
# Refresh canvas by reloading from .canvas/canvas.md file
|
|
1379
|
+
canvas_items = load_canvas_from_markdown(WORKSPACE_ROOT)
|
|
1380
|
+
|
|
1381
|
+
# Update agent state with reloaded canvas
|
|
1382
|
+
with _agent_state_lock:
|
|
1383
|
+
_agent_state["canvas"] = canvas_items
|
|
1384
|
+
|
|
1385
|
+
# Render the canvas items
|
|
1386
|
+
canvas_content = render_canvas_items(canvas_items, colors)
|
|
1387
|
+
|
|
1388
|
+
return file_tree, canvas_content
|
|
1389
|
+
|
|
1390
|
+
|
|
1391
|
+
# File upload
|
|
1392
|
+
@app.callback(
|
|
1393
|
+
[Output("upload-status", "children"),
|
|
1394
|
+
Output("file-tree", "children", allow_duplicate=True)],
|
|
1395
|
+
Input("file-upload", "contents"),
|
|
1396
|
+
[State("file-upload", "filename"),
|
|
1397
|
+
State("theme-store", "data")],
|
|
1398
|
+
prevent_initial_call=True
|
|
1399
|
+
)
|
|
1400
|
+
def handle_upload(contents, filenames, theme):
|
|
1401
|
+
"""Handle file uploads."""
|
|
1402
|
+
if not contents:
|
|
1403
|
+
raise PreventUpdate
|
|
1404
|
+
|
|
1405
|
+
colors = get_colors(theme or "light")
|
|
1406
|
+
uploaded = []
|
|
1407
|
+
for content, filename in zip(contents, filenames):
|
|
1408
|
+
try:
|
|
1409
|
+
_, content_string = content.split(',')
|
|
1410
|
+
decoded = base64.b64decode(content_string)
|
|
1411
|
+
file_path = WORKSPACE_ROOT / filename
|
|
1412
|
+
try:
|
|
1413
|
+
file_path.write_text(decoded.decode('utf-8'))
|
|
1414
|
+
except UnicodeDecodeError:
|
|
1415
|
+
file_path.write_bytes(decoded)
|
|
1416
|
+
uploaded.append(filename)
|
|
1417
|
+
except Exception as e:
|
|
1418
|
+
print(f"Upload error: {e}")
|
|
1419
|
+
|
|
1420
|
+
if uploaded:
|
|
1421
|
+
return f"Uploaded: {', '.join(uploaded)}", render_file_tree(build_file_tree(WORKSPACE_ROOT, WORKSPACE_ROOT), colors, STYLES)
|
|
1422
|
+
return "Upload failed", no_update
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
# View toggle callbacks - using SegmentedControl
|
|
1426
|
+
@app.callback(
|
|
1427
|
+
[Output("files-view", "style"),
|
|
1428
|
+
Output("canvas-view", "style"),
|
|
1429
|
+
Output("open-terminal-btn", "style")],
|
|
1430
|
+
[Input("sidebar-view-toggle", "value")],
|
|
1431
|
+
prevent_initial_call=True
|
|
1432
|
+
)
|
|
1433
|
+
def toggle_view(view_value):
|
|
1434
|
+
"""Toggle between files and canvas view using SegmentedControl."""
|
|
1435
|
+
if not view_value:
|
|
1436
|
+
raise PreventUpdate
|
|
1437
|
+
|
|
1438
|
+
if view_value == "canvas":
|
|
1439
|
+
# Show canvas, hide files, hide terminal button (not relevant for canvas)
|
|
1440
|
+
return (
|
|
1441
|
+
{"flex": "1", "display": "none", "flexDirection": "column"},
|
|
1442
|
+
{
|
|
1443
|
+
"flex": "1",
|
|
1444
|
+
"minHeight": "0",
|
|
1445
|
+
"display": "flex",
|
|
1446
|
+
"flexDirection": "column",
|
|
1447
|
+
"overflow": "hidden"
|
|
1448
|
+
},
|
|
1449
|
+
{"display": "none"} # Hide terminal button on canvas view
|
|
1450
|
+
)
|
|
1451
|
+
else:
|
|
1452
|
+
# Show files, hide canvas, show terminal button
|
|
1453
|
+
return (
|
|
1454
|
+
{
|
|
1455
|
+
"flex": "1",
|
|
1456
|
+
"minHeight": "0",
|
|
1457
|
+
"display": "flex",
|
|
1458
|
+
"flexDirection": "column",
|
|
1459
|
+
"paddingBottom": "5%"
|
|
1460
|
+
},
|
|
1461
|
+
{
|
|
1462
|
+
"flex": "1",
|
|
1463
|
+
"minHeight": "0",
|
|
1464
|
+
"display": "none",
|
|
1465
|
+
"flexDirection": "column",
|
|
1466
|
+
"overflow": "hidden"
|
|
1467
|
+
},
|
|
1468
|
+
{} # Show terminal button (default styles)
|
|
1469
|
+
)
|
|
1470
|
+
|
|
1471
|
+
|
|
1472
|
+
# Canvas content update
|
|
1473
|
+
@app.callback(
|
|
1474
|
+
Output("canvas-content", "children"),
|
|
1475
|
+
[Input("poll-interval", "n_intervals"),
|
|
1476
|
+
Input("sidebar-view-toggle", "value")],
|
|
1477
|
+
[State("theme-store", "data")],
|
|
1478
|
+
prevent_initial_call=False
|
|
1479
|
+
)
|
|
1480
|
+
def update_canvas_content(n_intervals, view_value, theme):
|
|
1481
|
+
"""Update canvas content from agent state."""
|
|
1482
|
+
state = get_agent_state()
|
|
1483
|
+
canvas_items = state.get("canvas", [])
|
|
1484
|
+
colors = get_colors(theme or "light")
|
|
1485
|
+
|
|
1486
|
+
# Use imported rendering function
|
|
1487
|
+
return render_canvas_items(canvas_items, colors)
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
# Clear canvas callback
|
|
1492
|
+
@app.callback(
|
|
1493
|
+
Output("canvas-content", "children", allow_duplicate=True),
|
|
1494
|
+
Input("clear-canvas-btn", "n_clicks"),
|
|
1495
|
+
[State("theme-store", "data")],
|
|
1496
|
+
prevent_initial_call=True
|
|
1497
|
+
)
|
|
1498
|
+
def clear_canvas(n_clicks, theme):
|
|
1499
|
+
"""Clear the canvas and archive the .canvas folder with a timestamp."""
|
|
1500
|
+
if not n_clicks:
|
|
1501
|
+
raise PreventUpdate
|
|
1502
|
+
|
|
1503
|
+
global _agent_state
|
|
1504
|
+
colors = get_colors(theme or "light")
|
|
1505
|
+
|
|
1506
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
1507
|
+
|
|
1508
|
+
# Archive .canvas folder if it exists (contains canvas.md and all assets)
|
|
1509
|
+
canvas_dir = WORKSPACE_ROOT / ".canvas"
|
|
1510
|
+
if canvas_dir.exists() and canvas_dir.is_dir():
|
|
1511
|
+
try:
|
|
1512
|
+
archive_dir = WORKSPACE_ROOT / f".canvas_{timestamp}"
|
|
1513
|
+
shutil.move(str(canvas_dir), str(archive_dir))
|
|
1514
|
+
print(f"Archived .canvas folder to {archive_dir}")
|
|
1515
|
+
except Exception as e:
|
|
1516
|
+
print(f"Failed to archive .canvas folder: {e}")
|
|
1517
|
+
|
|
1518
|
+
# Clear canvas in state
|
|
1519
|
+
with _agent_state_lock:
|
|
1520
|
+
_agent_state["canvas"] = []
|
|
1521
|
+
|
|
1522
|
+
# Return empty state
|
|
1523
|
+
return html.Div([
|
|
1524
|
+
html.Div("🗒", style={
|
|
1525
|
+
"fontSize": "48px",
|
|
1526
|
+
"textAlign": "center",
|
|
1527
|
+
"marginBottom": "16px",
|
|
1528
|
+
"opacity": "0.3"
|
|
1529
|
+
}),
|
|
1530
|
+
html.P("Canvas is empty", style={
|
|
1531
|
+
"textAlign": "center",
|
|
1532
|
+
"color": colors["text_muted"],
|
|
1533
|
+
"fontSize": "14px"
|
|
1534
|
+
}),
|
|
1535
|
+
html.P("The agent will add visualizations, charts, and notes here", style={
|
|
1536
|
+
"textAlign": "center",
|
|
1537
|
+
"color": colors["text_muted"],
|
|
1538
|
+
"fontSize": "12px",
|
|
1539
|
+
"marginTop": "8px"
|
|
1540
|
+
})
|
|
1541
|
+
], style={
|
|
1542
|
+
"display": "flex",
|
|
1543
|
+
"flexDirection": "column",
|
|
1544
|
+
"alignItems": "center",
|
|
1545
|
+
"justifyContent": "center",
|
|
1546
|
+
"height": "100%",
|
|
1547
|
+
"padding": "40px"
|
|
1548
|
+
})
|
|
1549
|
+
|
|
1550
|
+
|
|
1551
|
+
# =============================================================================
|
|
1552
|
+
# THEME TOGGLE CALLBACK - Using DMC 2.4 forceColorScheme
|
|
1553
|
+
# =============================================================================
|
|
1554
|
+
|
|
1555
|
+
@app.callback(
|
|
1556
|
+
[Output("theme-store", "data"),
|
|
1557
|
+
Output("mantine-provider", "forceColorScheme"),
|
|
1558
|
+
Output("theme-toggle-btn", "children")],
|
|
1559
|
+
[Input("theme-toggle-btn", "n_clicks")],
|
|
1560
|
+
[State("theme-store", "data")],
|
|
1561
|
+
prevent_initial_call=True
|
|
1562
|
+
)
|
|
1563
|
+
def toggle_theme(n_clicks, current_theme):
|
|
1564
|
+
"""Toggle between light and dark theme using DMC's forceColorScheme."""
|
|
1565
|
+
if not n_clicks:
|
|
1566
|
+
raise PreventUpdate
|
|
1567
|
+
|
|
1568
|
+
# Toggle theme
|
|
1569
|
+
new_theme = "dark" if current_theme == "light" else "light"
|
|
1570
|
+
|
|
1571
|
+
# Update the icon
|
|
1572
|
+
toggle_icon = DashIconify(
|
|
1573
|
+
icon="radix-icons:sun" if new_theme == "dark" else "radix-icons:moon",
|
|
1574
|
+
width=18
|
|
1575
|
+
)
|
|
1576
|
+
|
|
1577
|
+
return new_theme, new_theme, toggle_icon
|
|
1578
|
+
|
|
1579
|
+
|
|
1580
|
+
# Callback to initialize theme on page load
|
|
1581
|
+
@app.callback(
|
|
1582
|
+
[Output("mantine-provider", "forceColorScheme", allow_duplicate=True),
|
|
1583
|
+
Output("theme-toggle-btn", "children", allow_duplicate=True)],
|
|
1584
|
+
[Input("theme-store", "data")],
|
|
1585
|
+
prevent_initial_call='initial_duplicate'
|
|
1586
|
+
)
|
|
1587
|
+
def initialize_theme(theme):
|
|
1588
|
+
"""Initialize theme on page load from stored preference."""
|
|
1589
|
+
if not theme:
|
|
1590
|
+
theme = "light"
|
|
1591
|
+
|
|
1592
|
+
toggle_icon = DashIconify(
|
|
1593
|
+
icon="radix-icons:sun" if theme == "dark" else "radix-icons:moon",
|
|
1594
|
+
width=18
|
|
1595
|
+
)
|
|
1596
|
+
|
|
1597
|
+
return theme, toggle_icon
|
|
1598
|
+
|
|
1599
|
+
|
|
1600
|
+
# =============================================================================
|
|
1601
|
+
# PROGRAMMATIC API
|
|
1602
|
+
# =============================================================================
|
|
1603
|
+
|
|
1604
|
+
def run_app(
|
|
1605
|
+
agent_instance=None,
|
|
1606
|
+
workspace=None,
|
|
1607
|
+
agent_spec=None,
|
|
1608
|
+
port=None,
|
|
1609
|
+
host=None,
|
|
1610
|
+
debug=None,
|
|
1611
|
+
title=None,
|
|
1612
|
+
subtitle=None,
|
|
1613
|
+
config_file=None
|
|
1614
|
+
):
|
|
1615
|
+
"""
|
|
1616
|
+
Run DeepAgent Dash programmatically.
|
|
1617
|
+
|
|
1618
|
+
This function can be called from Python code or used as the entry point
|
|
1619
|
+
for the CLI. It handles configuration loading and overrides.
|
|
1620
|
+
|
|
1621
|
+
Args:
|
|
1622
|
+
agent_instance (object, optional): Agent object instance (Python API only)
|
|
1623
|
+
workspace (str, optional): Workspace directory path
|
|
1624
|
+
agent_spec (str, optional): Agent specification as "path:object" (overrides agent_instance)
|
|
1625
|
+
port (int, optional): Port number
|
|
1626
|
+
host (str, optional): Host to bind to
|
|
1627
|
+
debug (bool, optional): Debug mode
|
|
1628
|
+
title (str, optional): Application title
|
|
1629
|
+
subtitle (str, optional): Application subtitle
|
|
1630
|
+
config_file (str, optional): Path to config file (default: ./config.py)
|
|
1631
|
+
|
|
1632
|
+
Returns:
|
|
1633
|
+
int: Exit code (0 for success, non-zero for error)
|
|
1634
|
+
|
|
1635
|
+
Examples:
|
|
1636
|
+
>>> # Using agent instance directly
|
|
1637
|
+
>>> from cowork_dash import run_app
|
|
1638
|
+
>>> my_agent = MyAgent()
|
|
1639
|
+
>>> run_app(my_agent, workspace="~/my-workspace")
|
|
1640
|
+
|
|
1641
|
+
>>> # Using agent spec
|
|
1642
|
+
>>> run_app(agent_spec="my_agent.py:agent", port=8080)
|
|
1643
|
+
|
|
1644
|
+
>>> # Without agent (manual mode)
|
|
1645
|
+
>>> run_app(workspace="~/my-workspace", debug=True)
|
|
1646
|
+
"""
|
|
1647
|
+
global WORKSPACE_ROOT, APP_TITLE, APP_SUBTITLE, PORT, HOST, DEBUG, agent, AGENT_ERROR, args
|
|
1648
|
+
|
|
1649
|
+
# Load config file if specified and exists
|
|
1650
|
+
config_module = None
|
|
1651
|
+
if config_file:
|
|
1652
|
+
config_path = Path(config_file).resolve()
|
|
1653
|
+
if config_path.exists():
|
|
1654
|
+
import importlib.util
|
|
1655
|
+
spec = importlib.util.spec_from_file_location("user_config", config_path)
|
|
1656
|
+
if spec and spec.loader:
|
|
1657
|
+
config_module = importlib.util.module_from_spec(spec)
|
|
1658
|
+
spec.loader.exec_module(config_module)
|
|
1659
|
+
print(f"✓ Loaded config from {config_path}")
|
|
1660
|
+
else:
|
|
1661
|
+
print(f"⚠️ Config file not found: {config_path}, using defaults")
|
|
1662
|
+
|
|
1663
|
+
# Apply configuration with overrides
|
|
1664
|
+
if config_module:
|
|
1665
|
+
# Use config file values as base
|
|
1666
|
+
WORKSPACE_ROOT = Path(workspace).resolve() if workspace else getattr(config_module, "WORKSPACE_ROOT", config.WORKSPACE_ROOT)
|
|
1667
|
+
APP_TITLE = title if title else getattr(config_module, "APP_TITLE", config.APP_TITLE)
|
|
1668
|
+
APP_SUBTITLE = subtitle if subtitle else getattr(config_module, "APP_SUBTITLE", config.APP_SUBTITLE)
|
|
1669
|
+
PORT = port if port is not None else getattr(config_module, "PORT", config.PORT)
|
|
1670
|
+
HOST = host if host else getattr(config_module, "HOST", config.HOST)
|
|
1671
|
+
DEBUG = debug if debug is not None else getattr(config_module, "DEBUG", config.DEBUG)
|
|
1672
|
+
|
|
1673
|
+
# Agent priority: agent_spec > agent_instance > config file
|
|
1674
|
+
if agent_spec:
|
|
1675
|
+
# Load agent from spec (highest priority)
|
|
1676
|
+
agent, AGENT_ERROR = load_agent_from_spec(agent_spec)
|
|
1677
|
+
elif agent_instance is not None:
|
|
1678
|
+
# Use provided agent instance
|
|
1679
|
+
agent = agent_instance
|
|
1680
|
+
AGENT_ERROR = None
|
|
1681
|
+
else:
|
|
1682
|
+
# Get agent from config file
|
|
1683
|
+
get_agent_func = getattr(config_module, "get_agent", None)
|
|
1684
|
+
if get_agent_func:
|
|
1685
|
+
result = get_agent_func()
|
|
1686
|
+
if isinstance(result, tuple):
|
|
1687
|
+
agent, AGENT_ERROR = result
|
|
1688
|
+
else:
|
|
1689
|
+
agent = result
|
|
1690
|
+
AGENT_ERROR = None
|
|
1691
|
+
else:
|
|
1692
|
+
agent = None
|
|
1693
|
+
AGENT_ERROR = "No get_agent() function in config file"
|
|
1694
|
+
else:
|
|
1695
|
+
# No config file, use CLI args or defaults
|
|
1696
|
+
WORKSPACE_ROOT = Path(workspace).resolve() if workspace else config.WORKSPACE_ROOT
|
|
1697
|
+
APP_TITLE = title if title else config.APP_TITLE
|
|
1698
|
+
APP_SUBTITLE = subtitle if subtitle else config.APP_SUBTITLE
|
|
1699
|
+
PORT = port if port is not None else config.PORT
|
|
1700
|
+
HOST = host if host else config.HOST
|
|
1701
|
+
DEBUG = debug if debug is not None else config.DEBUG
|
|
1702
|
+
|
|
1703
|
+
# Agent priority: agent_spec > agent_instance > config default
|
|
1704
|
+
if agent_spec:
|
|
1705
|
+
# Load agent from spec (highest priority)
|
|
1706
|
+
agent, AGENT_ERROR = load_agent_from_spec(agent_spec)
|
|
1707
|
+
elif agent_instance is not None:
|
|
1708
|
+
# Use provided agent instance
|
|
1709
|
+
agent = agent_instance
|
|
1710
|
+
AGENT_ERROR = None
|
|
1711
|
+
else:
|
|
1712
|
+
# Use default config agent
|
|
1713
|
+
agent, AGENT_ERROR = load_agent_from_spec(config.AGENT_SPEC)
|
|
1714
|
+
|
|
1715
|
+
# Ensure workspace exists
|
|
1716
|
+
WORKSPACE_ROOT.mkdir(exist_ok=True, parents=True)
|
|
1717
|
+
|
|
1718
|
+
# Set environment variable for agent to access workspace
|
|
1719
|
+
# This allows user agents to read DEEPAGENT_WORKSPACE_ROOT
|
|
1720
|
+
os.environ['DEEPAGENT_WORKSPACE_ROOT'] = str(WORKSPACE_ROOT)
|
|
1721
|
+
|
|
1722
|
+
# Update global state to use the configured workspace
|
|
1723
|
+
global _agent_state
|
|
1724
|
+
_agent_state["canvas"] = load_canvas_from_markdown(WORKSPACE_ROOT)
|
|
1725
|
+
|
|
1726
|
+
# Create a mock args object for compatibility with existing code
|
|
1727
|
+
class Args:
|
|
1728
|
+
pass
|
|
1729
|
+
args = Args()
|
|
1730
|
+
args.workspace = workspace
|
|
1731
|
+
args.agent = agent_spec
|
|
1732
|
+
|
|
1733
|
+
# Print startup banner
|
|
1734
|
+
print("\n" + "="*50)
|
|
1735
|
+
print(f" {APP_TITLE}")
|
|
1736
|
+
print("="*50)
|
|
1737
|
+
print(f" Workspace: {WORKSPACE_ROOT}")
|
|
1738
|
+
if workspace:
|
|
1739
|
+
print(f" (from CLI: --workspace {workspace})")
|
|
1740
|
+
print(f" Agent: {'Ready' if agent else 'Not available'}")
|
|
1741
|
+
if agent_spec:
|
|
1742
|
+
print(f" (from CLI: --agent {agent_spec})")
|
|
1743
|
+
if AGENT_ERROR:
|
|
1744
|
+
print(f" Error: {AGENT_ERROR}")
|
|
1745
|
+
print(f" URL: http://{HOST}:{PORT}")
|
|
1746
|
+
print(f" Debug: {DEBUG}")
|
|
1747
|
+
print("="*50 + "\n")
|
|
1748
|
+
|
|
1749
|
+
# Run the app
|
|
1750
|
+
try:
|
|
1751
|
+
app.run(debug=DEBUG, host=HOST, port=PORT)
|
|
1752
|
+
return 0
|
|
1753
|
+
except Exception as e:
|
|
1754
|
+
print(f"\n❌ Error running app: {e}")
|
|
1755
|
+
return 1
|
|
1756
|
+
|
|
1757
|
+
|
|
1758
|
+
# =============================================================================
|
|
1759
|
+
# MAIN - BACKWARDS COMPATIBILITY
|
|
1760
|
+
# =============================================================================
|
|
1761
|
+
|
|
1762
|
+
if __name__ == "__main__":
|
|
1763
|
+
# Parse CLI arguments
|
|
1764
|
+
args = parse_args()
|
|
1765
|
+
|
|
1766
|
+
# When run directly (not as package), use original CLI arg parsing
|
|
1767
|
+
sys.exit(run_app(
|
|
1768
|
+
workspace=args.workspace if args.workspace else None,
|
|
1769
|
+
agent_spec=args.agent if args.agent else None,
|
|
1770
|
+
port=args.port if args.port else None,
|
|
1771
|
+
host=args.host if args.host else None,
|
|
1772
|
+
debug=args.debug if args.debug else (not args.no_debug if args.no_debug else None),
|
|
1773
|
+
title=args.title if args.title else None,
|
|
1774
|
+
subtitle=args.subtitle if args.subtitle else None,
|
|
1775
|
+
config_file=args.config if args.config else None
|
|
1776
|
+
))
|