Add image assets embeddable in artifact content

Agents can now put images inside markdown/HTML artifacts instead of only
posting them as standalone artifacts.

- New Asset model: an image uploaded ahead of the artifact that embeds it.
- POST /api/assets uploads an image (image/* only, 50 MB cap) and returns
  a stable URL the agent references normally, e.g. ![chart](url). No custom
  syntax; existing markdown/fragment rendering handles it.
- GET /assets/{id} serves the image, owner-scoped (404 for other users).
- On artifact creation the content is scanned for asset URLs and matching
  assets are attached to it, so the image dies with the artifact it belongs
  to. Uploads never referenced are swept after a 24h grace period.
- Quota: assets count toward the owner's quota at upload time and can
  trigger eviction; evicting an artifact frees its assets too, and usage
  now sums artifacts plus assets.
- daybook_post.py gains --upload-image, which prints the URL to embed.
- Docs and SKILL.md cover the upload-then-reference workflow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ritte 2026-07-24 12:24:51 +02:00
parent 13f728bcd6
commit bf8d1fe244
9 changed files with 316 additions and 10 deletions

View file

@ -30,13 +30,14 @@ from rest_framework.views import APIView
from .demo_data import create_demo_artifacts
from .forms import DaybookLoginForm
from .models import Artifact, TagColor
from .models import Artifact, Asset, TagColor
from .storage import (
QuotaExceeded,
artifact_disk_size,
delete_artifact_files,
disk_free_bytes,
make_room_for,
sweep_orphan_assets,
total_allocated_bytes,
user_quota_bytes,
user_usage_bytes,
@ -47,6 +48,11 @@ TEXT_KINDS = {Artifact.HTML_FRAGMENT, Artifact.HTML_FULL, Artifact.MARKDOWN, Art
REMEMBER_ME_SESSION_AGE = 60 * 60 * 24 * 30 # 30 days
MAX_ASSET_BYTES = 50 * 1024 * 1024 # 50 MB per uploaded image
# Matches the asset id in a Daybook asset URL, e.g. /assets/ab12cd34ef56
ASSET_URL_RE = re.compile(r"/assets/([0-9a-f]{12})")
def filter_by_label(queryset, label):
matching_ids = [artifact.id for artifact in queryset.only("id", "tags") if label in artifact.tags]
@ -112,6 +118,80 @@ class ArtifactListCreateView(APIView):
return Response(serialize_artifact(artifact, request), status=201)
class AssetUploadView(APIView):
"""Upload an image for embedding inside artifact content.
Returns a stable URL the agent puts in its markdown/fragment. The asset is
attached to whichever artifact later references that URL.
"""
parser_classes = [MultiPartParser, FormParser]
def post(self, request):
user = resolve_token_user(request)
if user is None:
return HttpResponseForbidden("Invalid or missing API token")
uploaded = request.data.get("file")
if not uploaded:
return Response({"detail": "Missing required field: file"}, status=400)
content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or ""
if not content_type.startswith("image/"):
return Response({"detail": "Only image uploads are supported."}, status=400)
if uploaded.size > MAX_ASSET_BYTES:
return Response(
{"detail": f"Image exceeds the {MAX_ASSET_BYTES // (1024 * 1024)} MB limit."},
status=413,
)
sweep_orphan_assets(user)
asset_id = generate_asset_id()
suffix = safe_suffix(uploaded.name, content_type)
directory = Path("_assets") / timezone.localdate().isoformat()
(settings.ARTIFACTS_ROOT / directory).mkdir(parents=True, exist_ok=True)
stored_path = directory / f"{asset_id}{suffix}"
with (settings.ARTIFACTS_ROOT / stored_path).open("wb") as target:
for chunk in uploaded.chunks():
target.write(chunk)
size_bytes = (settings.ARTIFACTS_ROOT / stored_path).stat().st_size
try:
make_room_for(user, size_bytes)
except QuotaExceeded as exc:
(settings.ARTIFACTS_ROOT / stored_path).unlink(missing_ok=True)
return Response({"detail": str(exc)}, status=413)
asset = Asset.objects.create(
id=asset_id,
owner=user,
path=stored_path.as_posix(),
content_type=content_type,
size_bytes=size_bytes,
)
return Response(
{
"id": asset.id,
"url": request.build_absolute_uri(reverse("asset", args=[asset.id])),
"content_type": asset.content_type,
"size_bytes": asset.size_bytes,
},
status=201,
)
@login_required
@xframe_options_sameorigin
def asset_view(request, asset_id):
asset = get_object_or_404(Asset, pk=asset_id, owner=request.user)
path = safe_artifact_path(asset.path)
if not path.exists():
raise Http404("Asset file not found")
return FileResponse(path.open("rb"), content_type=asset.content_type or "application/octet-stream")
@method_decorator(login_required, name="get")
class ArtifactDetailView(APIView):
def get(self, request, artifact_id):
@ -407,7 +487,7 @@ def create_text_artifact(data, request, user):
(target_dir / f"{artifact_id}.md").unlink(missing_ok=True)
return Response({"detail": str(exc)}, status=413)
return Artifact.objects.create(
artifact = Artifact.objects.create(
id=artifact_id,
owner=user,
source=source,
@ -422,6 +502,8 @@ def create_text_artifact(data, request, user):
size_bytes=size_bytes,
**source_fields(data, source),
)
attach_referenced_assets(artifact, content)
return artifact
def create_binary_artifact(data, user):
@ -518,6 +600,27 @@ def generate_id():
return artifact_id
def generate_asset_id():
while True:
asset_id = secrets.token_hex(6)
if not Asset.objects.filter(pk=asset_id).exists():
return asset_id
def attach_referenced_assets(artifact, content):
"""Link assets whose URLs appear in the artifact's content.
Assets are uploaded before the artifact exists, so this runs at creation
time and ties each referenced asset to the artifact that embeds it.
"""
referenced = set(ASSET_URL_RE.findall(content or ""))
if not referenced:
return 0
return Asset.objects.filter(
owner=artifact.owner, id__in=referenced, artifact__isnull=True
).update(artifact=artifact)
def resolve_token_user(request):
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):