Refine AgentFeed UI and local workflow

This commit is contained in:
peter 2026-06-11 14:01:16 +01:00
parent 3179f832fc
commit ba9f5eb168
29 changed files with 2986 additions and 69 deletions

View file

@ -5,6 +5,6 @@ from .models import Artifact
@admin.register(Artifact)
class ArtifactAdmin(admin.ModelAdmin):
list_display = ("id", "title", "persona", "kind", "day", "created_at")
list_filter = ("persona", "kind", "day")
search_fields = ("id", "title", "persona")
list_display = ("id", "title", "persona", "source_type", "source_name", "kind", "day", "created_at")
list_filter = ("persona", "source_type", "kind", "day")
search_fields = ("id", "title", "persona", "source_name", "source_id")

View file

@ -0,0 +1,41 @@
# Generated by Django 5.2.15 on 2026-06-10 15:14
from django.db import migrations, models
def fill_existing_source_names(apps, schema_editor):
Artifact = apps.get_model('artifacts', 'Artifact')
for artifact in Artifact.objects.filter(source_name=''):
artifact.source_name = artifact.persona
artifact.save(update_fields=['source_name'])
class Migration(migrations.Migration):
dependencies = [
('artifacts', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='artifact',
name='metadata',
field=models.JSONField(blank=True, default=dict),
),
migrations.AddField(
model_name='artifact',
name='source_id',
field=models.CharField(blank=True, max_length=120),
),
migrations.AddField(
model_name='artifact',
name='source_name',
field=models.CharField(blank=True, max_length=120),
),
migrations.AddField(
model_name='artifact',
name='source_type',
field=models.CharField(choices=[('agent', 'Agent'), ('person', 'Person'), ('system', 'System')], default='agent', max_length=20),
),
migrations.RunPython(fill_existing_source_names, migrations.RunPython.noop),
]

View file

@ -10,6 +10,10 @@ class Artifact(models.Model):
PDF = "pdf"
BINARY = "binary"
AGENT = "agent"
PERSON = "person"
SYSTEM = "system"
KIND_CHOICES = [
(HTML_FRAGMENT, "HTML fragment"),
(HTML_FULL, "Full HTML"),
@ -20,6 +24,12 @@ class Artifact(models.Model):
(BINARY, "Binary"),
]
SOURCE_TYPE_CHOICES = [
(AGENT, "Agent"),
(PERSON, "Person"),
(SYSTEM, "System"),
]
id = models.CharField(max_length=8, primary_key=True)
persona = models.CharField(max_length=80, db_index=True)
title = models.CharField(max_length=200)
@ -31,6 +41,10 @@ class Artifact(models.Model):
original_kind = models.CharField(max_length=20)
tags = models.JSONField(default=list, blank=True)
source_prompt = models.TextField(blank=True)
source_type = models.CharField(max_length=20, choices=SOURCE_TYPE_CHOICES, default=AGENT)
source_name = models.CharField(max_length=120, blank=True)
source_id = models.CharField(max_length=120, blank=True)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ["-created_at"]

View file

@ -6,5 +6,7 @@ from . import views
urlpatterns = [
path("", views.feed, name="feed"),
path("a/<str:artifact_id>", views.artifact_view, name="artifact"),
path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-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"),
]

View file

