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. . 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:
parent
13f728bcd6
commit
bf8d1fe244
9 changed files with 316 additions and 10 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from .models import Artifact, UserProfile
|
from .models import Artifact, Asset, UserProfile
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Artifact)
|
@admin.register(Artifact)
|
||||||
|
|
@ -14,3 +14,10 @@ class ArtifactAdmin(admin.ModelAdmin):
|
||||||
class UserProfileAdmin(admin.ModelAdmin):
|
class UserProfileAdmin(admin.ModelAdmin):
|
||||||
list_display = ("user", "quota_bytes")
|
list_display = ("user", "quota_bytes")
|
||||||
search_fields = ("user__username",)
|
search_fields = ("user__username",)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Asset)
|
||||||
|
class AssetAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("id", "owner", "artifact", "content_type", "size_bytes", "created_at")
|
||||||
|
list_filter = ("owner", "content_type")
|
||||||
|
search_fields = ("id",)
|
||||||
|
|
|
||||||
31
artifacts/migrations/0011_add_asset.py
Normal file
31
artifacts/migrations/0011_add_asset.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Generated by Django 5.2.15 on 2026-07-24 10:13
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('artifacts', '0010_backfill_sizes_and_profiles'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Asset',
|
||||||
|
fields=[
|
||||||
|
('id', models.CharField(max_length=12, primary_key=True, serialize=False)),
|
||||||
|
('path', models.CharField(max_length=500)),
|
||||||
|
('content_type', models.CharField(max_length=100)),
|
||||||
|
('size_bytes', models.BigIntegerField(default=0)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('artifact', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='assets', to='artifacts.artifact')),
|
||||||
|
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assets', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -78,3 +78,28 @@ class UserProfile(models.Model):
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.user} quota={self.quota_bytes}"
|
return f"{self.user} quota={self.quota_bytes}"
|
||||||
|
|
||||||
|
|
||||||
|
class Asset(models.Model):
|
||||||
|
"""An image uploaded for embedding inside an artifact's content.
|
||||||
|
|
||||||
|
Uploaded before the artifact exists, so `artifact` is null until the
|
||||||
|
artifact referencing its URL is created. Unattached assets are swept
|
||||||
|
after ASSET_ORPHAN_GRACE; attached ones die with their artifact.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id = models.CharField(max_length=12, primary_key=True)
|
||||||
|
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="assets")
|
||||||
|
artifact = models.ForeignKey(
|
||||||
|
Artifact, on_delete=models.CASCADE, related_name="assets", null=True, blank=True
|
||||||
|
)
|
||||||
|
path = models.CharField(max_length=500)
|
||||||
|
content_type = models.CharField(max_length=100)
|
||||||
|
size_bytes = models.BigIntegerField(default=0)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.id} ({self.content_type})"
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,13 @@ usage is a cheap SUM rather than a stat of every file.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from .models import Artifact, UserProfile
|
from .models import Artifact, Asset, UserProfile
|
||||||
|
|
||||||
|
|
||||||
class QuotaExceeded(Exception):
|
class QuotaExceeded(Exception):
|
||||||
|
|
@ -32,19 +34,38 @@ def artifact_disk_size(relative_path, kind):
|
||||||
|
|
||||||
|
|
||||||
def delete_artifact_files(artifact):
|
def delete_artifact_files(artifact):
|
||||||
"""Remove an artifact's files from disk (primary + markdown sidecar)."""
|
"""Remove an artifact's files from disk (primary, markdown sidecar, assets).
|
||||||
|
|
||||||
|
Assets are cascade-deleted from the DB by the FK, but their files must be
|
||||||
|
removed here explicitly.
|
||||||
|
"""
|
||||||
root = settings.ARTIFACTS_ROOT
|
root = settings.ARTIFACTS_ROOT
|
||||||
primary = root / artifact.path
|
primary = root / artifact.path
|
||||||
primary.unlink(missing_ok=True)
|
primary.unlink(missing_ok=True)
|
||||||
if artifact.kind == Artifact.MARKDOWN:
|
if artifact.kind == Artifact.MARKDOWN:
|
||||||
primary.with_suffix(".md").unlink(missing_ok=True)
|
primary.with_suffix(".md").unlink(missing_ok=True)
|
||||||
|
for asset in artifact.assets.all():
|
||||||
|
delete_asset_file(asset)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_asset_file(asset):
|
||||||
|
(settings.ARTIFACTS_ROOT / asset.path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def artifact_total_bytes(artifact):
|
||||||
|
"""An artifact's own bytes plus those of the assets embedded in it."""
|
||||||
|
asset_bytes = artifact.assets.aggregate(total=Sum("size_bytes")).get("total") or 0
|
||||||
|
return artifact.size_bytes + asset_bytes
|
||||||
|
|
||||||
|
|
||||||
def user_usage_bytes(user):
|
def user_usage_bytes(user):
|
||||||
return (
|
artifact_bytes = (
|
||||||
Artifact.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total")
|
Artifact.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0
|
||||||
or 0
|
|
||||||
)
|
)
|
||||||
|
asset_bytes = (
|
||||||
|
Asset.objects.filter(owner=user).aggregate(total=Sum("size_bytes")).get("total") or 0
|
||||||
|
)
|
||||||
|
return artifact_bytes + asset_bytes
|
||||||
|
|
||||||
|
|
||||||
def user_quota_bytes(user):
|
def user_quota_bytes(user):
|
||||||
|
|
@ -73,7 +94,8 @@ def make_room_for(user, incoming_bytes):
|
||||||
for artifact in unkept:
|
for artifact in unkept:
|
||||||
if used + incoming_bytes <= quota:
|
if used + incoming_bytes <= quota:
|
||||||
break
|
break
|
||||||
used -= artifact.size_bytes
|
# Freeing an artifact also frees the assets embedded in it.
|
||||||
|
used -= artifact_total_bytes(artifact)
|
||||||
delete_artifact_files(artifact)
|
delete_artifact_files(artifact)
|
||||||
artifact.delete()
|
artifact.delete()
|
||||||
evicted.append(artifact)
|
evicted.append(artifact)
|
||||||
|
|
@ -83,6 +105,27 @@ def make_room_for(user, incoming_bytes):
|
||||||
return evicted
|
return evicted
|
||||||
|
|
||||||
|
|
||||||
|
ASSET_ORPHAN_GRACE = timedelta(hours=24)
|
||||||
|
|
||||||
|
|
||||||
|
def sweep_orphan_assets(user=None):
|
||||||
|
"""Delete assets uploaded but never referenced by an artifact.
|
||||||
|
|
||||||
|
Runs opportunistically on upload. The grace period gives an agent time to
|
||||||
|
upload images and then post the artifact that references them.
|
||||||
|
"""
|
||||||
|
cutoff = timezone.now() - ASSET_ORPHAN_GRACE
|
||||||
|
orphans = Asset.objects.filter(artifact__isnull=True, created_at__lt=cutoff)
|
||||||
|
if user is not None:
|
||||||
|
orphans = orphans.filter(owner=user)
|
||||||
|
count = 0
|
||||||
|
for asset in orphans:
|
||||||
|
delete_asset_file(asset)
|
||||||
|
asset.delete()
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
def disk_free_bytes():
|
def disk_free_bytes():
|
||||||
"""Free space on the volume that holds the artifact store."""
|
"""Free space on the volume that holds the artifact store."""
|
||||||
return shutil.disk_usage(settings.ARTIFACTS_ROOT).free
|
return shutil.disk_usage(settings.ARTIFACTS_ROOT).free
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ urlpatterns = [
|
||||||
path("a/<str:artifact_id>/read", views.mark_artifact_read, name="artifact-mark-read"),
|
path("a/<str:artifact_id>/read", views.mark_artifact_read, name="artifact-mark-read"),
|
||||||
path("a/<str:artifact_id>/keep", views.toggle_artifact_keep, name="artifact-toggle-keep"),
|
path("a/<str:artifact_id>/keep", views.toggle_artifact_keep, name="artifact-toggle-keep"),
|
||||||
path("a/<str:artifact_id>", views.artifact_view, name="artifact"),
|
path("a/<str:artifact_id>", views.artifact_view, name="artifact"),
|
||||||
|
path("assets/<str:asset_id>", views.asset_view, name="asset"),
|
||||||
|
path("api/assets", views.AssetUploadView.as_view(), name="asset-upload-api"),
|
||||||
path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"),
|
path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"),
|
||||||
path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"),
|
path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"),
|
||||||
path("api/artifacts/<str:artifact_id>", views.ArtifactDetailView.as_view(), name="artifact-detail-api"),
|
path("api/artifacts/<str:artifact_id>", views.ArtifactDetailView.as_view(), name="artifact-detail-api"),
|
||||||
|
|
|
||||||
|
|
@ -30,13 +30,14 @@ from rest_framework.views import APIView
|
||||||
|
|
||||||
from .demo_data import create_demo_artifacts
|
from .demo_data import create_demo_artifacts
|
||||||
from .forms import DaybookLoginForm
|
from .forms import DaybookLoginForm
|
||||||
from .models import Artifact, TagColor
|
from .models import Artifact, Asset, TagColor
|
||||||
from .storage import (
|
from .storage import (
|
||||||
QuotaExceeded,
|
QuotaExceeded,
|
||||||
artifact_disk_size,
|
artifact_disk_size,
|
||||||
delete_artifact_files,
|
delete_artifact_files,
|
||||||
disk_free_bytes,
|
disk_free_bytes,
|
||||||
make_room_for,
|
make_room_for,
|
||||||
|
sweep_orphan_assets,
|
||||||
total_allocated_bytes,
|
total_allocated_bytes,
|
||||||
user_quota_bytes,
|
user_quota_bytes,
|
||||||
user_usage_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
|
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):
|
def filter_by_label(queryset, label):
|
||||||
matching_ids = [artifact.id for artifact in queryset.only("id", "tags") if label in artifact.tags]
|
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)
|
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")
|
@method_decorator(login_required, name="get")
|
||||||
class ArtifactDetailView(APIView):
|
class ArtifactDetailView(APIView):
|
||||||
def get(self, request, artifact_id):
|
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)
|
(target_dir / f"{artifact_id}.md").unlink(missing_ok=True)
|
||||||
return Response({"detail": str(exc)}, status=413)
|
return Response({"detail": str(exc)}, status=413)
|
||||||
|
|
||||||
return Artifact.objects.create(
|
artifact = Artifact.objects.create(
|
||||||
id=artifact_id,
|
id=artifact_id,
|
||||||
owner=user,
|
owner=user,
|
||||||
source=source,
|
source=source,
|
||||||
|
|
@ -422,6 +502,8 @@ def create_text_artifact(data, request, user):
|
||||||
size_bytes=size_bytes,
|
size_bytes=size_bytes,
|
||||||
**source_fields(data, source),
|
**source_fields(data, source),
|
||||||
)
|
)
|
||||||
|
attach_referenced_assets(artifact, content)
|
||||||
|
return artifact
|
||||||
|
|
||||||
|
|
||||||
def create_binary_artifact(data, user):
|
def create_binary_artifact(data, user):
|
||||||
|
|
@ -518,6 +600,27 @@ def generate_id():
|
||||||
return artifact_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):
|
def resolve_token_user(request):
|
||||||
auth = request.headers.get("Authorization", "")
|
auth = request.headers.get("Authorization", "")
|
||||||
if not auth.startswith("Bearer "):
|
if not auth.startswith("Bearer "):
|
||||||
|
|
|
||||||
42
docs/api.md
42
docs/api.md
|
|
@ -220,6 +220,48 @@ curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" `
|
||||||
-F "file=@C:\path\to\diagram.svg;type=image/svg+xml"
|
-F "file=@C:\path\to\diagram.svg;type=image/svg+xml"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Upload An Image For Embedding
|
||||||
|
|
||||||
|
To put an image *inside* a markdown/HTML artifact (rather than posting it as its own
|
||||||
|
artifact), upload it first and reference the returned URL in your content.
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/assets
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
Authorization: Bearer <your-personal-token>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `file` | Required. Must be an `image/*` content type. Max 50 MB. |
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "ab12cd34ef56",
|
||||||
|
"url": "http://127.0.0.1:8001/assets/ab12cd34ef56",
|
||||||
|
"content_type": "image/png",
|
||||||
|
"size_bytes": 20481
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then reference the URL normally:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|

|
||||||
|
```
|
||||||
|
|
||||||
|
Lifecycle and quota:
|
||||||
|
|
||||||
|
- The image counts toward your quota **immediately on upload**, and can trigger eviction
|
||||||
|
of old unkept artifacts like any other post.
|
||||||
|
- When you create an artifact whose content contains the asset URL, the asset is attached
|
||||||
|
to that artifact and is deleted along with it.
|
||||||
|
- Uploads never referenced by any artifact are swept after 24 hours.
|
||||||
|
- Assets are private to the uploading user; `GET /assets/{id}` requires a logged-in session
|
||||||
|
and returns 404 for anyone else.
|
||||||
|
|
||||||
## List Artifacts
|
## List Artifacts
|
||||||
|
|
||||||
Endpoint:
|
Endpoint:
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,23 @@ DAYBOOK_SOURCE=agent
|
||||||
|
|
||||||
For images, PDFs, SVGs, and other files, use `--file`; the helper infers multipart and content type.
|
For images, PDFs, SVGs, and other files, use `--file`; the helper infers multipart and content type.
|
||||||
|
|
||||||
|
## Images Inside Content
|
||||||
|
|
||||||
|
To embed an image *within* a markdown or HTML artifact (rather than posting it as its own
|
||||||
|
artifact), upload it first and reference the returned URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Upload -> prints the URL as the last line
|
||||||
|
python skills/daybook-posting/scripts/daybook_post.py --upload-image chart.png
|
||||||
|
|
||||||
|
# 2. Reference that URL in your markdown, then post as usual
|
||||||
|
# 
|
||||||
|
python skills/daybook-posting/scripts/daybook_post.py report.md
|
||||||
|
```
|
||||||
|
|
||||||
|
The image is linked to the artifact that references it and is deleted along with it.
|
||||||
|
Unreferenced uploads are cleaned up after 24 hours.
|
||||||
|
|
||||||
For CSV, JSON, or code that should render today, use `--file path --as-markdown` to post it as a Markdown fenced code block. Without `--as-markdown`, these upload as downloadable binary files.
|
For CSV, JSON, or code that should render today, use `--file path --as-markdown` to post it as a Markdown fenced code block. Without `--as-markdown`, these upload as downloadable binary files.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,19 @@ def main():
|
||||||
)
|
)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
|
if args.upload_image:
|
||||||
|
try:
|
||||||
|
response = upload_asset(base_url, token, Path(args.upload_image))
|
||||||
|
except ValueError as exc:
|
||||||
|
print(f"error: {exc}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
except (HTTPError, URLError) as exc:
|
||||||
|
print_http_error(exc)
|
||||||
|
return 1
|
||||||
|
print(json.dumps(response, indent=2))
|
||||||
|
print(response.get("url", ""))
|
||||||
|
return 0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if args.path:
|
if args.path:
|
||||||
resolve_auto_path(args)
|
resolve_auto_path(args)
|
||||||
|
|
@ -87,11 +100,12 @@ def parse_args():
|
||||||
parser.add_argument("--source-prompt", default="", help="Prompt/instruction that produced the artifact.")
|
parser.add_argument("--source-prompt", default="", help="Prompt/instruction that produced the artifact.")
|
||||||
parser.add_argument("--source-type", choices=["agent", "person", "system"], default="agent")
|
parser.add_argument("--source-type", choices=["agent", "person", "system"], default="agent")
|
||||||
parser.add_argument("--as-markdown", action="store_true", help="Wrap --file content in a Markdown fenced code block instead of uploading it.")
|
parser.add_argument("--as-markdown", action="store_true", help="Wrap --file content in a Markdown fenced code block instead of uploading it.")
|
||||||
|
parser.add_argument("--upload-image", help="Upload an image for embedding in artifact content. Prints the URL to reference in markdown/HTML.")
|
||||||
parser.add_argument("--check", action="store_true", help="Fetch /api/capabilities and exit.")
|
parser.add_argument("--check", action="store_true", help="Fetch /api/capabilities and exit.")
|
||||||
parser.add_argument("--dry-run", action="store_true", help="Print request details without posting.")
|
parser.add_argument("--dry-run", action="store_true", help="Print request details without posting.")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.check:
|
if args.check or args.upload_image:
|
||||||
return args
|
return args
|
||||||
|
|
||||||
if args.path:
|
if args.path:
|
||||||
|
|
@ -370,6 +384,28 @@ def content_type_for(path):
|
||||||
return mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
return mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||||
|
|
||||||
|
|
||||||
|
def upload_asset(base_url, token, path):
|
||||||
|
"""Upload an image and return its Daybook URL for embedding in content."""
|
||||||
|
if not path.is_file():
|
||||||
|
raise ValueError(f"image file does not exist: {path}")
|
||||||
|
|
||||||
|
boundary = f"----daybook-{uuid.uuid4().hex}"
|
||||||
|
chunks = [
|
||||||
|
f"--{boundary}\r\n".encode("utf-8"),
|
||||||
|
f'Content-Disposition: form-data; name="file"; filename="{path.name}"\r\n'.encode("utf-8"),
|
||||||
|
f"Content-Type: {content_type_for(path)}\r\n\r\n".encode("utf-8"),
|
||||||
|
path.read_bytes(),
|
||||||
|
b"\r\n",
|
||||||
|
f"--{boundary}--\r\n".encode("utf-8"),
|
||||||
|
]
|
||||||
|
request = Request(f"{base_url}/api/assets", data=b"".join(chunks), method="POST")
|
||||||
|
request.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
|
||||||
|
request.add_header("Authorization", f"Bearer {token}")
|
||||||
|
request.add_header("Accept", "application/json")
|
||||||
|
with urlopen(request, timeout=60) as response:
|
||||||
|
return json.loads(response.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
def request_json(method, url, token=""):
|
def request_json(method, url, token=""):
|
||||||
request = Request(url, method=method)
|
request = Request(url, method=method)
|
||||||
request.add_header("Accept", "application/json")
|
request.add_header("Accept", "application/json")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue