Use cache_id for plan node details and show input/output media

- Changed node click to use cache_id in URL instead of step_id
- Updated route to lookup step by cache_id
- Added input media previews showing thumbnails of each input step
- Enhanced output preview with video/image/audio support
- Added parameters section showing step config
- Updated JavaScript to pass cacheId when clicking nodes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
giles
2026-01-11 11:44:21 +00:00
parent 17b92c77ef
commit 73b7f173c5

199
server.py
View File

@@ -1425,9 +1425,9 @@ async def load_plan_for_run_with_fallback(run: RunStatus) -> Optional[dict]:
return None
@app.get("/run/{run_id}/plan/node/{step_id}", response_class=HTMLResponse)
async def run_plan_node_detail(run_id: str, step_id: str, request: Request):
"""HTMX partial: Get node detail HTML fragment."""
@app.get("/run/{run_id}/plan/node/{cache_id}", response_class=HTMLResponse)
async def run_plan_node_detail(run_id: str, cache_id: str, request: Request):
"""HTMX partial: Get node detail HTML fragment by cache_id."""
ctx = await get_user_context_from_cookie(request)
if not ctx:
return HTMLResponse('<p class="text-red-400">Login required</p>', status_code=401)
@@ -1442,23 +1442,28 @@ async def run_plan_node_detail(run_id: str, step_id: str, request: Request):
if not plan_data:
return HTMLResponse('<p class="text-gray-400">Plan not found</p>')
# Find the step
step = None
# Build a lookup from cache_id to step and step_id to step
steps_by_cache_id = {}
steps_by_step_id = {}
for s in plan_data.get("steps", []):
if s.get("step_id") == step_id:
step = s
break
if s.get("cache_id"):
steps_by_cache_id[s["cache_id"]] = s
if s.get("step_id"):
steps_by_step_id[s["step_id"]] = s
# Find the step by cache_id
step = steps_by_cache_id.get(cache_id)
if not step:
return HTMLResponse(f'<p class="text-gray-400">Step {step_id} not found</p>')
return HTMLResponse(f'<p class="text-gray-400">Step with cache {cache_id[:16]}... not found</p>')
# Get step info
step_id = step.get("step_id", "")
step_name = step.get("name", step_id[:20])
node_type = step.get("node_type", "EFFECT")
cache_id = step.get("cache_id", "")
config = step.get("config", {})
level = step.get("level", 0)
input_steps = step.get("input_steps", [])
input_hashes = step.get("input_hashes", {})
# Check for IPFS CID
step_cid = None
@@ -1470,69 +1475,105 @@ async def run_plan_node_detail(run_id: str, step_id: str, request: Request):
has_cached = cache_manager.has_content(cache_id) if cache_id else False
color = NODE_COLORS.get(node_type, NODE_COLORS["default"])
# Build preview HTML
preview_html = ""
# Build INPUT media previews - show each input's cached content
inputs_html = ""
if input_steps:
input_items = ""
for inp_step_id in input_steps:
inp_step = steps_by_step_id.get(inp_step_id)
if inp_step:
inp_cache_id = inp_step.get("cache_id", "")
inp_name = inp_step.get("name", inp_step_id[:12])
inp_has_cached = cache_manager.has_content(inp_cache_id) if inp_cache_id else False
# Build preview thumbnail for input
inp_preview = ""
if inp_has_cached and inp_cache_id:
inp_media_type = detect_media_type(get_cache_path(inp_cache_id))
if inp_media_type == "video":
inp_preview = f'<video src="/cache/{inp_cache_id}/raw" class="w-full h-20 object-cover rounded" muted></video>'
elif inp_media_type == "image":
inp_preview = f'<img src="/cache/{inp_cache_id}/raw" class="w-full h-20 object-cover rounded">'
else:
inp_preview = f'<div class="w-full h-20 bg-dark-500 rounded flex items-center justify-center text-xs text-gray-400">File</div>'
else:
inp_preview = f'<div class="w-full h-20 bg-dark-500 rounded flex items-center justify-center text-xs text-gray-400">Not cached</div>'
input_items += f'''
<a href="/cache/{inp_cache_id}" class="block bg-dark-600 rounded-lg overflow-hidden hover:bg-dark-500 transition-colors">
{inp_preview}
<div class="p-2">
<div class="text-xs text-white truncate">{inp_name}</div>
<div class="text-xs text-gray-500 font-mono truncate">{inp_cache_id[:12]}...</div>
</div>
</a>
'''
inputs_html = f'''
<div class="mt-4">
<h5 class="text-sm font-medium text-gray-400 mb-2">Inputs ({len(input_steps)})</h5>
<div class="grid grid-cols-2 gap-2">{input_items}</div>
</div>'''
# Build OUTPUT preview
output_preview = ""
if has_cached and cache_id:
media_type = detect_media_type(get_cache_path(cache_id))
if media_type == "video":
preview_html = f'''
output_preview = f'''
<div class="mb-4">
<video src="/cache/{cache_id}/raw" controls muted class="w-full max-h-64 rounded-lg"></video>
<h5 class="text-sm font-medium text-gray-400 mb-2">Output</h5>
<video src="/cache/{cache_id}/raw" controls muted class="w-full max-h-48 rounded-lg"></video>
</div>'''
elif media_type == "image":
preview_html = f'''
output_preview = f'''
<div class="mb-4">
<img src="/cache/{cache_id}/raw" class="w-full max-h-64 rounded-lg object-contain">
<h5 class="text-sm font-medium text-gray-400 mb-2">Output</h5>
<img src="/cache/{cache_id}/raw" class="w-full max-h-48 rounded-lg object-contain">
</div>'''
elif media_type == "audio":
output_preview = f'''
<div class="mb-4">
<h5 class="text-sm font-medium text-gray-400 mb-2">Output</h5>
<audio src="/cache/{cache_id}/raw" controls class="w-full"></audio>
</div>'''
elif step_cid:
ipfs_gateway = IPFS_GATEWAY_URL.rstrip('/') if IPFS_GATEWAY_URL else "https://ipfs.io/ipfs"
preview_html = f'''
output_preview = f'''
<div class="mb-4">
<video src="{ipfs_gateway}/{step_cid}" controls muted class="w-full max-h-64 rounded-lg"></video>
<h5 class="text-sm font-medium text-gray-400 mb-2">Output (IPFS)</h5>
<video src="{ipfs_gateway}/{step_cid}" controls muted class="w-full max-h-48 rounded-lg"></video>
</div>'''
# Build output link
output_html = ""
output_link = ""
if step_cid:
output_html = f'''
<div class="mt-4">
<h5 class="text-sm font-medium text-gray-400 mb-2">Output (IPFS)</h5>
<a href="/ipfs/{step_cid}" class="flex items-center justify-between bg-dark-600 rounded p-3 hover:bg-dark-500 transition-colors">
<span class="font-mono text-xs text-gray-300 truncate">{step_cid}</span>
<span class="px-3 py-1 bg-blue-600 text-white text-xs rounded ml-2">View</span>
</a>
</div>'''
output_link = f'''
<a href="/ipfs/{step_cid}" class="flex items-center justify-between bg-dark-600 rounded p-2 hover:bg-dark-500 transition-colors text-xs">
<span class="font-mono text-gray-300 truncate">{step_cid[:24]}...</span>
<span class="px-2 py-1 bg-blue-600 text-white rounded ml-2">View</span>
</a>'''
elif has_cached and cache_id:
output_html = f'''
<div class="mt-4">
<h5 class="text-sm font-medium text-gray-400 mb-2">Output</h5>
<a href="/cache/{cache_id}" class="flex items-center justify-between bg-dark-600 rounded p-3 hover:bg-dark-500 transition-colors">
<span class="font-mono text-xs text-gray-300 truncate">{cache_id}</span>
<span class="px-3 py-1 bg-blue-600 text-white text-xs rounded ml-2">View</span>
</a>
</div>'''
output_link = f'''
<a href="/cache/{cache_id}" class="flex items-center justify-between bg-dark-600 rounded p-2 hover:bg-dark-500 transition-colors text-xs">
<span class="font-mono text-gray-300 truncate">{cache_id[:24]}...</span>
<span class="px-2 py-1 bg-blue-600 text-white rounded ml-2">View</span>
</a>'''
# Config display
# Config/parameters display
config_html = ""
if config:
config_json = json.dumps(config, indent=2)
config_items = ""
for key, value in config.items():
if isinstance(value, (dict, list)):
value_str = json.dumps(value)
else:
value_str = str(value)
config_items += f'<div class="flex justify-between"><span class="text-gray-400">{key}:</span><span class="text-white">{value_str[:30]}</span></div>'
config_html = f'''
<div class="mt-4">
<h5 class="text-sm font-medium text-gray-400 mb-2">Config</h5>
<pre class="text-xs bg-dark-600 p-3 rounded overflow-x-auto max-h-40">{config_json}</pre>
</div>'''
# Input steps
inputs_html = ""
if input_steps:
inputs_list = "".join([
f'<span class="px-2 py-1 bg-dark-500 rounded text-xs font-mono">{inp[:16]}...</span>'
for inp in input_steps
])
inputs_html = f'''
<div class="mt-4">
<h5 class="text-sm font-medium text-gray-400 mb-2">Input Steps</h5>
<div class="flex flex-wrap gap-2">{inputs_list}</div>
<h5 class="text-sm font-medium text-gray-400 mb-2">Parameters</h5>
<div class="bg-dark-600 rounded p-3 text-xs space-y-1">{config_items}</div>
</div>'''
status = "cached" if (has_cached or step_cid) else ("completed" if run.status == "completed" else "pending")
@@ -1554,14 +1595,14 @@ async def run_plan_node_detail(run_id: str, step_id: str, request: Request):
</svg>
</button>
</div>
{preview_html}
<div class="text-sm space-y-2">
<div><span class="text-gray-400">Step ID:</span> <span class="font-mono text-xs">{step_id}</span></div>
<div><span class="text-gray-400">Cache ID:</span> <span class="font-mono text-xs">{cache_id[:32] if cache_id else "N/A"}...</span></div>
</div>
{output_html}
{output_preview}
{output_link}
{inputs_html}
{config_html}
<div class="mt-4 text-xs text-gray-500 space-y-1">
<div><span class="text-gray-400">Step ID:</span> <span class="font-mono">{step_id[:32]}...</span></div>
<div><span class="text-gray-400">Cache ID:</span> <span class="font-mono">{cache_id[:32]}...</span></div>
</div>
''')
@@ -5004,8 +5045,8 @@ def render_dag_cytoscape(nodes_json: str, edges_json: str, container_id: str = "
var currentRunId = '{run_id}';
var currentNodeId = '{initial_node}';
function loadNodeDetail(stepId) {{
if (!currentRunId || !stepId) return;
function loadNodeDetail(stepId, cacheId) {{
if (!currentRunId || !cacheId) return;
currentNodeId = stepId;
var detailsEl = document.getElementById('{container_id}-details');
@@ -5014,12 +5055,12 @@ def render_dag_cytoscape(nodes_json: str, edges_json: str, container_id: str = "
detailsEl.classList.remove('hidden');
contentEl.innerHTML = '<div class="text-gray-400">Loading...</div>';
// Update URL without full page reload
var newUrl = '/run/' + currentRunId + '/plan?node=' + encodeURIComponent(stepId);
history.pushState({{ node: stepId }}, '', newUrl);
// Update URL without full page reload - use cache_id
var newUrl = '/run/' + currentRunId + '/plan?node=' + encodeURIComponent(cacheId);
history.pushState({{ node: cacheId }}, '', newUrl);
// Fetch node details via HTMX-style fetch
fetch('/run/' + currentRunId + '/plan/node/' + encodeURIComponent(stepId))
// Fetch node details via cache_id
fetch('/run/' + currentRunId + '/plan/node/' + encodeURIComponent(cacheId))
.then(function(response) {{ return response.text(); }})
.then(function(html) {{
contentEl.innerHTML = html;
@@ -5060,7 +5101,18 @@ def render_dag_cytoscape(nodes_json: str, edges_json: str, container_id: str = "
// Handle browser back/forward
window.addEventListener('popstate', function(event) {{
if (event.state && event.state.node) {{
loadNodeDetail(event.state.node);
// event.state.node is now cache_id, find the corresponding step
var cacheId = event.state.node;
if (window.artdagCy) {{
var nodes = window.artdagCy.nodes();
for (var i = 0; i < nodes.length; i++) {{
if (nodes[i].data('cacheId') === cacheId) {{
loadNodeDetail(nodes[i].data('id'), cacheId);
nodes[i].select();
return;
}}
}}
}}
}} else {{
closeNodeDetail();
}}
@@ -5157,7 +5209,8 @@ def render_dag_cytoscape(nodes_json: str, edges_json: str, container_id: str = "
window.artdagCy.on('tap', 'node', function(evt) {{
var node = evt.target;
var stepId = node.data('id');
loadNodeDetail(stepId);
var cacheId = node.data('cacheId');
loadNodeDetail(stepId, cacheId);
}});
// Click on background closes detail
@@ -5167,9 +5220,17 @@ def render_dag_cytoscape(nodes_json: str, edges_json: str, container_id: str = "
}}
}});
// Load initial node from URL if specified
// Load initial node from URL if specified (currentNodeId is cache_id)
if (currentNodeId) {{
loadNodeDetail(currentNodeId);
// Find node by cacheId and load its details
var nodes = window.artdagCy.nodes();
for (var i = 0; i < nodes.length; i++) {{
if (nodes[i].data('cacheId') === currentNodeId) {{
loadNodeDetail(nodes[i].data('id'), currentNodeId);
nodes[i].select();
break;
}}
}}
}}
// WebSocket update function for real-time status updates