@ -1,4 +1,5 @@
import html
import json
import mimetypes
import secrets
from pathlib import Path
@ -39,6 +40,10 @@ class ArtifactListCreateView(APIView):
artifacts = artifacts.filter(day=day)
if tag := request.query_params.get("tag"):
artifacts = artifacts.filter(tags__contains=[tag])
if kind := request.query_params.get("kind"):
artifacts = artifacts.filter(kind=kind)
if title_query := request.query_params.get("q"):
artifacts = artifacts.filter(title__icontains=title_query)
return Response([serialize_artifact(artifact, request) for artifact in artifacts])
@ -50,7 +55,7 @@ class ArtifactListCreateView(APIView):
artifact = create_binary_artifact(request.data)
if isinstance(artifact, Response):
return artifact
return Response({"id": artifact.id, "url": reverse("artifact", args=[artifact.id])}, status=201)
return Response(serialize_artifact(artifact, request), status=201)
data = request.data
kind = data.get("kind")
@ -62,16 +67,81 @@ class ArtifactListCreateView(APIView):
if missing:
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400)
source_error = validate_source_fields(data)
if source_error:
return source_error
artifact = create_text_artifact(data, request)
return Response({"id": artifact.id, "url": reverse("artifact", args=[artifact.id])}, status=201)
return Response(serialize_artifact(artifact, request), status=201)
class ArtifactDetailView(APIView):
def get(self, request, artifact_id):
artifact = get_object_or_404(Artifact, pk=artifact_id)
return Response(serialize_artifact(artifact, request))
def patch(self, request, artifact_id):
if not has_valid_token(request):
return HttpResponseForbidden("Invalid or missing API token")
artifact = get_object_or_404(Artifact, pk=artifact_id)
data = request.data
if "title" in data:
if not data["title"]:
return Response({"detail": "title cannot be blank"}, status=400)
artifact.title = data["title"]
if "tags" in data:
artifact.tags = normalize_tags(data["tags"])
if "source_prompt" in data:
artifact.source_prompt = data["source_prompt"] or ""
if "source_type" in data:
if data["source_type"] not in {choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES}:
return Response({"detail": "source_type must be agent, person, or system"}, status=400)
artifact.source_type = data["source_type"]
if "source_name" in data:
artifact.source_name = data["source_name"] or ""
if "source_id" in data:
artifact.source_id = data["source_id"] or ""
if "metadata" in data:
if not isinstance(data["metadata"], dict):
return Response({"detail": "metadata must be an object"}, status=400)
artifact.metadata = data["metadata"]
artifact.save(
update_fields=[
"title",
"tags",
"source_prompt",
"source_type",
"source_name",
"source_id",
"metadata",
]
)
return Response(serialize_artifact(artifact, request))
class CapabilitiesView(APIView):
def get(self, request):
return Response(
{
"artifact_kinds": [choice[0] for choice in Artifact.KIND_CHOICES],
"filters": ["persona", "day", "tag", "kind", "q"],
"views": ["rendered", "raw"],
"raw_supported_for": [Artifact.MARKDOWN, Artifact.HTML_FULL],
"source_types": [choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES],
"auth": "bearer_token",
}
)
def feed(request):
persona = request.GET.get("persona", "")
selected_kind = request.GET.get("kind", "")
title_query = request.GET.get("q", "")
selected_day = parse_date(request.GET.get("day", "")) or timezone.localdate()
today = timezone.localdate()
selected_day = parse_date(request.GET.get("day", "")) or today
selected_artifact_id = request.GET.get("artifact", "")
preview_none = request.GET.get("preview") == "none"
selected_view = "raw" if request.GET.get("view") == "raw" else "rendered"
all_artifacts = Artifact.objects.all()
artifacts = all_artifacts
@ -83,12 +153,13 @@ def feed(request):
artifacts = artifacts.filter(title__icontains=title_query)
days = list(artifacts.dates("day", "day", order="DESC"))
if selected_day not in days:
days.append(selected_day)
days.sort(reverse=True)
for day in (today, selected_day):
if day not in days:
days.append(day)
days.sort(reverse=True)
day_artifacts = artifacts.filter(day=selected_day).order_by("-created_at")
selected_artifact = day_artifacts.filter(pk=selected_artifact_id).first()
selected_artifact = None if preview_none else day_artifacts.filter(pk=selected_artifact_id).first()
toggle_query = {"day": selected_day.isoformat(), "view": "rendered"}
if persona:
toggle_query["persona"] = persona
@ -116,7 +187,8 @@ def feed(request):
"selected_kind": selected_kind,
"selected_persona": persona,
"selected_view": selected_view,
"today": timezone.localdate(),
"preview_none": preview_none,
"today": today,
"title_query": title_query,
"kind_choices": Artifact.KIND_CHOICES,
},
@ -177,6 +249,7 @@ def create_text_artifact(data, request):
original_kind=kind,
tags=normalize_tags(data.get("tags", [])),
source_prompt=data.get("source_prompt", ""),
**source_fields(data, persona),
)
@ -188,6 +261,9 @@ def create_binary_artifact(data):
missing.append("file")
if missing:
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400)
source_error = validate_source_fields(data)
if source_error:
return source_error
now = timezone.localtime()
artifact_id = generate_id()
@ -217,6 +293,7 @@ def create_binary_artifact(data):
original_kind=kind,
tags=normalize_tags(data.get("tags", [])),
source_prompt=data.get("source_prompt", ""),
**source_fields(data, persona),
)
@ -275,6 +352,41 @@ def normalize_tags(tags):
return []
def validate_source_fields(data):
if source_type := data.get("source_type"):
if source_type not in {choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES}:
return Response({"detail": "source_type must be agent, person, or system"}, status=400)
try:
parse_metadata_value(data.get("metadata", {}))
except ValueError as exc:
return Response({"detail": str(exc)}, status=400)
return None
def source_fields(data, persona):
return {
"source_type": data.get("source_type") or Artifact.AGENT,
"source_name": data.get("source_name") or persona,
"source_id": data.get("source_id") or "",
"metadata": parse_metadata_value(data.get("metadata", {})),
}
def parse_metadata_value(value):
if value in (None, ""):
return {}
if isinstance(value, dict):
return value
if isinstance(value, str):
try:
parsed = json.loads(value)
except json.JSONDecodeError as exc:
raise ValueError("metadata must be an object") from exc
if isinstance(parsed, dict):
return parsed
raise ValueError("metadata must be an object")
def binary_kind(content_type):
if content_type.startswith("image/"):
return Artifact.IMAGE
@ -294,6 +406,9 @@ def serialize_artifact(artifact, request):
return {
"id": artifact.id,
"url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])),
"api_url": request.build_absolute_uri(reverse("artifact-detail-api", args=[artifact.id])),
"raw_url": request.build_absolute_uri(f"{reverse('artifact', args=[artifact.id])}?raw=1"),
"raw_available": raw_artifact_path(artifact) is not None,
"persona": artifact.persona,
"title": artifact.title,
"kind": artifact.kind,
@ -302,6 +417,10 @@ def serialize_artifact(artifact, request):
"created_at": artifact.created_at.isoformat(),
"tags": artifact.tags,
"source_prompt": artifact.source_prompt,
"source_type": artifact.source_type,
"source_name": artifact.source_name,
"source_id": artifact.source_id,
"metadata": artifact.metadata,
}