Fix media friendly names, metadata display, output recording, and plan display
- Add friendly name display to media detail and list pages - Unpack nested meta fields to top level for template access - Fix output_cid mismatch: use IPFS CID consistently between cache and database - Add dual-indexing in cache_manager to map both IPFS CID and local hash - Fix plan display: accept IPFS CIDs (Qm..., bafy...) not just 64-char hashes - Add friendly names to recipe listing - Add recipe upload button and handler to recipes list - Add debug logging to recipe listing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -50,7 +50,9 @@ async def get_cached(
|
|||||||
auth_service = AuthService(get_redis_client())
|
auth_service = AuthService(get_redis_client())
|
||||||
ctx = auth_service.get_user_from_cookie(request)
|
ctx = auth_service.get_user_from_cookie(request)
|
||||||
|
|
||||||
cache_item = await cache_service.get_cache_item(cid)
|
# Pass actor_id to get friendly name and user-specific metadata
|
||||||
|
actor_id = ctx.actor_id if ctx else None
|
||||||
|
cache_item = await cache_service.get_cache_item(cid, actor_id=actor_id)
|
||||||
if not cache_item:
|
if not cache_item:
|
||||||
if wants_html(request):
|
if wants_html(request):
|
||||||
templates = get_templates(request)
|
templates = get_templates(request)
|
||||||
|
|||||||
@@ -181,7 +181,13 @@ async def get_run(
|
|||||||
plan_sexp = None # Native S-expression if available
|
plan_sexp = None # Native S-expression if available
|
||||||
recipe_ipfs_cid = None
|
recipe_ipfs_cid = None
|
||||||
recipe_id = run.get("recipe")
|
recipe_id = run.get("recipe")
|
||||||
if recipe_id and len(recipe_id) == 64: # Looks like a hash
|
# Check for valid recipe ID (64-char hash, IPFS CIDv0 "Qm...", or CIDv1 "bafy...")
|
||||||
|
is_valid_recipe_id = recipe_id and (
|
||||||
|
len(recipe_id) == 64 or
|
||||||
|
recipe_id.startswith("Qm") or
|
||||||
|
recipe_id.startswith("bafy")
|
||||||
|
)
|
||||||
|
if is_valid_recipe_id:
|
||||||
try:
|
try:
|
||||||
from ..services.recipe_service import RecipeService
|
from ..services.recipe_service import RecipeService
|
||||||
recipe_service = RecipeService(get_redis_client(), get_cache_manager())
|
recipe_service = RecipeService(get_redis_client(), get_cache_manager())
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ class CacheService:
|
|||||||
self.cache = cache_manager
|
self.cache = cache_manager
|
||||||
self.cache_dir = Path(os.environ.get("CACHE_DIR", "/tmp/artdag-cache"))
|
self.cache_dir = Path(os.environ.get("CACHE_DIR", "/tmp/artdag-cache"))
|
||||||
|
|
||||||
async def get_cache_item(self, cid: str) -> Optional[Dict[str, Any]]:
|
async def get_cache_item(self, cid: str, actor_id: str = None) -> Optional[Dict[str, Any]]:
|
||||||
"""Get cached item with full metadata for display."""
|
"""Get cached item with full metadata for display."""
|
||||||
# Check if content exists
|
# Check if content exists
|
||||||
if not self.cache.has_content(cid):
|
if not self.cache.has_content(cid):
|
||||||
@@ -106,14 +106,14 @@ class CacheService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# Get metadata from database
|
# Get metadata from database
|
||||||
meta = await self.db.load_item_metadata(cid, None)
|
meta = await self.db.load_item_metadata(cid, actor_id)
|
||||||
cache_item = await self.db.get_cache_item(cid)
|
cache_item = await self.db.get_cache_item(cid)
|
||||||
|
|
||||||
media_type = detect_media_type(path)
|
media_type = detect_media_type(path)
|
||||||
mime_type = get_mime_type(path)
|
mime_type = get_mime_type(path)
|
||||||
size = path.stat().st_size
|
size = path.stat().st_size
|
||||||
|
|
||||||
return {
|
result = {
|
||||||
"cid": cid,
|
"cid": cid,
|
||||||
"path": str(path),
|
"path": str(path),
|
||||||
"media_type": media_type,
|
"media_type": media_type,
|
||||||
@@ -123,6 +123,28 @@ class CacheService:
|
|||||||
"meta": meta,
|
"meta": meta,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Unpack meta fields to top level for template convenience
|
||||||
|
if meta:
|
||||||
|
result["title"] = meta.get("title")
|
||||||
|
result["description"] = meta.get("description")
|
||||||
|
result["tags"] = meta.get("tags", [])
|
||||||
|
result["source_type"] = meta.get("source_type")
|
||||||
|
result["source_note"] = meta.get("source_note")
|
||||||
|
result["created_at"] = meta.get("created_at")
|
||||||
|
result["filename"] = meta.get("filename")
|
||||||
|
|
||||||
|
# Get friendly name if actor_id provided
|
||||||
|
if actor_id:
|
||||||
|
from .naming_service import get_naming_service
|
||||||
|
naming = get_naming_service()
|
||||||
|
friendly = await naming.get_by_cid(actor_id, cid)
|
||||||
|
if friendly:
|
||||||
|
result["friendly_name"] = friendly["friendly_name"]
|
||||||
|
result["base_name"] = friendly["base_name"]
|
||||||
|
result["version_id"] = friendly["version_id"]
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
async def check_access(self, cid: str, actor_id: str, username: str) -> bool:
|
async def check_access(self, cid: str, actor_id: str, username: str) -> bool:
|
||||||
"""Check if user has access to content."""
|
"""Check if user has access to content."""
|
||||||
user_hashes = await self._get_user_cache_hashes(username, actor_id)
|
user_hashes = await self._get_user_cache_hashes(username, actor_id)
|
||||||
@@ -508,6 +530,19 @@ class CacheService:
|
|||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Add friendly names to items
|
||||||
|
if actor_id:
|
||||||
|
from .naming_service import get_naming_service
|
||||||
|
naming = get_naming_service()
|
||||||
|
for item in items:
|
||||||
|
cid = item.get("cid")
|
||||||
|
if cid:
|
||||||
|
friendly = await naming.get_by_cid(actor_id, cid)
|
||||||
|
if friendly:
|
||||||
|
item["friendly_name"] = friendly["friendly_name"]
|
||||||
|
item["base_name"] = friendly["base_name"]
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
||||||
# Legacy compatibility methods
|
# Legacy compatibility methods
|
||||||
|
|||||||
@@ -75,8 +75,9 @@ class RecipeService:
|
|||||||
|
|
||||||
if hasattr(self.cache, 'list_by_type'):
|
if hasattr(self.cache, 'list_by_type'):
|
||||||
items = self.cache.list_by_type('recipe')
|
items = self.cache.list_by_type('recipe')
|
||||||
logger.info(f"Found {len(items)} recipes in cache")
|
logger.info(f"Found {len(items)} recipe CIDs in cache: {items[:5]}...")
|
||||||
for cid in items:
|
for cid in items:
|
||||||
|
logger.debug(f"Attempting to get recipe {cid[:16]}...")
|
||||||
recipe = await self.get_recipe(cid)
|
recipe = await self.get_recipe(cid)
|
||||||
if recipe and not recipe.get("error"):
|
if recipe and not recipe.get("error"):
|
||||||
owner = recipe.get("owner")
|
owner = recipe.get("owner")
|
||||||
@@ -85,9 +86,25 @@ class RecipeService:
|
|||||||
# means the recipe is shared/public and visible to all users
|
# means the recipe is shared/public and visible to all users
|
||||||
if actor_id is None or owner is None or owner == actor_id:
|
if actor_id is None or owner is None or owner == actor_id:
|
||||||
recipes.append(recipe)
|
recipes.append(recipe)
|
||||||
|
elif recipe and recipe.get("error"):
|
||||||
|
logger.warning(f"Recipe {cid[:16]}... has error: {recipe.get('error')}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Recipe {cid[:16]}... returned None")
|
||||||
else:
|
else:
|
||||||
logger.warning("Cache does not have list_by_type method")
|
logger.warning("Cache does not have list_by_type method")
|
||||||
|
|
||||||
|
# Add friendly names
|
||||||
|
if actor_id:
|
||||||
|
from .naming_service import get_naming_service
|
||||||
|
naming = get_naming_service()
|
||||||
|
for recipe in recipes:
|
||||||
|
recipe_id = recipe.get("recipe_id")
|
||||||
|
if recipe_id:
|
||||||
|
friendly = await naming.get_by_cid(actor_id, recipe_id)
|
||||||
|
if friendly:
|
||||||
|
recipe["friendly_name"] = friendly["friendly_name"]
|
||||||
|
recipe["base_name"] = friendly["base_name"]
|
||||||
|
|
||||||
# Sort by name
|
# Sort by name
|
||||||
recipes.sort(key=lambda r: r.get("name", ""))
|
recipes.sort(key=lambda r: r.get("name", ""))
|
||||||
|
|
||||||
|
|||||||
17
app/templates/cache/detail.html
vendored
17
app/templates/cache/detail.html
vendored
@@ -39,6 +39,17 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Friendly Name -->
|
||||||
|
{% if cache.friendly_name %}
|
||||||
|
<div class="bg-gray-800 rounded-lg border border-gray-700 p-4 mb-6">
|
||||||
|
<div class="mb-2">
|
||||||
|
<span class="text-gray-500 text-sm">Friendly Name</span>
|
||||||
|
<p class="text-blue-400 font-medium text-lg mt-1">{{ cache.friendly_name }}</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-500 text-xs">Use in recipes: <code class="bg-gray-900 px-2 py-0.5 rounded">{{ cache.base_name }}</code></p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- User Metadata (editable) -->
|
<!-- User Metadata (editable) -->
|
||||||
<div id="metadata-section" class="bg-gray-800 rounded-lg border border-gray-700 p-4 mb-6">
|
<div id="metadata-section" class="bg-gray-800 rounded-lg border border-gray-700 p-4 mb-6">
|
||||||
<div class="flex items-center justify-between mb-3">
|
<div class="flex items-center justify-between mb-3">
|
||||||
@@ -50,15 +61,19 @@
|
|||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% if cache.title or cache.description %}
|
{% if cache.title or cache.description or cache.filename %}
|
||||||
<div class="space-y-2 mb-4">
|
<div class="space-y-2 mb-4">
|
||||||
{% if cache.title %}
|
{% if cache.title %}
|
||||||
<h4 class="text-white font-medium">{{ cache.title }}</h4>
|
<h4 class="text-white font-medium">{{ cache.title }}</h4>
|
||||||
|
{% elif cache.filename %}
|
||||||
|
<h4 class="text-white font-medium">{{ cache.filename }}</h4>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if cache.description %}
|
{% if cache.description %}
|
||||||
<p class="text-gray-400">{{ cache.description }}</p>
|
<p class="text-gray-400">{{ cache.description }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500 text-sm mb-4">No title or description set. Click Edit to add metadata.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if cache.tags %}
|
{% if cache.tags %}
|
||||||
<div class="flex flex-wrap gap-2 mb-4">
|
<div class="flex flex-wrap gap-2 mb-4">
|
||||||
|
|||||||
4
app/templates/cache/media_list.html
vendored
4
app/templates/cache/media_list.html
vendored
@@ -68,7 +68,11 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
|
{% if item.friendly_name %}
|
||||||
|
<div class="text-xs text-blue-400 font-medium truncate">{{ item.friendly_name }}</div>
|
||||||
|
{% else %}
|
||||||
<div class="font-mono text-xs text-gray-500 truncate">{{ item.cid[:16] }}...</div>
|
<div class="font-mono text-xs text-gray-500 truncate">{{ item.cid[:16] }}...</div>
|
||||||
|
{% endif %}
|
||||||
{% if item.filename %}
|
{% if item.filename %}
|
||||||
<div class="text-xs text-gray-600 truncate">{{ item.filename }}</div>
|
<div class="text-xs text-gray-600 truncate">{{ item.filename }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
<div class="max-w-6xl mx-auto">
|
<div class="max-w-6xl mx-auto">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h1 class="text-3xl font-bold">Recipes</h1>
|
<h1 class="text-3xl font-bold">Recipes</h1>
|
||||||
|
<label class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded font-medium cursor-pointer">
|
||||||
|
Upload Recipe
|
||||||
|
<input type="file" accept=".sexp,.yaml,.yml" class="hidden" id="recipe-upload" />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-gray-400 mb-8">
|
<p class="text-gray-400 mb-8">
|
||||||
@@ -13,10 +17,10 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if recipes %}
|
{% if recipes %}
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" id="recipes-list">
|
||||||
{% for recipe in recipes %}
|
{% for recipe in recipes %}
|
||||||
<a href="/recipes/{{ recipe.recipe_id }}"
|
<a href="/recipes/{{ recipe.recipe_id }}"
|
||||||
class="bg-gray-800 border border-gray-700 rounded-lg p-4 hover:border-gray-600 transition-colors">
|
class="recipe-card bg-gray-800 border border-gray-700 rounded-lg p-4 hover:border-gray-600 transition-colors">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<span class="font-medium text-white">{{ recipe.name }}</span>
|
<span class="font-medium text-white">{{ recipe.name }}</span>
|
||||||
{% if recipe.version %}
|
{% if recipe.version %}
|
||||||
@@ -42,14 +46,91 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-3 text-xs">
|
||||||
|
{% if recipe.friendly_name %}
|
||||||
|
<span class="text-blue-400 font-medium">{{ recipe.friendly_name }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-600 font-mono truncate">{{ recipe.recipe_id[:24] }}...</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if has_more %}
|
||||||
|
<div hx-get="/recipes?offset={{ offset + limit }}&limit={{ limit }}"
|
||||||
|
hx-trigger="revealed"
|
||||||
|
hx-swap="afterend"
|
||||||
|
hx-select="#recipes-list > *"
|
||||||
|
class="h-20 flex items-center justify-center text-gray-500">
|
||||||
|
Loading more...
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="bg-gray-800 border border-gray-700 rounded-lg p-12 text-center">
|
<div class="bg-gray-800 border border-gray-700 rounded-lg p-12 text-center">
|
||||||
<p class="text-gray-500 mb-4">No recipes available.</p>
|
<p class="text-gray-500 mb-4">No recipes available.</p>
|
||||||
<p class="text-gray-600 text-sm">Recipes are defined in YAML format and submitted via API.</p>
|
<p class="text-gray-600 text-sm mb-6">
|
||||||
|
Recipes are S-expression files (.sexp) that define processing pipelines.
|
||||||
|
</p>
|
||||||
|
<label class="bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded font-medium cursor-pointer inline-block">
|
||||||
|
Upload Your First Recipe
|
||||||
|
<input type="file" accept=".sexp,.yaml,.yml" class="hidden" id="recipe-upload-empty" />
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="upload-result" class="fixed bottom-4 right-4 max-w-sm"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function handleRecipeUpload(input) {
|
||||||
|
const file = input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
fetch('/recipes/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) throw new Error('Upload failed');
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
const resultDiv = document.getElementById('upload-result');
|
||||||
|
resultDiv.innerHTML = `
|
||||||
|
<div class="bg-green-900 border border-green-700 rounded-lg p-4">
|
||||||
|
<p class="text-green-300 font-medium">Recipe uploaded!</p>
|
||||||
|
<p class="text-green-400 text-sm mt-1">${data.name} v${data.version}</p>
|
||||||
|
<p class="text-gray-400 text-xs mt-2 font-mono">${data.recipe_id}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 1500);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
const resultDiv = document.getElementById('upload-result');
|
||||||
|
resultDiv.innerHTML = `
|
||||||
|
<div class="bg-red-900 border border-red-700 rounded-lg p-4">
|
||||||
|
<p class="text-red-300 font-medium">Upload failed</p>
|
||||||
|
<p class="text-red-400 text-sm mt-1">${error.message}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('recipe-upload')?.addEventListener('change', function() {
|
||||||
|
handleRecipeUpload(this);
|
||||||
|
});
|
||||||
|
document.getElementById('recipe-upload-empty')?.addEventListener('change', function() {
|
||||||
|
handleRecipeUpload(this);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -412,6 +412,14 @@ class L1CacheManager:
|
|||||||
# Update content index (CID -> node_id mapping)
|
# Update content index (CID -> node_id mapping)
|
||||||
self._set_content_index(cid, node_id)
|
self._set_content_index(cid, node_id)
|
||||||
|
|
||||||
|
# Also index by local hash if cid is an IPFS CID
|
||||||
|
# This ensures both IPFS CID and local hash can be used to find the file
|
||||||
|
if self._is_ipfs_cid(cid):
|
||||||
|
local_hash = file_hash(source_path)
|
||||||
|
if local_hash != cid:
|
||||||
|
self._set_content_index(local_hash, node_id)
|
||||||
|
logger.debug(f"Dual-indexed: {local_hash[:16]}... -> {node_id}")
|
||||||
|
|
||||||
logger.info(f"Cached: {cid[:16]}...")
|
logger.info(f"Cached: {cid[:16]}...")
|
||||||
|
|
||||||
return CachedFile.from_cache_entry(entry), cid
|
return CachedFile.from_cache_entry(entry), cid
|
||||||
|
|||||||
@@ -365,9 +365,13 @@ def execute_dag(self, dag_json: str, run_id: str = None) -> dict:
|
|||||||
logger.info(f"Cached node {node_id}: {cached.cid[:16]}... -> {ipfs_cid or 'no IPFS'}")
|
logger.info(f"Cached node {node_id}: {cached.cid[:16]}... -> {ipfs_cid or 'no IPFS'}")
|
||||||
|
|
||||||
# Get output hash from the output node
|
# Get output hash from the output node
|
||||||
|
# Use the same identifier that's in the cache index (IPFS CID if available)
|
||||||
if result.output_path and result.output_path.exists():
|
if result.output_path and result.output_path.exists():
|
||||||
output_cid = file_hash(result.output_path)
|
local_hash = file_hash(result.output_path)
|
||||||
output_ipfs_cid = node_ipfs_cids.get(dag.output_id)
|
output_ipfs_cid = node_ipfs_cids.get(dag.output_id)
|
||||||
|
# Use IPFS CID as primary identifier if available, otherwise local hash
|
||||||
|
# This must match what's in the content_index from cache_manager.put()
|
||||||
|
output_cid = node_hashes.get(dag.output_id, local_hash)
|
||||||
|
|
||||||
# Store output in database (for L2 to query IPFS CID)
|
# Store output in database (for L2 to query IPFS CID)
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|||||||
Reference in New Issue
Block a user