Add multi-user auth, settings area, and unified label feed

Turns Daybook into a multi-user tool and reworks the feed UI:

- Multi-user: Django session login (username/password + remember-me),
  per-user personal API tokens (DRF authtoken), whole app gated behind
  login. Each artifact has an owner; feeds are private per user.
- Rename persona -> source (free-text label for whatever posted an
  artifact: agent-profile, script, or person). source + tags form one
  unified label system; single label filter matches tags, source has
  its own filter/column/sidebar.
- Settings area (gear/user menu, top-right): Account (view/regenerate
  token) and Demo Data (in-app button seeding one artifact per kind).
  User account management via Django admin (superusers only).
- Feed rework: rolling 7-day window with sticky inline date separators
  and infinite scroll; left sidebar is now a Sources list with per-source
  unread counts; artifact type shown as text after title; source in its
  own column.
- Unread tracking: read_at on Artifact, marked read when previewed,
  bold titles for unread, sidebar unread badges, old banner removed.
- Tag colours: global shared registry, hash-based default with optional
  per-tag override via tag_colors on POST/PATCH.
- Ingest: token resolves owner; same source name under two users' tokens
  stays isolated. daybook_post.py renamed persona->source, requires a
  real token, --check accepts a bearer token. Docs updated to match.

Migrations 0004-0008 rename persona, add owner (nullable -> backfill to
first superuser -> required), and add read_at. Fresh installs no-op the
backfill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ritte 2026-07-24 09:06:46 +02:00
parent 203f9b578e
commit 37cd96fa7a
33 changed files with 1614 additions and 367 deletions

View file

@ -0,0 +1,13 @@
---
description: Post one demo artifact of every kind (markdown, html_fragment, html_full, deck, image, pdf, binary), tagged, dated today.
---
Run the demo seeding script to populate today with one tagged sample artifact per supported kind:
```bash
python scripts/seed_demo_artifacts.py
```
Requires the Daybook dev server to be running on the project's fixed local port: `python manage.py runserver 127.0.0.1:8001`. The script defaults to `DAYBOOK_URL=http://127.0.0.1:8001` / `DAYBOOK_TOKEN=dev-token`; no overrides needed unless the user has changed these.
After running, report each posted artifact's kind and URL, and remind the user to reload the feed for today.

View file

@ -2,15 +2,18 @@
## Project
Daybook is a self-hosted Django + Django REST Framework web app where AI agents publish artifacts and a human browses them in a time-ordered, persona-filterable feed.
Daybook is a self-hosted, multi-user Django + Django REST Framework web app where AI agents, scripts, or people publish artifacts and each logged-in user browses their own time-ordered, label-filterable feed.
The core product principle is: agents publish content, not files. The server owns IDs, styling, storage layout, dates, and normalization into browser-native HTML, image, or PDF outputs.
## Current MVP Decisions
- Use a single shared ingest API token for now.
- Require `persona` in every artifact payload.
- Do not add reader authentication in the local/self-hosted MVP.
- Each user has a personal ingest API token (DRF `Token` model), viewable/regenerable from Settings → Account.
- Require `source` in every artifact payload — a free-text label identifying what posted it (agent-profile, script, or person name). Ownership of the resulting artifact comes from whichever user's token authenticated the POST, not from the `source` value itself.
- `source` and `tags` together form one unified **label** system: every artifact shows all its labels next to the title, and a single `label` filter/query param matches against either.
- Require login (Django session auth) for the whole app, including the feed and API reads. Ingest (POST/PATCH) stays per-user bearer-token authenticated, since agents don't have browser sessions.
- User accounts are created/managed via Django admin (superusers only — regular users are not staff and cannot access `/admin/`).
- `TagColor` (label color overrides) stays global/shared across all users.
- Use random 8-character artifact IDs.
- Start as a plain Django project; add Docker after core ingest and viewer work.
- Preserve original markdown as `.md` alongside rendered `.html`.
@ -22,7 +25,8 @@ The core product principle is: agents publish content, not files. The server own
- Main app: `artifacts/`.
- Theme CSS: `static/themes/`.
- Feed template: `templates/artifacts/feed.html`.
- Stored artifact files live under `artifacts_store/{persona}/{YYYY-MM-DD}/`.
- Settings templates: `templates/artifacts/settings_*.html`.
- Stored artifact files live under `artifacts_store/{source}/{YYYY-MM-DD}/`.
## Implementation Guidelines
@ -38,6 +42,8 @@ The core product principle is: agents publish content, not files. The server own
- Develop and test locally in this workspace by default.
- Use the local Django commands for normal iteration: `python manage.py check`, `python manage.py migrate`, and `python manage.py runserver`.
- Run the local development server on `127.0.0.1:8001`. Port `8000` may be reserved or blocked on this workstation.
- Always use this same fixed port for the dev server. Never start a second instance on a different port; if `8001` is already serving, reuse it rather than starting another.
- All artifact-posting tools (`skills/daybook-posting/scripts/daybook_post.py`, `scripts/seed_demo_artifacts.py`, and any future posting skill) must default `DAYBOOK_URL` to `http://127.0.0.1:8001` to match.
- Do not deploy to the Proxmox NUC during routine code iteration.
- Treat the NUC container as the local live/staging target. Deploy there only at explicit stable checkpoints, such as an end-of-day build.
- Before any NUC deployment, preserve `/opt/daybook/deploy/.env` and `/opt/daybook/deploy/data`.
@ -60,9 +66,9 @@ The core product principle is: agents publish content, not files. The server own
## API Defaults
- MVP ingest endpoint: `POST /api/artifacts`
- MVP list endpoint: `GET /api/artifacts?persona=&day=&tag=`
- MVP list endpoint: `GET /api/artifacts?label=&day=&kind=&q=`
- Artifact view: `GET /a/{id}`
- Development token: `Authorization: Bearer dev-token`
- Auth: personal per-user token from Settings → Account, `Authorization: Bearer <token>`. Get one by creating a superuser (`python manage.py createsuperuser`), logging in, and visiting Settings → Account.
## Claude Code / OpenCode

View file

@ -5,6 +5,6 @@ from .models import Artifact
@admin.register(Artifact)
class ArtifactAdmin(admin.ModelAdmin):
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")
list_display = ("id", "title", "owner", "source", "source_type", "source_name", "kind", "day", "created_at")
list_filter = ("owner", "source", "source_type", "kind", "day")
search_fields = ("id", "title", "source", "source_name", "source_id")

87
artifacts/demo_data.py Normal file
View file

@ -0,0 +1,87 @@
"""Sample artifact content for the Settings -> Demo Data button.
Creates one artifact of every supported kind, owned by the requesting user,
tagged "demo", with source "demo".
"""
SOURCE = "demo"
TEXT_SAMPLES = [
(
"markdown",
"Seed: Markdown Sample",
"# Markdown Sample\n\nThis is a **seed** artifact demonstrating markdown rendering.\n\n- item one\n- item two\n",
"markdown",
),
(
"html_fragment",
"Seed: HTML Fragment Sample",
"<h2>HTML Fragment</h2><p>This is a seed HTML fragment artifact.</p>",
"html-fragment",
),
(
"html_full",
"Seed: Full HTML Sample",
"<!doctype html><html><head><title>Seed Full HTML</title></head>"
"<body><h1>Full HTML Doc</h1><p>Seed artifact.</p></body></html>",
"html-full",
),
(
"deck",
"Seed: Deck Sample",
"<section><h1>Slide One</h1><p>Seed deck artifact.</p></section>"
"<section><h1>Slide Two</h1><p>More content.</p></section>",
"deck",
),
]
SEED_PNG_BYTES = bytes.fromhex(
"89504e470d0a1a0a0000000d494844520000000100000001080600000"
"01f15c4890000000a49444154789c6360000002000155e59a1a0000000049454e44ae426082"
)
SEED_PDF_BYTES = (
b"%PDF-1.1\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\n"
b"trailer<</Root 1 0 R>>\n"
)
SEED_BIN_BYTES = b"\x00\x01\x02\x03seed-binary-data\xff\xfe"
BINARY_SAMPLES = [
("Seed: Image Sample", "seed_pixel.png", "image/png", SEED_PNG_BYTES, "image"),
("Seed: PDF Sample", "seed_doc.pdf", "application/pdf", SEED_PDF_BYTES, "pdf"),
("Seed: Binary Sample", "seed_data.bin", "application/octet-stream", SEED_BIN_BYTES, "binary"),
]
def create_demo_artifacts(user, request):
from django.core.files.uploadedfile import SimpleUploadedFile
from .views import create_binary_artifact, create_text_artifact
created = 0
for kind, title, content, tag in TEXT_SAMPLES:
data = {
"source": SOURCE,
"title": title,
"kind": kind,
"content": content,
"tags": ["demo", tag],
}
create_text_artifact(data, request, user)
created += 1
for title, filename, content_type, file_bytes, tag in BINARY_SAMPLES:
uploaded = SimpleUploadedFile(filename, file_bytes, content_type=content_type)
data = {
"source": SOURCE,
"title": title,
"file": uploaded,
"tags": ["demo", tag],
}
create_binary_artifact(data, user)
created += 1
return created

6
artifacts/forms.py Normal file
View file

@ -0,0 +1,6 @@
from django import forms
from django.contrib.auth.forms import AuthenticationForm
class DaybookLoginForm(AuthenticationForm):
remember_me = forms.BooleanField(required=False, initial=True)

View file

@ -0,0 +1,20 @@
# Generated by Django 5.2.15 on 2026-07-23 06:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('artifacts', '0002_artifact_metadata_artifact_source_id_and_more'),
]
operations = [
migrations.CreateModel(
name='TagColor',
fields=[
('name', models.CharField(max_length=40, primary_key=True, serialize=False)),
('color', models.CharField(max_length=20)),
],
),
]

View file

@ -0,0 +1,16 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("artifacts", "0003_tagcolor"),
]
operations = [
migrations.RenameField(
model_name="artifact",
old_name="persona",
new_name="source",
),
]

View file

@ -0,0 +1,24 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("artifacts", "0004_rename_persona_to_source"),
]
operations = [
migrations.AddField(
model_name="artifact",
name="owner",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="artifacts",
to=settings.AUTH_USER_MODEL,
),
),
]

View file

@ -0,0 +1,27 @@
from django.db import migrations
def backfill_owner(apps, schema_editor):
User = apps.get_model("auth", "User")
Artifact = apps.get_model("artifacts", "Artifact")
first_superuser = User.objects.filter(is_superuser=True).order_by("date_joined").first()
if first_superuser is None:
return
Artifact.objects.filter(owner__isnull=True).update(owner=first_superuser)
def noop_reverse(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
("artifacts", "0005_add_owner_nullable"),
]
operations = [
migrations.RunPython(backfill_owner, noop_reverse),
]

View file

@ -0,0 +1,23 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("artifacts", "0006_backfill_owner"),
]
operations = [
migrations.AlterField(
model_name="artifact",
name="owner",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="artifacts",
to=settings.AUTH_USER_MODEL,
),
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.2.15 on 2026-07-23 21:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('artifacts', '0007_owner_required'),
]
operations = [
migrations.AddField(
model_name='artifact',
name='read_at',
field=models.DateTimeField(blank=True, null=True),
),
]

View file

@ -1,3 +1,4 @@
from django.conf import settings
from django.db import models
@ -31,7 +32,8 @@ class Artifact(models.Model):
]
id = models.CharField(max_length=8, primary_key=True)
persona = models.CharField(max_length=80, db_index=True)
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="artifacts")
source = models.CharField(max_length=80, db_index=True)
title = models.CharField(max_length=200)
kind = models.CharField(max_length=20, choices=KIND_CHOICES)
theme = models.CharField(max_length=40, blank=True)
@ -45,9 +47,22 @@ class Artifact(models.Model):
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)
read_at = models.DateTimeField(null=True, blank=True)
@property
def is_unread(self):
return self.read_at is None
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"{self.title} ({self.persona})"
return f"{self.title} ({self.source})"
class TagColor(models.Model):
name = models.CharField(max_length=40, primary_key=True)
color = models.CharField(max_length=20)
def __str__(self):
return f"{self.name} ({self.color})"

View file

@ -5,8 +5,13 @@ from . import views
urlpatterns = [
path("", views.feed, name="feed"),
path("feed/older", views.feed_older, name="feed-older"),
path("a/<str:artifact_id>/read", views.mark_artifact_read, name="artifact-mark-read"),
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"),
path("settings/", views.settings_home, name="settings-home"),
path("settings/account/", views.settings_account, name="settings-account"),
path("settings/demo-data/", views.settings_demo_data, name="settings-demo-data"),
]

View file

@ -1,27 +1,48 @@
import hashlib
import html
import json
import mimetypes
import re
import secrets
from datetime import timedelta
from pathlib import Path
from urllib.parse import urlencode
import markdown
from django.conf import settings
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden
from django.shortcuts import get_object_or_404, render
from django.contrib import messages
from django.contrib.auth import views as auth_views
from django.contrib.auth.decorators import login_required
from django.db.models import Count, Q
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.template.defaultfilters import slugify
from django.urls import reverse
from django.utils.dateparse import parse_date
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.decorators.http import require_POST
from rest_framework.authtoken.models import Token
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import Artifact
from .demo_data import create_demo_artifacts
from .forms import DaybookLoginForm
from .models import Artifact, TagColor
TEXT_KINDS = {Artifact.HTML_FRAGMENT, Artifact.HTML_FULL, Artifact.MARKDOWN, Artifact.DECK}
REMEMBER_ME_SESSION_AGE = 60 * 60 * 24 * 30 # 30 days
def filter_by_label(queryset, label):
matching_ids = [artifact.id for artifact in queryset.only("id", "tags") if label in artifact.tags]
return queryset.filter(pk__in=matching_ids)
DEFAULT_THEMES = {
Artifact.HTML_FRAGMENT: "report",
Artifact.MARKDOWN: "report",
@ -29,17 +50,18 @@ DEFAULT_THEMES = {
}
@method_decorator(login_required, name="get")
class ArtifactListCreateView(APIView):
parser_classes = [JSONParser, FormParser, MultiPartParser]
def get(self, request):
artifacts = Artifact.objects.all()
if persona := request.query_params.get("persona"):
artifacts = artifacts.filter(persona=persona)
artifacts = Artifact.objects.filter(owner=request.user)
if source := request.query_params.get("source"):
artifacts = artifacts.filter(source=source)
if label := request.query_params.get("label"):
artifacts = filter_by_label(artifacts, label)
if day := request.query_params.get("day"):
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"):
@ -48,13 +70,15 @@ class ArtifactListCreateView(APIView):
return Response([serialize_artifact(artifact, request) for artifact in artifacts])
def post(self, request):
if not has_valid_token(request):
user = resolve_token_user(request)
if user is None:
return HttpResponseForbidden("Invalid or missing API token")
if request.content_type and request.content_type.startswith("multipart/"):
artifact = create_binary_artifact(request.data)
artifact = create_binary_artifact(request.data, user)
if isinstance(artifact, Response):
return artifact
apply_tag_colors(request.data)
return Response(serialize_artifact(artifact, request), status=201)
data = request.data
@ -62,7 +86,7 @@ class ArtifactListCreateView(APIView):
if kind not in TEXT_KINDS:
return Response({"detail": "kind must be html_fragment, html_full, markdown, or deck"}, status=400)
required = ["persona", "title", "content"]
required = ["source", "title", "content"]
missing = [field for field in required if not data.get(field)]
if missing:
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400)
@ -71,20 +95,23 @@ class ArtifactListCreateView(APIView):
if source_error:
return source_error
artifact = create_text_artifact(data, request)
artifact = create_text_artifact(data, request, user)
apply_tag_colors(data)
return Response(serialize_artifact(artifact, request), status=201)
@method_decorator(login_required, name="get")
class ArtifactDetailView(APIView):
def get(self, request, artifact_id):
artifact = get_object_or_404(Artifact, pk=artifact_id)
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
return Response(serialize_artifact(artifact, request))
def patch(self, request, artifact_id):
if not has_valid_token(request):
user = resolve_token_user(request)
if user is None:
return HttpResponseForbidden("Invalid or missing API token")
artifact = get_object_or_404(Artifact, pk=artifact_id)
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=user)
data = request.data
if "title" in data:
if not data["title"]:
@ -106,6 +133,11 @@ class ArtifactDetailView(APIView):
if not isinstance(data["metadata"], dict):
return Response({"detail": "metadata must be an object"}, status=400)
artifact.metadata = data["metadata"]
if "tag_colors" in data:
try:
apply_tag_colors(data)
except ValueError as exc:
return Response({"detail": str(exc)}, status=400)
artifact.save(
update_fields=[
"title",
@ -121,11 +153,16 @@ class ArtifactDetailView(APIView):
class CapabilitiesView(APIView):
"""Static capability info. Reachable by a logged-in session or a valid API token,
so agents can check connectivity before posting. Exposes no user data."""
def get(self, request):
if not request.user.is_authenticated and resolve_token_user(request) is None:
return HttpResponseForbidden("Login or a valid API token is required")
return Response(
{
"artifact_kinds": [choice[0] for choice in Artifact.KIND_CHOICES],
"filters": ["persona", "day", "tag", "kind", "q"],
"filters": ["source", "label", "day", "kind", "q"],
"views": ["rendered", "raw"],
"raw_supported_for": [Artifact.MARKDOWN, Artifact.HTML_FULL],
"source_types": [choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES],
@ -134,35 +171,71 @@ class CapabilitiesView(APIView):
)
FEED_WINDOW_DAYS = 7
def apply_feed_filters(queryset, source="", label="", kind="", title_query=""):
if source:
queryset = queryset.filter(source=source)
if label:
queryset = filter_by_label(queryset, label)
if kind:
queryset = queryset.filter(kind=kind)
if title_query:
queryset = queryset.filter(title__icontains=title_query)
return queryset
def group_artifacts_by_day(artifacts, overrides):
"""Turn a flat, reverse-chronological list into [{day, artifacts}] groups."""
groups = []
for artifact in artifacts:
# Source has its own column in the row, so chips are the free-form tags only.
artifact.label_chips = tags_with_colors(artifact.tags, overrides)
if not groups or groups[-1]["day"] != artifact.day:
groups.append({"day": artifact.day, "artifacts": []})
groups[-1]["artifacts"].append(artifact)
return groups
@login_required
def feed(request):
persona = request.GET.get("persona", "")
selected_source = request.GET.get("source", "")
selected_label = request.GET.get("label", "")
selected_kind = request.GET.get("kind", "")
title_query = request.GET.get("q", "")
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
if persona:
artifacts = artifacts.filter(persona=persona)
if selected_kind:
artifacts = artifacts.filter(kind=selected_kind)
if title_query:
artifacts = artifacts.filter(title__icontains=title_query)
days = list(artifacts.dates("day", "day", order="DESC"))
for day in (today, selected_day):
if day not in days:
days.append(day)
days.sort(reverse=True)
all_artifacts = Artifact.objects.filter(owner=request.user)
artifacts = apply_feed_filters(
all_artifacts, selected_source, selected_label, selected_kind, title_query
)
day_artifacts = artifacts.filter(day=selected_day).order_by("-created_at")
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
window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1)
if not artifacts.filter(day__gte=window_start).exists():
# Nothing recent for these filters: fall back to the most recent days that
# do match, so selecting a source is never met with a blank list.
recent_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS])
if recent_days:
window_start = recent_days[-1]
window_artifacts = list(artifacts.filter(day__gte=window_start).order_by("-created_at"))
older_count = artifacts.filter(day__lt=window_start).count()
overrides = tag_color_overrides()
day_groups = group_artifacts_by_day(window_artifacts, overrides)
selected_artifact = None
if not preview_none:
selected_artifact = next((a for a in window_artifacts if a.id == selected_artifact_id), None)
toggle_query = {"view": "rendered"}
if selected_source:
toggle_query["source"] = selected_source
if selected_label:
toggle_query["label"] = selected_label
if selected_kind:
toggle_query["kind"] = selected_kind
if title_query:
@ -172,20 +245,36 @@ def feed(request):
rendered_url = f"?{urlencode(toggle_query)}"
toggle_query["view"] = "raw"
raw_url = f"?{urlencode(toggle_query)}"
personas = all_artifacts.order_by("persona").values_list("persona", flat=True).distinct()
# Source counts come from the other filters only, so the sidebar still shows
# every source available under the current label/type/name filters.
source_scoped = apply_feed_filters(
all_artifacts, "", selected_label, selected_kind, title_query
)
source_counts = (
source_scoped.values("source")
.annotate(count=Count("id", filter=Q(read_at__isnull=True)))
.order_by("source")
)
all_labels = sorted({tag for tags in all_artifacts.values_list("tags", flat=True) for tag in tags})
return render(
request,
"artifacts/feed.html",
{
"artifacts": day_artifacts,
"days": days,
"personas": personas,
"artifacts": window_artifacts,
"day_groups": day_groups,
"older_count": older_count,
"window_start": window_start,
"source_counts": source_counts,
"total_count": source_scoped.filter(read_at__isnull=True).count(),
"all_labels": labels_with_colors_list(all_labels, overrides),
"raw_url": raw_url,
"rendered_url": rendered_url,
"selected_artifact": selected_artifact,
"selected_day": selected_day,
"selected_kind": selected_kind,
"selected_persona": persona,
"selected_label": selected_label,
"selected_source": selected_source,
"selected_view": selected_view,
"preview_none": preview_none,
"today": today,
@ -195,9 +284,59 @@ def feed(request):
)
@login_required
@require_POST
def mark_artifact_read(request, artifact_id):
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
if artifact.read_at is None:
artifact.read_at = timezone.now()
artifact.save(update_fields=["read_at"])
return JsonResponse({"id": artifact.id, "read_at": artifact.read_at.isoformat()})
@login_required
def feed_older(request):
"""Return the next page of older day-groups as an HTML fragment for infinite scroll."""
before = parse_date(request.GET.get("before", "")) or timezone.localdate()
artifacts = apply_feed_filters(
Artifact.objects.filter(owner=request.user),
request.GET.get("source", ""),
request.GET.get("label", ""),
request.GET.get("kind", ""),
request.GET.get("q", ""),
).filter(day__lt=before).order_by("-created_at")
# Take a whole page of days at a time so a day is never split across pages.
page_days = list(artifacts.dates("day", "day", order="DESC")[:FEED_WINDOW_DAYS])
if not page_days:
return HttpResponse("")
page_artifacts = list(artifacts.filter(day__gte=page_days[-1]))
day_groups = group_artifacts_by_day(page_artifacts, tag_color_overrides())
remaining = artifacts.filter(day__lt=page_days[-1]).count()
return render(
request,
"artifacts/_day_groups.html",
{
"day_groups": day_groups,
"next_before": page_days[-1].isoformat(),
"has_more": remaining > 0,
"selected_artifact": None,
"selected_source": request.GET.get("source", ""),
"selected_label": request.GET.get("label", ""),
"selected_kind": request.GET.get("kind", ""),
"title_query": request.GET.get("q", ""),
"selected_view": "raw" if request.GET.get("view") == "raw" else "rendered",
"today": timezone.localdate(),
},
)
@login_required
@xframe_options_sameorigin
def artifact_view(request, artifact_id):
artifact = get_object_or_404(Artifact, pk=artifact_id)
artifact = get_object_or_404(Artifact, pk=artifact_id, owner=request.user)
if request.GET.get("raw") == "1":
raw_path = raw_artifact_path(artifact)
if raw_path and raw_path.exists():
@ -212,16 +351,16 @@ def artifact_view(request, artifact_id):
return FileResponse(path.open("rb"), content_type=content_type or "application/octet-stream")
def create_text_artifact(data, request):
def create_text_artifact(data, request, user):
now = timezone.localtime()
artifact_id = generate_id()
persona = slugify(data["persona"]) or "unknown"
source = slugify(data["source"]) or "unknown"
kind = data["kind"]
theme = data.get("theme") or DEFAULT_THEMES.get(kind, "base")
title = data["title"]
content = data["content"]
directory = Path(persona) / now.date().isoformat()
directory = Path(source) / now.date().isoformat()
target_dir = settings.ARTIFACTS_ROOT / directory
target_dir.mkdir(parents=True, exist_ok=True)
@ -240,7 +379,8 @@ def create_text_artifact(data, request):
return Artifact.objects.create(
id=artifact_id,
persona=persona,
owner=user,
source=source,
title=title,
kind=kind,
theme=theme,
@ -249,13 +389,13 @@ 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),
**source_fields(data, source),
)
def create_binary_artifact(data):
def create_binary_artifact(data, user):
uploaded = data.get("file")
required = ["persona", "title"]
required = ["source", "title"]
missing = [field for field in required if not data.get(field)]
if not uploaded:
missing.append("file")
@ -267,12 +407,12 @@ def create_binary_artifact(data):
now = timezone.localtime()
artifact_id = generate_id()
persona = slugify(data["persona"]) or "unknown"
source = slugify(data["source"]) or "unknown"
title = data["title"]
content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or ""
kind = binary_kind(content_type)
directory = Path(persona) / now.date().isoformat()
directory = Path(source) / now.date().isoformat()
target_dir = settings.ARTIFACTS_ROOT / directory
target_dir.mkdir(parents=True, exist_ok=True)
@ -284,7 +424,8 @@ def create_binary_artifact(data):
return Artifact.objects.create(
id=artifact_id,
persona=persona,
owner=user,
source=source,
title=title,
kind=kind,
theme="",
@ -293,7 +434,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),
**source_fields(data, source),
)
@ -338,10 +479,17 @@ def generate_id():
return artifact_id
def has_valid_token(request):
expected = getattr(settings, "DAYBOOK_API_TOKEN", "")
def resolve_token_user(request):
auth = request.headers.get("Authorization", "")
return bool(expected) and auth == f"Bearer {expected}"
if not auth.startswith("Bearer "):
return None
key = auth[len("Bearer "):].strip()
if not key:
return None
try:
return Token.objects.select_related("user").get(key=key).user
except Token.DoesNotExist:
return None
def normalize_tags(tags):
@ -352,6 +500,39 @@ def normalize_tags(tags):
return []
HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
TAG_PALETTE = [
"#4f6df5", "#e0578c", "#2fa88f", "#d98b2b",
"#8c5fd6", "#3fa1d8", "#c14f4f", "#5aa552",
]
def tag_color(name, overrides):
if override := overrides.get(name):
return override
index = int(hashlib.sha256(name.encode("utf-8")).hexdigest(), 16) % len(TAG_PALETTE)
return TAG_PALETTE[index]
def tag_color_overrides():
return dict(TagColor.objects.values_list("name", "color"))
def tags_with_colors(tags, overrides=None):
overrides = overrides if overrides is not None else tag_color_overrides()
return [{"name": tag, "color": tag_color(tag, overrides)} for tag in tags]
def labels_with_colors_list(labels, overrides=None):
overrides = overrides if overrides is not None else tag_color_overrides()
return [{"name": label, "color": tag_color(label, overrides)} for label in labels]
def labels_with_colors(artifact, overrides=None):
return labels_with_colors_list([artifact.source, *artifact.tags], overrides)
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}:
@ -360,13 +541,39 @@ def validate_source_fields(data):
parse_metadata_value(data.get("metadata", {}))
except ValueError as exc:
return Response({"detail": str(exc)}, status=400)
try:
parse_tag_colors_value(data.get("tag_colors", {}))
except ValueError as exc:
return Response({"detail": str(exc)}, status=400)
return None
def source_fields(data, persona):
def parse_tag_colors_value(value):
if value in (None, ""):
return {}
if isinstance(value, str):
try:
value = json.loads(value)
except json.JSONDecodeError as exc:
raise ValueError("tag_colors must be an object") from exc
if not isinstance(value, dict):
raise ValueError("tag_colors must be an object")
for tag, color in value.items():
if not HEX_COLOR_RE.match(str(color)):
raise ValueError(f"tag_colors[{tag}] must be a hex color like #RRGGBB")
return value
def apply_tag_colors(data):
tag_colors = parse_tag_colors_value(data.get("tag_colors", {}))
for tag, color in tag_colors.items():
TagColor.objects.update_or_create(name=tag, defaults={"color": color})
def source_fields(data, source):
return {
"source_type": data.get("source_type") or Artifact.AGENT,
"source_name": data.get("source_name") or persona,
"source_name": data.get("source_name") or source,
"source_id": data.get("source_id") or "",
"metadata": parse_metadata_value(data.get("metadata", {})),
}
@ -409,18 +616,20 @@ def serialize_artifact(artifact, request):
"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,
"source": artifact.source,
"title": artifact.title,
"kind": artifact.kind,
"theme": artifact.theme,
"day": artifact.day.isoformat(),
"created_at": artifact.created_at.isoformat(),
"tags": artifact.tags,
"tags": tags_with_colors(artifact.tags),
"labels": labels_with_colors(artifact),
"source_prompt": artifact.source_prompt,
"source_type": artifact.source_type,
"source_name": artifact.source_name,
"source_id": artifact.source_id,
"metadata": artifact.metadata,
"read_at": artifact.read_at.isoformat() if artifact.read_at else None,
}
@ -441,6 +650,52 @@ def raw_artifact_path(artifact):
return None
class DaybookLoginView(auth_views.LoginView):
template_name = "artifacts/login.html"
authentication_form = DaybookLoginForm
def form_valid(self, form):
response = super().form_valid(form)
if form.cleaned_data.get("remember_me"):
self.request.session.set_expiry(REMEMBER_ME_SESSION_AGE)
else:
self.request.session.set_expiry(0)
return response
@login_required
def settings_home(request):
return redirect("settings-account")
@login_required
def settings_account(request):
token, _ = Token.objects.get_or_create(user=request.user)
if request.method == "POST" and request.POST.get("action") == "regenerate":
token.delete()
token = Token.objects.create(user=request.user)
messages.success(request, "API token regenerated.")
return redirect("settings-account")
return render(
request,
"artifacts/settings_account.html",
{"token": token, "active_section": "account"},
)
@login_required
def settings_demo_data(request):
if request.method == "POST":
count = create_demo_artifacts(request.user, request)
messages.success(request, f"Created {count} demo artifacts.")
return redirect("settings-demo-data")
return render(
request,
"artifacts/settings_demo_data.html",
{"active_section": "demo-data"},
)
def raw_unavailable_html(artifact):
return f"""<!doctype html>
<html>

View file

@ -17,6 +17,7 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"rest_framework.authtoken",
"artifacts",
]
@ -59,6 +60,9 @@ DATABASES = {
AUTH_PASSWORD_VALIDATORS = []
LOGIN_URL = "login"
LOGIN_REDIRECT_URL = "feed"
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
@ -76,4 +80,3 @@ STORAGES = {
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
ARTIFACTS_ROOT = Path(os.environ.get("ARTIFACTS_ROOT", DATA_DIR / "artifacts_store"))
DAYBOOK_API_TOKEN = os.environ.get("DAYBOOK_API_TOKEN", "dev-token")

View file

@ -1,8 +1,13 @@
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path
from artifacts.views import DaybookLoginView
urlpatterns = [
path("admin/", admin.site.urls),
path("accounts/login/", DaybookLoginView.as_view(), name="login"),
path("accounts/logout/", auth_views.LogoutView.as_view(next_page="login"), name="logout"),
path("", include("artifacts.urls")),
]

View file

@ -2,17 +2,26 @@
This is the shortest path for an agent to publish and retrieve artifacts from Daybook.
## 0. Get Your API Token
Log in to the app in a browser and copy your personal token from **Settings → Account**
(`http://127.0.0.1:8001/settings/account/`). Every artifact you post with it lands in your
feed, and only yours.
## 1. Check Capabilities
Also confirms your token is valid:
```powershell
Invoke-RestMethod "http://127.0.0.1:8001/api/capabilities"
Invoke-RestMethod "http://127.0.0.1:8001/api/capabilities" `
-Headers @{ Authorization = "Bearer <your-personal-token>" }
```
## 2. Publish Markdown
```powershell
$body = @{
persona = "hermes"
source = "hermes-research"
source_type = "agent"
source_name = "Hermes Agent"
source_id = "hermes-local"
@ -29,7 +38,7 @@ $body = @{
$artifact = Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts" `
-Method Post `
-Headers @{ Authorization = "Bearer dev-token" } `
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
-ContentType "application/json" `
-Body $body
@ -46,7 +55,7 @@ $artifact.url
```powershell
$body = @{
persona = "athena"
source = "athena"
source_type = "agent"
source_name = "Athena Agent"
kind = "html_fragment"
@ -59,7 +68,7 @@ $body = @{
Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts" `
-Method Post `
-Headers @{ Authorization = "Bearer dev-token" } `
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
-ContentType "application/json" `
-Body $body
```
@ -68,8 +77,8 @@ Invoke-RestMethod `
```powershell
curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" `
-H "Authorization: Bearer dev-token" `
-F "persona=orion" `
-H "Authorization: Bearer <your-personal-token>" `
-F "source=orion" `
-F "source_type=agent" `
-F "source_name=Orion Agent" `
-F "title=Generated PDF" `
@ -79,8 +88,13 @@ curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" `
## 5. Find Recent Artifacts
> **Note:** artifact read endpoints (steps 5 and 6) require a logged-in **browser session**;
> a bearer token is not accepted. These are for browsing from the app, not for agents.
> Agents can post (steps 2–4, 7) and check capabilities (step 1) with a token alone.
```powershell
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?persona=hermes"
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?source=hermes-research"
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?label=status"
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?kind=markdown&q=status"
```
@ -105,7 +119,7 @@ $body = @{
Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" `
-Method Patch `
-Headers @{ Authorization = "Bearer dev-token" } `
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
-ContentType "application/json" `
-Body $body
```
@ -114,7 +128,7 @@ Invoke-RestMethod `
Recommended fields for every agent post:
- `persona`: stable feed identity.
- `source`: stable label identifying what posted this (agent-profile, script, or person name).
- `source_type`: usually `agent`.
- `source_name`: readable agent name.
- `source_id`: stable ID if the agent has one.

View file

@ -8,13 +8,19 @@ Base URL while running locally:
http://127.0.0.1:8001
```
Authentication for write endpoints:
Authentication for write endpoints (POST/PATCH) — a personal per-user API token, from Settings → Account in the app:
```http
Authorization: Bearer dev-token
Authorization: Bearer <your-personal-token>
```
Read endpoints are currently unauthenticated for the local/self-hosted MVP.
Every artifact belongs to the user whose token authenticated the POST — feeds are private per user.
Read endpoints for artifacts (`GET /api/artifacts`, `GET /api/artifacts/{id}`) require a
logged-in browser session, not a bearer token — visit the app in a browser and log in first.
`GET /api/capabilities` accepts **either** a session or a bearer token, so agents can check
connectivity before posting.
## Artifact Kinds
@ -32,13 +38,18 @@ Currently supported kinds:
## Source Fields
Every artifact can identify who or what produced it.
Every artifact can identify who or what produced it. `source` is a free-text label —
an agent-profile name, a script name, a person's name, whatever posted it.
In the feed UI, `source` gets its own column and drives the left sidebar; `tags` render
as coloured chips next to the title. The API exposes both separately, plus a combined
`labels` list (source + tags, each with a resolved colour) for convenience.
| Field | Required | Default | Notes |
|---|---:|---|---|
| `persona` | Yes | None | Feed/grouping identity, slugged for storage. |
| `source` | Yes | None | Label identifying what posted this, slugged for storage. |
| `source_type` | No | `agent` | One of `agent`, `person`, `system`. |
| `source_name` | No | `persona` | Human-readable source name. |
| `source_name` | No | `source` | Human-readable source name. |
| `source_id` | No | Empty string | Stable external agent/person/system ID. |
| `metadata` | No | `{}` | Free-form object for model/run/tool metadata. |
@ -46,7 +57,7 @@ Example source metadata:
```json
{
"persona": "hermes",
"source": "hermes-research",
"source_type": "agent",
"source_name": "Hermes Agent",
"source_id": "agent-hermes-prod",
@ -65,14 +76,14 @@ Endpoint:
```http
POST /api/artifacts
Content-Type: application/json
Authorization: Bearer dev-token
Authorization: Bearer <your-personal-token>
```
Required fields:
| Field | Notes |
|---|---|
| `persona` | Required. |
| `source` | Required. |
| `kind` | One of `html_fragment`, `html_full`, `markdown`, `deck`. |
| `title` | Required. |
| `content` | Required text content. |
@ -83,17 +94,33 @@ Optional fields:
|---|---|
| `theme` | Defaults by kind. Common values: `report`, `tasks`, `deck`, `base`. |
| `tags` | Array or comma-separated string. |
| `tag_colors` | Object mapping tag name → `#RRGGBB`. Sets a colour override for those tags. |
| `source_prompt` | Prompt or instruction that led to the artifact. |
| `source_type` | Defaults to `agent`. |
| `source_name` | Defaults to `persona`. |
| `source_name` | Defaults to `source`. |
| `source_id` | Optional stable source ID. |
| `metadata` | Optional object. |
### Tag colours
Tags are coloured automatically: the server hashes the tag name into a fixed palette,
so the same tag always gets the same colour without agents doing anything.
To force a specific colour, pass `tag_colors`:
```json
{"tags": ["urgent", "billing"], "tag_colors": {"urgent": "#d1453b"}}
```
Colour overrides are **global and shared across all users** — setting a colour for
`urgent` changes it everywhere, for everyone. Values must be `#RRGGBB`; anything else
returns `400`. Overrides can also be set via `PATCH` on an existing artifact.
PowerShell example:
```powershell
$body = @{
persona = "hermes"
source = "hermes-research"
source_type = "agent"
source_name = "Hermes Agent"
source_id = "agent-hermes-local"
@ -112,7 +139,7 @@ $body = @{
Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts" `
-Method Post `
-Headers @{ Authorization = "Bearer dev-token" } `
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
-ContentType "application/json" `
-Body $body
```
@ -126,13 +153,18 @@ Response shape:
"api_url": "http://127.0.0.1:8001/api/artifacts/a1b2c3d4",
"raw_url": "http://127.0.0.1:8001/a/a1b2c3d4?raw=1",
"raw_available": true,
"persona": "hermes",
"source": "hermes-research",
"title": "Morning Brief",
"kind": "markdown",
"theme": "report",
"day": "2026-06-10",
"created_at": "2026-06-10T15:00:00+00:00",
"tags": ["brief", "daily"],
"labels": [
{"name": "hermes-research", "color": "#4f6df5"},
{"name": "brief", "color": "#e0578c"},
{"name": "daily", "color": "#2fa88f"}
],
"source_prompt": "Prepare a concise morning brief.",
"source_type": "agent",
"source_name": "Hermes Agent",
@ -140,10 +172,15 @@ Response shape:
"metadata": {
"model": "gpt-5.5",
"run_id": "brief-001"
}
},
"read_at": null
}
```
`read_at` is `null` until the artifact is opened in the feed viewer, then holds the
timestamp it was first read. Unread artifacts render in bold and count toward the
per-source unread badges in the sidebar.
## Create File Artifact
Endpoint:
@ -151,14 +188,14 @@ Endpoint:
```http
POST /api/artifacts
Content-Type: multipart/form-data
Authorization: Bearer dev-token
Authorization: Bearer <your-personal-token>
```
Required fields:
| Field | Notes |
|---|---|
| `persona` | Required. |
| `source` | Required. |
| `title` | Required. |
| `file` | Required file part. |
@ -174,8 +211,8 @@ PowerShell/curl example:
```powershell
curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" `
-H "Authorization: Bearer dev-token" `
-F "persona=orion" `
-H "Authorization: Bearer <your-personal-token>" `
-F "source=orion" `
-F "source_type=agent" `
-F "source_name=Orion Agent" `
-F "title=Architecture Diagram" `
@ -191,23 +228,28 @@ Endpoint:
GET /api/artifacts
```
Supported filters:
Requires a logged-in browser session. Returns only the current user's artifacts.
Supported filters (combine freely; all are ANDed):
| Query param | Meaning |
|---|---|
| `persona` | Exact persona match. |
| `source` | Exact match on the artifact's `source`. |
| `label` | Matches any of the artifact's `tags`. |
| `day` | Exact day, `YYYY-MM-DD`. |
| `tag` | Matches artifacts containing the tag. |
| `kind` | Exact artifact kind. |
| `q` | Case-insensitive title fragment. |
`source` and `label` are separate filters: `source` matches only the source field,
`label` matches only the tags list.
Examples:
```powershell
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts"
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?persona=hermes"
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?source=hermes-research"
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?kind=markdown&q=brief"
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?day=2026-06-10&tag=daily"
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?day=2026-06-10&label=daily"
```
## Get Artifact Metadata
@ -231,7 +273,7 @@ Endpoint:
```http
PATCH /api/artifacts/{id}
Content-Type: application/json
Authorization: Bearer dev-token
Authorization: Bearer <your-personal-token>
```
Editable fields:
@ -240,18 +282,21 @@ Editable fields:
|---|---|
| `title` | Must not be blank. |
| `tags` | Array or comma-separated string. |
| `tag_colors` | Object mapping tag name → `#RRGGBB`. Updates the global colour registry. |
| `source_prompt` | String. |
| `source_type` | `agent`, `person`, or `system`. |
| `source_name` | String. |
| `source_id` | String. |
| `metadata` | Object. Replaces existing metadata. |
You can only PATCH artifacts your own token owns; others return `404`.
Not editable yet:
| Field | Reason |
|---|---|
| `content` | Content replacement/revisions are intentionally deferred. |
| `persona` | Affects storage layout and feed identity. |
| `source` | Affects storage layout and feed identity. |
| `kind` | Affects renderer and storage behavior. |
| `day` | Affects time-ordered feed and storage layout. |
@ -271,7 +316,7 @@ $body = @{
Invoke-RestMethod `
-Uri "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" `
-Method Patch `
-Headers @{ Authorization = "Bearer dev-token" } `
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
-ContentType "application/json" `
-Body $body
```
@ -312,7 +357,7 @@ Example response:
```json
{
"artifact_kinds": ["html_fragment", "html_full", "markdown", "deck", "image", "pdf", "binary"],
"filters": ["persona", "day", "tag", "kind", "q"],
"filters": ["source", "label", "day", "kind", "q"],
"views": ["rendered", "raw"],
"raw_supported_for": ["markdown", "html_full"],
"source_types": ["agent", "person", "system"],
@ -320,12 +365,18 @@ Example response:
}
```
Agents should call this endpoint if they need to adapt to the current Daybook instance.
Agents should call this endpoint if they need to adapt to the current Daybook instance, or
simply to check the instance is reachable and their token is valid. Unlike the other read
endpoints, it accepts a bearer token as well as a browser session:
```bash
curl -H "Authorization: Bearer <your-personal-token>" http://127.0.0.1:8001/api/capabilities
```
## Current MVP Constraints
- A single shared ingest token is used: `dev-token`.
- Reader/browser authentication is not implemented.
- Each user has a personal ingest API token; there is no shared token.
- Reader/browser access requires login; feeds are private per user.
- Content modification/revisions are deferred.
- Office document conversion is not implemented.
- Manual browser upload/drag-and-drop is deferred.

View file

@ -9,12 +9,12 @@ The helper is intentionally zero-dependency and uses only Python's standard libr
## Configuration
```text
DAYBOOK_URL=http://127.0.0.1:8000
DAYBOOK_TOKEN=dev-token
DAYBOOK_PERSONA=agent
DAYBOOK_URL=http://127.0.0.1:8001
DAYBOOK_TOKEN=your-personal-api-token
DAYBOOK_SOURCE=agent
```
Override these per environment. For deployed instances, use the token from `deploy/.env`.
`DAYBOOK_TOKEN` is a personal per-user token, from Settings → Account in the app — not a token shared across users. Override these per environment.
## Commands

145
planning/mcp_research.md Normal file
View file

@ -0,0 +1,145 @@
# AgentBook — MCP Server Implementation Guide
## Goal
Expose AgentBook's existing REST API (agents posting/reading items — research, reports, notes) as **MCP tools**, so AI agents can interact with AgentBook directly over MCP instead of hand-rolling REST calls.
## Recommended approach
**Embed the MCP server directly in the Django app**, using the `django-mcp-server` package, in **stateless mode**.
Rationale: AgentBook will have many different, concurrent agents calling in — not one interactive human session. Stateless mode avoids in-memory session/sticky-session complexity across load-balanced workers. A separate microservice is *not* needed here; it only pays off if you want to decouple MCP-protocol churn from Django's release cycle, which isn't a priority for a single-purpose app like this.
---
## 1. Install
```bash
pip install django-mcp-server
```
## 2. Configure Django settings
```python
INSTALLED_APPS = [
# ...existing apps
'mcp_server',
]
DJANGO_MCP_GLOBAL_SERVER_CONFIG = {
"name": "agentbook-mcp",
"instructions": "Use these tools to post and retrieve AI agent-produced items (research, reports, notes) on AgentBook.",
"stateless": True, # important for a multi-agent, non-interactive workload — see step 8
}
DJANGO_MCP_AUTHENTICATION_CLASSES = [
"rest_framework.authentication.TokenAuthentication", # reuse existing agent API tokens
]
```
## 3. Mount the endpoint
```python
# urls.py
from django.urls import path, include
urlpatterns = [
# ...existing urls
path("", include("mcp_server.urls")),
]
```
Default endpoint: `/mcp` (no trailing slash). Configurable via `DJANGO_MCP_ENDPOINT` if you want e.g. `/mcp/`.
## 4. Define tools
Create `agentbook/mcp.py`. Two approaches — use whichever fits each endpoint:
### A. Wrap existing DRF views (fastest — reuses your validation/serializers)
```python
from mcp_server import drf_publish_create_mcp_tool, drf_publish_list_mcp_tool
from .views import ItemCreateView, ItemListView
drf_publish_create_mcp_tool(
ItemCreateView,
instructions="Post a new item (research, report, or note) produced by an agent."
)
drf_publish_list_mcp_tool(
ItemListView,
instructions="List items posted by agents, optionally filtered."
)
```
**Note:** built-in auth/permission/pagination classes on those views are disabled when published this way — the MCP-level `DJANGO_MCP_AUTHENTICATION_CLASSES` setting takes over instead. If a wrapped view relied on `self.paginator`, account for that (it will be `None`).
### B. Custom tool functions (for anything not already a clean DRF view)
```python
from mcp_server import mcp_server as mcp
from .models import Item
@mcp.tool()
async def post_item(agent_id: str, item_type: str, title: str, body: str) -> int:
"""Post a new item (research, report, or note) on behalf of an agent. Returns the item ID."""
item = await Item.objects.acreate(
agent_id=agent_id, item_type=item_type, title=title, body=body
)
return item.id
@mcp.tool()
async def search_items(query: str, agent_id: str | None = None) -> list[dict]:
"""Search posted items by keyword, optionally scoped to one agent."""
qs = Item.objects.filter(body__icontains=query)
if agent_id:
qs = qs.filter(agent_id=agent_id)
results = [item async for item in qs[:20]]
return [{"id": i.id, "title": i.title, "type": i.item_type} for i in results]
```
**Important:** use Django's **async ORM API** (`acreate`, `afirst`, `async for`, etc.) inside `@mcp.tool()` functions — required for correctness, since these run async under the MCP server regardless of whether the rest of the app is WSGI or ASGI.
## 5. Verify tool registration
```bash
python manage.py mcp_inspect
```
## 6. Test locally
```python
from mcp.client.streamable_http import streamablehttp_client
from mcp import ClientSession
import asyncio
async def main():
async with streamablehttp_client("http://localhost:8000/mcp") as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
result = await session.call_tool(
"post_item",
{"agent_id": "abc", "item_type": "report", "title": "Q3 findings", "body": "..."}
)
print(result)
asyncio.run(main())
```
Or point the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) at `http://localhost:8000/mcp`.
## 7. Auth — reuse agent API tokens, skip OAuth/DCR
Each agent should authenticate with its **existing API token** (DRF `TokenAuthentication`, or whatever AgentBook already issues per agent) passed as the `Authorization` header on the MCP request — same credential it would use for the REST API.
Don't implement the OAuth2 Dynamic Client Registration flow. That's only required for Claude.ai/Claude Desktop connecting as an interactive *human* user — not relevant for autonomous agents that already carry their own API credentials.
## 8. Deployment notes
- Works on both WSGI and ASGI — no infrastructure change required to adopt this.
- Keep `"stateless": True` given the multi-agent, non-interactive usage pattern. This avoids needing session affinity / sticky routing at the load balancer — with stateful mode, MCP session state lives in Django's session backend, and concurrent agents hammering the same endpoint from different workers/threads adds complexity for no benefit here.
- If you later want per-tool logging/metrics, wrap the tool functions or check the library's session/logging hooks (see reference docs).
## Reference
- `django-mcp-server` docs: https://github.com/gts360/django-mcp-server
- MCP Streamable HTTP transport spec: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports

View file

@ -7,11 +7,13 @@ The helper is for agents and scripts that need to publish artifacts to Daybook w
Configuration:
```text
DAYBOOK_URL=http://127.0.0.1:8000
DAYBOOK_TOKEN=dev-token
DAYBOOK_PERSONA=agent
DAYBOOK_URL=http://127.0.0.1:8001
DAYBOOK_TOKEN=your-personal-api-token
DAYBOOK_SOURCE=agent
```
`DAYBOOK_TOKEN` is a personal per-user token from Settings → Account in the app, not shared across users.
Examples:
```bash

View file

@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Post one demo artifact of every kind, tagged, dated today.
Posts through the real ingest API using the existing daybook_post.py helper,
so seeded data goes through the same normalization as real agent output.
Usage (from project root, with the dev server running):
python scripts/seed_demo_artifacts.py
"""
import subprocess
import sys
import tempfile
from pathlib import Path
HELPER = Path(__file__).resolve().parent.parent / "skills" / "daybook-posting" / "scripts" / "daybook_post.py"
PERSONA = "seed-demo"
TAGS = "seed-demo,demo"
def run_post(args):
result = subprocess.run([sys.executable, str(HELPER), *args], capture_output=True, text=True)
print(result.stdout.strip())
if result.returncode != 0:
print(result.stderr.strip(), file=sys.stderr)
return result.returncode
def post_text(kind, title, content, extra_tag):
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False, encoding="utf-8") as tmp:
tmp.write(content)
content_path = tmp.name
return run_post([
"--kind", kind,
"--title", title,
"--persona", PERSONA,
"--tags", f"{TAGS},{extra_tag}",
"--content-file", content_path,
])
def post_file(title, file_path, extra_tag):
return run_post([
"--file", str(file_path),
"--title", title,
"--persona", PERSONA,
"--tags", f"{TAGS},{extra_tag}",
])
def main():
exit_codes = []
exit_codes.append(post_text(
"markdown",
"Seed: Markdown Sample",
"# Markdown Sample\n\nThis is a **seed** artifact demonstrating markdown rendering.\n\n- item one\n- item two\n",
"markdown",
))
exit_codes.append(post_text(
"html_fragment",
"Seed: HTML Fragment Sample",
"<h2>HTML Fragment</h2><p>This is a seed HTML fragment artifact.</p>",
"html-fragment",
))
exit_codes.append(post_text(
"html_full",
"Seed: Full HTML Sample",
"<!doctype html><html><head><title>Seed Full HTML</title></head>"
"<body><h1>Full HTML Doc</h1><p>Seed artifact.</p></body></html>",
"html-full",
))
exit_codes.append(post_text(
"deck",
"Seed: Deck Sample",
"<section><h1>Slide One</h1><p>Seed deck artifact.</p></section>"
"<section><h1>Slide Two</h1><p>More content.</p></section>",
"deck",
))
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_dir = Path(tmp_dir)
png_path = tmp_dir / "seed_pixel.png"
png_path.write_bytes(bytes.fromhex(
"89504e470d0a1a0a0000000d494844520000000100000001080600000"
"01f15c4890000000a49444154789c6360000002000155e59a1a0000000049454e44ae426082"
))
exit_codes.append(post_file("Seed: Image Sample", png_path, "image"))
pdf_path = tmp_dir / "seed_doc.pdf"
pdf_path.write_bytes(
b"%PDF-1.1\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
b"3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj\n"
b"trailer<</Root 1 0 R>>\n"
)
exit_codes.append(post_file("Seed: PDF Sample", pdf_path, "pdf"))
bin_path = tmp_dir / "seed_data.bin"
bin_path.write_bytes(b"\x00\x01\x02\x03seed-binary-data\xff\xfe")
exit_codes.append(post_file("Seed: Binary Sample", bin_path, "binary"))
return 1 if any(exit_codes) else 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -27,11 +27,13 @@ The artifact URL is printed as the final stdout line.
Use environment variables:
```text
DAYBOOK_URL=http://127.0.0.1:8000
DAYBOOK_TOKEN=dev-token
DAYBOOK_PERSONA=agent
DAYBOOK_URL=http://127.0.0.1:8001
DAYBOOK_TOKEN=your-personal-api-token
DAYBOOK_SOURCE=agent
```
`DAYBOOK_TOKEN` is your personal per-user API token, from Settings → Account in the app (not a token shared by everyone). Artifacts posted with your token land only in your feed.
## Kind Cheat Sheet
| Kind | Use for | Content shape |
@ -48,7 +50,7 @@ For CSV, JSON, or code that should render today, use `--file path --as-markdown`
## Rules
- Run `--check` first if unsure Daybook is reachable.
- Use a stable `DAYBOOK_PERSONA` or pass `--persona`.
- Use a stable `DAYBOOK_SOURCE` or pass `--source` (the agent-profile, script, or person name that produced this artifact).
- Use `--tags a,b,c` for filtering.
- Use `--meta run_id=... --meta model=...` for machine-readable context.
- Never inline multiline content into the shell command; use a file or stdin.

View file

@ -20,26 +20,39 @@ from urllib.request import Request, urlopen
TEXT_KINDS = {"markdown", "html_fragment", "html_full", "deck"}
TEXT_SUFFIXES = {".md", ".markdown", ".txt", ".html", ".htm"}
DEFAULT_URL = "http://127.0.0.1:8000"
DEFAULT_TOKEN = "dev-token"
DEFAULT_PERSONA = "agent"
DEFAULT_URL = "http://127.0.0.1:8001"
DEFAULT_SOURCE = "agent"
def main():
args = parse_args()
base_url = os.environ.get("DAYBOOK_URL", DEFAULT_URL).rstrip("/")
token = os.environ.get("DAYBOOK_TOKEN", DEFAULT_TOKEN)
persona = args.persona or os.environ.get("DAYBOOK_PERSONA", DEFAULT_PERSONA)
# DAYBOOK_TOKEN is your personal per-user token (Settings -> Account in the app);
# there is no shared fallback token, so posting without it cannot work.
token = os.environ.get("DAYBOOK_TOKEN", "")
source = args.source or os.environ.get("DAYBOOK_SOURCE", DEFAULT_SOURCE)
if args.check:
response = request_json("GET", f"{base_url}/api/capabilities")
try:
response = request_json("GET", f"{base_url}/api/capabilities", token)
except (HTTPError, URLError) as exc:
print_http_error(exc)
return 1
print(json.dumps(response, indent=2))
return 0
if not token:
print(
"error: DAYBOOK_TOKEN is not set. Get your personal API token from "
f"{base_url}/settings/account/ and set DAYBOOK_TOKEN.",
file=sys.stderr,
)
return 2
try:
if args.path:
resolve_auto_path(args)
payload = build_payload(args, persona)
payload = build_payload(args, source)
validate_payload(payload, args)
if args.dry_run:
print_dry_run(base_url, token, payload, args)
@ -62,14 +75,14 @@ def parse_args():
parser.add_argument("path", nargs="?", help="Auto mode: one file path. Kind, content-type, and title are inferred. The simplest way to post.")
parser.add_argument("--kind", choices=sorted(TEXT_KINDS), help="Text artifact kind. Omit when using --file or a positional path.")
parser.add_argument("--title", help="Artifact title. Required unless --check is used.")
parser.add_argument("--persona", help="Artifact persona. Defaults to DAYBOOK_PERSONA or 'agent'.")
parser.add_argument("--source", help="Artifact source (agent-profile, script, or person name). Defaults to DAYBOOK_SOURCE or 'agent'.")
parser.add_argument("--content-file", help="Read text artifact content from this file.")
parser.add_argument("--file", help="Upload this file as image/pdf/binary, or render as markdown with --as-markdown.")
parser.add_argument("--tags", help="Comma-separated tags.")
parser.add_argument("--theme", help="Theme for markdown/html_fragment. Example: report, tasks, bootstrap.")
parser.add_argument("--meta", action="append", default=[], help="Metadata key=value. Repeatable.")
parser.add_argument("--meta-json", help="Metadata JSON object. Merged with --meta values.")
parser.add_argument("--source-name", help="Human-readable source name. Defaults to persona.")
parser.add_argument("--source-name", help="Human-readable source name. Defaults to --source.")
parser.add_argument("--source-id", default="", help="Stable source identifier.")
parser.add_argument("--source-prompt", default="", help="Prompt/instruction that produced the artifact.")
parser.add_argument("--source-type", choices=["agent", "person", "system"], default="agent")
@ -160,14 +173,14 @@ def humanize(stem):
return cleaned.title() if cleaned else stem
def build_payload(args, persona):
def build_payload(args, source):
metadata = parse_metadata(args)
common = {
"persona": persona,
"source": source,
"title": args.title,
"tags": normalize_tags(args.tags),
"source_type": args.source_type,
"source_name": args.source_name or persona,
"source_name": args.source_name or source,
"source_id": args.source_id,
"source_prompt": args.source_prompt,
"metadata": metadata,
@ -357,9 +370,11 @@ def content_type_for(path):
return mimetypes.guess_type(path.name)[0] or "application/octet-stream"
def request_json(method, url):
def request_json(method, url, token=""):
request = Request(url, method=method)
request.add_header("Accept", "application/json")
if token:
request.add_header("Authorization", f"Bearer {token}")
with urlopen(request, timeout=15) as response:
return json.loads(response.read().decode("utf-8"))

View file

@ -8,6 +8,7 @@
--text: #2f343d;
--muted: #7b8491;
--selected-day-bg: #3d4552;
--day-tile-bg: #eef0f4;
--selected-artifact-bg: #eaf3ff;
--selected-artifact-rail: #dcecff;
--control-border: #d9dee6;
@ -37,10 +38,73 @@ body {
display: grid;
flex: 0 0 auto;
gap: 0;
grid-template-columns: max-content 1fr;
grid-template-columns: max-content 1fr max-content;
padding: 0;
}
.topbar-actions {
align-items: center;
display: flex;
gap: 0.4rem;
padding: 0.32rem 0.8rem;
position: relative;
}
.icon-button {
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--muted);
cursor: pointer;
font-size: 0.85rem;
padding: 0.4rem 0.6rem;
}
.icon-button:hover {
background: var(--surface);
border-color: var(--control-border);
}
.user-menu-dropdown {
background: var(--surface);
border: 1px solid var(--control-border);
border-radius: 8px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
display: flex;
flex-direction: column;
min-width: 10rem;
position: absolute;
right: 0.8rem;
top: calc(100% + 4px);
z-index: 20;
}
.user-menu-dropdown[hidden] {
display: none;
}
.user-menu-dropdown a,
.user-menu-dropdown button {
background: transparent;
border: 0;
color: var(--text);
cursor: pointer;
font-size: 0.85rem;
padding: 0.5rem 0.8rem;
text-align: left;
text-decoration: none;
width: 100%;
}
.user-menu-dropdown a:hover,
.user-menu-dropdown button:hover {
background: var(--surface-alt);
}
.user-menu-dropdown form {
margin: 0;
}
.brandline {
align-items: center;
display: flex;
@ -48,6 +112,15 @@ body {
padding: 0.38rem 0.8rem;
}
.brand-link {
color: inherit;
text-decoration: none;
}
.brand-link:hover {
opacity: 0.75;
}
.eyebrow {
color: var(--muted);
font-size: 0.68rem;
@ -142,14 +215,14 @@ h1 {
overflow: hidden;
}
.day-sidebar,
.source-sidebar,
.artifact-sidebar,
.preview-pane {
min-width: 0;
padding: 0;
}
.day-sidebar,
.source-sidebar,
.artifact-sidebar {
min-height: 0;
overflow-x: hidden;
@ -157,7 +230,7 @@ h1 {
scrollbar-width: none;
}
.day-sidebar {
.source-sidebar {
display: flex;
flex-direction: column;
overflow-y: hidden;
@ -170,7 +243,7 @@ h1 {
padding: 0;
}
.day-list,
.source-list,
.artifact-list {
min-height: 0;
overflow-x: hidden;
@ -179,34 +252,34 @@ h1 {
scrollbar-width: none;
}
.day-sidebar:hover .day-list,
.source-sidebar:hover .source-list,
.artifact-sidebar:hover .artifact-list {
scrollbar-color: rgba(61, 69, 82, 0.35) transparent;
scrollbar-width: thin;
}
.day-list::-webkit-scrollbar,
.source-list::-webkit-scrollbar,
.artifact-list::-webkit-scrollbar {
width: 0;
}
.day-sidebar:hover .day-list::-webkit-scrollbar,
.source-sidebar:hover .source-list::-webkit-scrollbar,
.artifact-sidebar:hover .artifact-list::-webkit-scrollbar {
width: 6px;
}
.day-list::-webkit-scrollbar-thumb,
.source-list::-webkit-scrollbar-thumb,
.artifact-list::-webkit-scrollbar-thumb {
background: rgba(61, 69, 82, 0.35);
border-radius: 999px;
}
.day-list::-webkit-scrollbar-track,
.source-list::-webkit-scrollbar-track,
.artifact-list::-webkit-scrollbar-track {
background: transparent;
}
.day-sidebar {
.source-sidebar {
background: var(--surface-alt);
}
@ -250,7 +323,7 @@ h1 {
user-select: none;
}
.day-sidebar h2,
.source-sidebar h2,
.pane-heading h2 {
color: var(--text);
font-size: 0.68rem;
@ -259,64 +332,97 @@ h1 {
text-transform: uppercase;
}
.day-list,
.artifact-list {
.source-list {
display: grid;
}
.day-list {
.source-list {
align-content: start;
flex: 1 1 auto;
gap: 0.35rem;
padding: 0.55rem;
}
.day-link,
.source-link,
.artifact-row {
color: inherit;
text-decoration: none;
}
.day-link {
.source-link {
align-items: center;
background: var(--day-tile-bg);
border-radius: 6px;
display: grid;
gap: 0.1rem;
padding: 0.48rem;
display: flex;
gap: 0.4rem;
justify-content: space-between;
padding: 0.45rem 0.55rem;
}
.day-link:hover,
.day-link.selected {
.source-link:hover {
background: var(--surface);
box-shadow: none;
}
.day-link:focus-visible {
.source-link:focus-visible {
outline: 2px solid #8ab4f8;
outline-offset: 2px;
}
.day-link.selected {
.source-link.selected {
background: var(--selected-day-bg);
color: #ffffff;
}
.day-link.selected span,
.day-link.selected strong {
.source-link.selected .source-name,
.source-link.selected .source-count {
color: #ffffff;
}
.day-link.selected .today-badge {
background: #d8f1df;
color: #28583a;
.source-name {
font-size: 0.8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-count {
color: var(--muted);
flex: 0 0 auto;
font-size: 0.7rem;
}
.day-link span,
.pane-heading span,
.artifact-time {
color: var(--muted);
font-size: 0.7rem;
}
.day-separator {
align-items: center;
background: var(--surface-alt);
border-bottom: 1px solid var(--grid-line);
color: var(--muted);
display: flex;
font-size: 0.7rem;
font-weight: 700;
justify-content: space-between;
letter-spacing: 0.04em;
padding: 0.32rem 0.55rem;
position: sticky;
text-transform: uppercase;
top: 0;
z-index: 5;
}
.day-separator-count {
font-weight: 400;
}
.feed-more {
height: 1px;
}
.today-badge {
background: #d8f1df;
border-radius: 999px;
@ -329,10 +435,6 @@ h1 {
padding: 0.08rem 0.38rem;
}
.day-link strong {
color: var(--text);
font-size: 0.82rem;
}
.pane-bar {
align-items: baseline;
@ -353,7 +455,7 @@ h1 {
border-bottom: 1px solid var(--grid-line);
}
.day-sidebar .pane-bar,
.source-sidebar .pane-bar,
.artifact-sidebar .pane-bar {
align-items: center;
justify-content: center;
@ -365,30 +467,6 @@ h1 {
gap: 0.04rem;
}
.new-artifacts-banner {
background: #eaf3ff;
border: 0;
border-radius: 999px;
color: #315a8a;
cursor: pointer;
display: block;
font: inherit;
font-size: 0.76rem;
font-weight: 700;
margin: 0 0.55rem 0.5rem;
padding: 0.48rem 0.7rem;
text-align: center;
width: calc(100% - 1.1rem);
}
.new-artifacts-banner[hidden] {
display: none;
}
.new-artifacts-banner:hover {
background: #dcecff;
}
.artifact-list {
align-content: start;
flex: 1 1 auto;
@ -402,7 +480,7 @@ h1 {
border-radius: 0;
display: grid;
gap: 0;
grid-template-columns: 3.15rem minmax(0, 1fr) 2.6rem;
grid-template-columns: 3.15rem minmax(0, 1fr) 7rem;
min-height: 2.7rem;
overflow: hidden;
padding: 0;
@ -427,55 +505,30 @@ h1 {
background: var(--selected-artifact-rail);
}
.artifact-row.selected .artifact-row-main span,
.artifact-row.selected .artifact-title,
.artifact-row.selected .artifact-time {
color: var(--text);
}
.artifact-row.selected .kind-icon {
background: #dbeaff;
color: #315a8a;
}
.kind-icon {
align-items: center;
background: #f1f3f6;
color: #5f6875;
display: inline-flex;
font-size: 0.56rem;
font-weight: 800;
justify-content: center;
letter-spacing: 0.04em;
line-height: 1;
padding: 0 0.42rem;
white-space: nowrap;
}
.kind-image {
color: #265336;
}
.kind-pdf {
color: #78352f;
}
.kind-markdown {
color: #443265;
}
.kind-deck {
color: #6e4a12;
}
.artifact-row-main {
align-items: center;
border-right: 1px solid var(--grid-line);
display: flex;
flex-wrap: wrap;
gap: 0.32rem;
min-width: 0;
padding: 0.42rem 0.55rem;
}
.artifact-row-main span {
.artifact-row.unread .artifact-title {
font-weight: 700;
}
.artifact-row.unread .artifact-source {
color: var(--text);
}
.artifact-row-main > .artifact-title {
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
color: var(--text);
@ -484,6 +537,39 @@ h1 {
overflow: hidden;
}
.artifact-kind-text {
color: var(--muted);
font-size: 0.68rem;
white-space: nowrap;
}
.artifact-source {
align-items: center;
color: var(--muted);
display: flex;
font-size: 0.72rem;
overflow: hidden;
padding: 0.42rem 0.55rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag-chips {
display: flex;
flex-wrap: wrap;
gap: 0.24rem;
}
.tag-chip {
border-radius: 999px;
color: #ffffff;
font-size: 0.66rem;
font-weight: 600;
line-height: 1;
padding: 0.18rem 0.44rem;
white-space: nowrap;
}
.artifact-time {
align-items: center;
background: #f3f5f7;
@ -646,10 +732,120 @@ kbd {
display: none;
}
.day-sidebar,
.source-sidebar,
.artifact-sidebar {
border-bottom: 1px solid #d9c9ad;
border-right: 0;
max-height: 32vh;
}
}
.login-page {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
overflow: auto;
}
.login-card {
background: var(--surface);
border: 1px solid var(--control-border);
border-radius: 12px;
display: flex;
flex-direction: column;
gap: 0.8rem;
padding: 2rem;
width: 20rem;
}
.login-card label {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.login-card label span {
color: var(--muted);
font-size: 0.78rem;
}
.login-card input[type="text"],
.login-card input[type="password"] {
border: 1px solid var(--control-border);
border-radius: 6px;
padding: 0.5rem 0.6rem;
}
.login-remember {
align-items: center;
flex-direction: row !important;
gap: 0.5rem !important;
}
.login-error {
color: #c14f4f;
font-size: 0.82rem;
}
.settings-layout {
display: grid;
flex: 1 1 auto;
grid-template-columns: 14rem 1fr;
min-height: 0;
overflow: auto;
}
.settings-nav {
background: var(--surface-alt);
border-right: 1px solid var(--grid-line);
display: flex;
flex-direction: column;
padding: 1rem 0.6rem;
}
.settings-nav a {
border-radius: 6px;
color: var(--text);
padding: 0.5rem 0.7rem;
text-decoration: none;
}
.settings-nav a:hover {
background: var(--surface);
}
.settings-nav a.selected {
background: var(--day-tile-bg);
font-weight: 600;
}
.settings-nav-back {
border-bottom: 1px solid var(--grid-line);
color: var(--muted) !important;
font-size: 0.82rem;
margin-bottom: 0.5rem;
padding-bottom: 0.7rem !important;
}
.settings-panel {
max-width: 42rem;
padding: 1.6rem 2rem;
}
.settings-messages {
background: #eaf7ed;
border-radius: 8px;
color: #28583a;
list-style: none;
margin: 0 0 1.2rem;
padding: 0.6rem 0.9rem;
}
.token-value {
background: var(--surface-alt);
border-radius: 6px;
display: inline-block;
padding: 0.4rem 0.6rem;
word-break: break-all;
}

View file

@ -0,0 +1,34 @@
{% for group in day_groups %}
<div class="day-group">
<div class="day-separator">
<span>{{ group.day|date:"D, M j Y" }}{% if group.day == today %} <em class="today-badge">Today</em>{% endif %}</span>
<span class="day-separator-count">{{ group.artifacts|length }}</span>
</div>
{% for artifact in group.artifacts %}
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %} {% if artifact.is_unread %}unread{% endif %}"
href="?{% if selected_source %}source={{ selected_source|urlencode }}&amp;{% endif %}{% if selected_label %}label={{ selected_label|urlencode }}&amp;{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&amp;{% endif %}{% if title_query %}q={{ title_query|urlencode }}&amp;{% endif %}artifact={{ artifact.id }}&amp;view={{ selected_view }}"
data-artifact-id="{{ artifact.id }}"
data-title="{{ artifact.title }}"
data-kind="{{ artifact.kind }}"
data-rendered-url="{% url 'artifact' artifact.id %}"
data-raw-url="{% url 'artifact' artifact.id %}?raw=1">
<time class="artifact-time" datetime="{{ artifact.created_at|date:'c' }}">{{ artifact.created_at|date:"H:i" }}</time>
<span class="artifact-row-main">
<span class="artifact-title">{{ artifact.title }}</span>
<span class="artifact-kind-text">{% if artifact.kind == "image" %}image{% elif artifact.kind == "pdf" %}pdf{% elif artifact.kind == "markdown" %}markdown{% elif artifact.kind == "deck" %}deck{% elif artifact.kind == "binary" %}binary{% else %}html{% endif %}</span>
{% if artifact.label_chips %}
<span class="tag-chips">
{% for label in artifact.label_chips %}
<span class="tag-chip" style="background:{{ label.color }}">{{ label.name }}</span>
{% endfor %}
</span>
{% endif %}
</span>
<span class="artifact-source">{{ artifact.source }}</span>
</a>
{% endfor %}
</div>
{% endfor %}
{% if has_more %}
<div class="feed-more" data-feed-more data-next-before="{{ next_before }}"></div>
{% endif %}

View file

@ -0,0 +1,28 @@
<form method="get" class="filter">
<input type="hidden" name="view" value="{{ selected_view }}">
{% if selected_source %}<input type="hidden" name="source" value="{{ selected_source }}">{% endif %}
{% if selected_artifact %}<input type="hidden" name="artifact" value="{{ selected_artifact.id }}">{% endif %}
<label>
<span>Label</span>
<select name="label" onchange="this.form.submit()">
<option value="">All labels</option>
{% for label in all_labels %}
<option value="{{ label.name }}" {% if label.name == selected_label %}selected{% endif %}>{{ label.name }}</option>
{% endfor %}
</select>
</label>
<label>
<span>Type</span>
<select name="kind" onchange="this.form.submit()">
<option value="">All types</option>
{% for value, label in kind_choices %}
<option value="{{ value }}" {% if value == selected_kind %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>
<span>Name</span>
<input type="search" name="q" value="{{ title_query }}" placeholder="Title contains" onsearch="this.form.submit()">
</label>
<button type="submit">Filter</button>
</form>

View file

@ -0,0 +1,37 @@
<header class="topbar">
<div class="brandline">
<a class="brand-link" href="{% url 'feed' %}"><h1>AgentFeed</h1></a>
</div>
<div class="toolbar">
{% if topbar_toolbar %}{% include topbar_toolbar %}{% endif %}
</div>
<div class="topbar-actions">
<button class="icon-button" type="button" data-user-menu-toggle aria-haspopup="true" aria-expanded="false" title="{{ request.user.username }}">
&#9881; {{ request.user.username }}
</button>
<div class="user-menu-dropdown" hidden data-user-menu>
<a href="{% url 'settings-account' %}">Settings</a>
<a href="{% url 'settings-demo-data' %}">Demo Data</a>
<form method="post" action="{% url 'logout' %}">{% csrf_token %}<button type="submit">Log out</button></form>
</div>
</div>
</header>
<script>
(() => {
const toggle = document.querySelector("[data-user-menu-toggle]");
const menu = document.querySelector("[data-user-menu]");
if (!toggle || !menu) return;
toggle.addEventListener("click", (event) => {
event.stopPropagation();
const isHidden = menu.hidden;
menu.hidden = !isHidden;
toggle.setAttribute("aria-expanded", String(isHidden));
});
document.addEventListener("click", (event) => {
if (!menu.hidden && !menu.contains(event.target) && event.target !== toggle) {
menu.hidden = true;
toggle.setAttribute("aria-expanded", "false");
}
});
})();
</script>

View file

@ -7,94 +7,51 @@
<link rel="stylesheet" href="/static/daybook.css">
</head>
<body>
<header class="topbar">
<div class="brandline">
<h1>AgentFeed</h1>
</div>
<div class="toolbar">
<form method="get" class="filter">
<input type="hidden" name="day" value="{{ selected_day|date:'Y-m-d' }}">
<input type="hidden" name="view" value="{{ selected_view }}">
{% if selected_artifact %}<input type="hidden" name="artifact" value="{{ selected_artifact.id }}">{% endif %}
<label>
<span>Persona</span>
<select name="persona" onchange="this.form.submit()">
<option value="">All personas</option>
{% for persona in personas %}
<option value="{{ persona }}" {% if persona == selected_persona %}selected{% endif %}>{{ persona }}</option>
{% endfor %}
</select>
</label>
<label>
<span>Type</span>
<select name="kind" onchange="this.form.submit()">
<option value="">All types</option>
{% for value, label in kind_choices %}
<option value="{{ value }}" {% if value == selected_kind %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</label>
<label>
<span>Name</span>
<input type="search" name="q" value="{{ title_query }}" placeholder="Title contains" onsearch="this.form.submit()">
</label>
<button type="submit">Filter</button>
</form>
</div>
</header>
{% include "artifacts/_topbar.html" with topbar_toolbar="artifacts/_feed_toolbar.html" %}
<main class="feed"
data-selected-day="{{ selected_day|date:'Y-m-d' }}"
data-selected-persona="{{ selected_persona }}"
data-selected-source="{{ selected_source }}"
data-selected-label="{{ selected_label }}"
data-selected-kind="{{ selected_kind }}"
data-title-query="{{ title_query }}">
<aside class="day-sidebar" aria-label="Days">
data-title-query="{{ title_query }}"
data-selected-view="{{ selected_view }}"
data-window-start="{{ window_start|date:'Y-m-d' }}"
data-older-count="{{ older_count }}">
<aside class="source-sidebar" aria-label="Sources">
<div class="pane-bar">
<h2>Days</h2>
<h2>Sources</h2>
</div>
<nav class="day-list">
{% for day in days %}
<a class="day-link {% if day == selected_day %}selected{% endif %}" href="?day={{ day|date:'Y-m-d' }}{% if selected_persona %}&amp;persona={{ selected_persona|urlencode }}{% endif %}{% if selected_kind %}&amp;kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&amp;q={{ title_query|urlencode }}{% endif %}&amp;view={{ selected_view }}&amp;preview=none">
<span>{{ day|date:"D" }}{% if day == today %}<em class="today-badge">Today</em>{% endif %}</span>
<strong>{{ day|date:"Y-m-d" }}</strong>
<nav class="source-list">
<a class="source-link {% if not selected_source %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&amp;{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&amp;{% endif %}{% if title_query %}q={{ title_query|urlencode }}&amp;{% endif %}view={{ selected_view }}&amp;preview=none">
<span class="source-name">All sources</span>
{% if total_count %}<span class="source-count">{{ total_count }}</span>{% endif %}
</a>
{% for entry in source_counts %}
<a class="source-link {% if entry.source == selected_source %}selected{% endif %}" href="?source={{ entry.source|urlencode }}{% if selected_label %}&amp;label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&amp;kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&amp;q={{ title_query|urlencode }}{% endif %}&amp;view={{ selected_view }}&amp;preview=none">
<span class="source-name">{{ entry.source }}</span>
{% if entry.count %}<span class="source-count">{{ entry.count }}</span>{% endif %}
</a>
{% endfor %}
</nav>
</aside>
<div class="splitter" data-splitter="days" role="separator" aria-label="Resize days sidebar" aria-orientation="vertical"></div>
<div class="splitter" data-splitter="days" role="separator" aria-label="Resize sources sidebar" aria-orientation="vertical"></div>
<aside class="artifact-sidebar" aria-label="Artifacts for selected day">
<aside class="artifact-sidebar" aria-label="Artifact feed">
<div class="pane-bar pane-heading">
<h2>{{ selected_day|date:"M j Y" }}</h2>
<h2>{% if selected_source %}{{ selected_source }}{% else %}All sources{% endif %}</h2>
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
</div>
<button class="new-artifacts-banner" type="button" hidden data-new-artifacts-banner>
<strong data-new-artifacts-count>0</strong> new artifact<span data-new-artifacts-plural>s</span> available
</button>
<div class="artifact-list">
{% for artifact in artifacts %}
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %}"
href="?day={{ selected_day|date:'Y-m-d' }}{% if selected_persona %}&amp;persona={{ selected_persona|urlencode }}{% endif %}{% if selected_kind %}&amp;kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&amp;q={{ title_query|urlencode }}{% endif %}&amp;artifact={{ artifact.id }}&amp;view={{ selected_view }}"
data-artifact-id="{{ artifact.id }}"
data-title="{{ artifact.title }}"
data-kind="{{ artifact.kind }}"
data-rendered-url="{% url 'artifact' artifact.id %}"
data-raw-url="{% url 'artifact' artifact.id %}?raw=1">
<time class="artifact-time" datetime="{{ artifact.created_at|date:'c' }}">{{ artifact.created_at|date:"H:i" }}</time>
<span class="artifact-row-main">
<span>{{ artifact.title }}</span>
</span>
<span class="kind-icon kind-{{ artifact.kind }}" aria-hidden="true">{% if artifact.kind == "image" %}IMG{% elif artifact.kind == "pdf" %}PDF{% elif artifact.kind == "markdown" %}MD{% elif artifact.kind == "deck" %}DECK{% elif artifact.kind == "binary" %}BIN{% else %}HTML{% endif %}</span>
</a>
{% empty %}
<div class="artifact-list" data-artifact-list>
{% if day_groups %}
{% include "artifacts/_day_groups.html" with has_more=older_count next_before=window_start|date:"Y-m-d" %}
{% else %}
<div class="empty compact">
<h3>No artifacts</h3>
<p>No artifacts exist for this day yet.</p>
<p>Nothing in the last {{ 7 }} days for these filters.</p>
</div>
{% endfor %}
{% endif %}
</div>
</aside>
@ -195,12 +152,11 @@
const previewPane = document.querySelector(".preview-pane");
const previewBody = document.querySelector(".preview-body");
const rows = Array.from(document.querySelectorAll(".artifact-row"));
const dayLinks = Array.from(document.querySelectorAll(".day-link"));
const initialArtifactIds = new Set(rows.map((row) => row.dataset.artifactId));
const newArtifactsBanner = document.querySelector("[data-new-artifacts-banner]");
const newArtifactsCount = document.querySelector("[data-new-artifacts-count]");
const newArtifactsPlural = document.querySelector("[data-new-artifacts-plural]");
const artifactList = document.querySelector("[data-artifact-list]");
// Rows grow as older pages load, so always read them live rather than caching an array.
const allRows = () => Array.from(document.querySelectorAll(".artifact-row"));
const sourceLinks = Array.from(document.querySelectorAll(".source-link"));
const csrfToken = document.querySelector("[name=csrfmiddlewaretoken]")?.value || "";
const escapeHtml = (value) => value.replace(/[&<>"]/g, (character) => ({
"&": "&amp;",
@ -222,7 +178,7 @@
}, 1200);
};
const selectedRow = () => document.querySelector(".artifact-row.selected");
let navigationMode = selectedRow() ? "artifacts" : "days";
let navigationMode = selectedRow() ? "artifacts" : "sources";
const urlFor = (row, view) => view === "raw" ? row.dataset.rawUrl : row.dataset.renderedUrl;
const pageUrlFor = (row, view) => {
const url = new URL(row.href, window.location.origin);
@ -286,9 +242,39 @@
}
};
const markRead = async (row) => {
if (!row.classList.contains("unread")) return;
row.classList.remove("unread");
decrementSourceCount(row.querySelector(".artifact-source")?.textContent.trim());
try {
await fetch(`/a/${row.dataset.artifactId}/read`, {
method: "POST",
headers: {"X-CSRFToken": csrfToken},
});
} catch {
// Non-critical: the row stays visually read until the next page load.
}
};
const decrementSourceCount = (sourceName) => {
document.querySelectorAll(".source-link").forEach((link) => {
const name = link.querySelector(".source-name")?.textContent.trim();
if (name !== sourceName && name !== "All sources") return;
const badge = link.querySelector(".source-count");
if (!badge) return;
const next = Number(badge.textContent) - 1;
if (next > 0) {
badge.textContent = String(next);
} else {
badge.remove();
}
});
};
const renderPreview = (row, pushState = true) => {
navigationMode = "artifacts";
rows.forEach((candidate) => candidate.classList.toggle("selected", candidate === row));
markRead(row);
allRows().forEach((candidate) => candidate.classList.toggle("selected", candidate === row));
const source = urlFor(row, selectedView);
const title = escapeHtml(row.dataset.title);
const kind = row.dataset.kind;
@ -306,26 +292,28 @@
};
const clearPreview = () => {
navigationMode = "days";
navigationMode = "sources";
if (document.activeElement && document.activeElement.classList.contains("artifact-row")) {
document.activeElement.blur();
}
rows.forEach((candidate) => candidate.classList.remove("selected"));
allRows().forEach((candidate) => candidate.classList.remove("selected"));
disableActions();
previewBody.innerHTML = `<div class="empty preview-empty"><h2>No artifact loaded</h2><p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p></div>`;
window.history.pushState({}, "", noPreviewUrl());
const currentDay = document.querySelector(".day-link.selected");
if (currentDay) currentDay.focus({preventScroll: true});
const currentSource = document.querySelector(".source-link.selected");
if (currentSource) currentSource.focus({preventScroll: true});
};
rows.forEach((row) => {
row.addEventListener("click", (event) => {
event.preventDefault();
renderPreview(row);
});
// Delegated so rows appended by infinite scroll work without rebinding.
artifactList.addEventListener("click", (event) => {
const row = event.target.closest(".artifact-row");
if (!row) return;
event.preventDefault();
renderPreview(row);
});
const moveSelection = (direction) => {
const rows = allRows();
if (!rows.length) return;
navigationMode = "artifacts";
const current = selectedRow();
@ -337,14 +325,14 @@
nextRow.scrollIntoView({block: "nearest"});
};
const moveDay = (direction) => {
if (!dayLinks.length) return;
navigationMode = "days";
const current = document.querySelector(".day-link.selected");
const currentIndex = current ? dayLinks.indexOf(current) : 0;
const nextIndex = Math.max(0, Math.min(dayLinks.length - 1, currentIndex + direction));
const nextDay = dayLinks[nextIndex];
if (nextDay && nextDay !== current) window.location.href = nextDay.href;
const moveSource = (direction) => {
if (!sourceLinks.length) return;
navigationMode = "sources";
const current = document.querySelector(".source-link.selected");
const currentIndex = current ? sourceLinks.indexOf(current) : 0;
const nextIndex = Math.max(0, Math.min(sourceLinks.length - 1, currentIndex + direction));
const nextSource = sourceLinks[nextIndex];
if (nextSource && nextSource !== current) window.location.href = nextSource.href;
};
document.addEventListener("keydown", (event) => {
@ -353,23 +341,23 @@
if (event.altKey && event.key === "ArrowDown") {
event.preventDefault();
moveDay(1);
moveSource(1);
} else if (event.altKey && event.key === "ArrowUp") {
event.preventDefault();
moveDay(-1);
moveSource(-1);
} else if (event.altKey) {
return;
} else if (event.key === "ArrowDown") {
event.preventDefault();
if (navigationMode === "days") {
moveDay(1);
if (navigationMode === "sources") {
moveSource(1);
} else {
moveSelection(1);
}
} else if (event.key === "ArrowUp") {
event.preventDefault();
if (navigationMode === "days") {
moveDay(-1);
if (navigationMode === "sources") {
moveSource(-1);
} else {
moveSelection(-1);
}
@ -384,6 +372,7 @@
moveSelection(-5);
}
} else if (event.key === "ArrowRight") {
const rows = allRows();
if (!selectedRow() && rows.length) {
event.preventDefault();
renderPreview(rows[0]);
@ -397,46 +386,59 @@
event.preventDefault();
clearPreview();
} else {
navigationMode = "days";
const currentDay = document.querySelector(".day-link.selected");
if (currentDay) currentDay.focus({preventScroll: true});
navigationMode = "sources";
const currentSource = document.querySelector(".source-link.selected");
if (currentSource) currentSource.focus({preventScroll: true});
}
}
});
const artifactListUrl = () => {
const url = new URL("/api/artifacts", window.location.origin);
const params = url.searchParams;
if (feed.dataset.selectedDay) params.set("day", feed.dataset.selectedDay);
if (feed.dataset.selectedPersona) params.set("persona", feed.dataset.selectedPersona);
if (feed.dataset.selectedKind) params.set("kind", feed.dataset.selectedKind);
if (feed.dataset.titleQuery) params.set("q", feed.dataset.titleQuery);
return url;
};
const showNewArtifactsBanner = (count) => {
if (!newArtifactsBanner || count < 1) return;
newArtifactsCount.textContent = String(count);
newArtifactsPlural.hidden = count === 1;
newArtifactsBanner.hidden = false;
};
const checkForNewArtifacts = async () => {
// Infinite scroll: when the sentinel at the end of the list scrolls into
// view, fetch the next page of older day-groups and append them.
let loadingOlder = false;
const loadOlder = async (sentinel) => {
if (loadingOlder) return;
loadingOlder = true;
sentinel.removeAttribute("data-feed-more");
try {
const response = await fetch(artifactListUrl(), {headers: {Accept: "application/json"}});
const url = new URL("{% url 'feed-older' %}", window.location.origin);
const params = url.searchParams;
params.set("before", sentinel.dataset.nextBefore);
params.set("view", feed.dataset.selectedView || "rendered");
if (feed.dataset.selectedSource) params.set("source", feed.dataset.selectedSource);
if (feed.dataset.selectedLabel) params.set("label", feed.dataset.selectedLabel);
if (feed.dataset.selectedKind) params.set("kind", feed.dataset.selectedKind);
if (feed.dataset.titleQuery) params.set("q", feed.dataset.titleQuery);
const response = await fetch(url, {headers: {"X-Requested-With": "fetch"}});
if (!response.ok) return;
const artifacts = await response.json();
const unseenCount = artifacts.filter((artifact) => !initialArtifactIds.has(artifact.id)).length;
showNewArtifactsBanner(unseenCount);
const markup = (await response.text()).trim();
sentinel.remove();
if (markup) {
artifactList.insertAdjacentHTML("beforeend", markup);
observeSentinel();
}
} catch {
// Polling is opportunistic; leave the current feed untouched on errors.
// Leave the list as-is; the user can scroll again to retry.
} finally {
loadingOlder = false;
}
};
if (newArtifactsBanner) {
newArtifactsBanner.addEventListener("click", () => window.location.reload());
window.setInterval(checkForNewArtifacts, 5000);
}
const olderObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
olderObserver.unobserve(entry.target);
loadOlder(entry.target);
}
});
}, {root: artifactList, rootMargin: "200px"});
const observeSentinel = () => {
const sentinel = artifactList.querySelector("[data-feed-more]");
if (sentinel) olderObserver.observe(sentinel);
};
observeSentinel();
previewPane.addEventListener("click", (event) => {
const copyButton = event.target.closest("[data-copy-url]");

View file

@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Log in — AgentFeed</title>
<link rel="stylesheet" href="/static/daybook.css">
</head>
<body>
<main class="login-page">
<form method="post" class="login-card">
{% csrf_token %}
<h1>AgentFeed</h1>
{% if form.non_field_errors %}
<p class="login-error">{{ form.non_field_errors|join:" " }}</p>
{% endif %}
<label>
<span>Username</span>
{{ form.username }}
</label>
<label>
<span>Password</span>
{{ form.password }}
</label>
<label class="login-remember">
{{ form.remember_me }}
<span>Remember me</span>
</label>
<button type="submit">Log in</button>
<input type="hidden" name="next" value="{{ next }}">
</form>
</main>
</body>
</html>

View file

@ -0,0 +1,15 @@
{% extends "artifacts/settings_base.html" %}
{% block settings_content %}
<h2>Account</h2>
<p><strong>Username:</strong> {{ request.user.username }}</p>
<h3>API Token</h3>
<p>Use this token as <code>Authorization: Bearer &lt;token&gt;</code> when posting artifacts.</p>
<p><code class="token-value">{{ token.key }}</code></p>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="regenerate">
<button type="submit">Regenerate Token</button>
</form>
{% endblock %}

View file

@ -0,0 +1,28 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Settings — AgentFeed</title>
<link rel="stylesheet" href="/static/daybook.css">
</head>
<body>
{% include "artifacts/_topbar.html" %}
<main class="settings-layout">
<nav class="settings-nav">
<a class="settings-nav-back" href="{% url 'feed' %}">&larr; Back to Feed</a>
<a class="{% if active_section == 'account' %}selected{% endif %}" href="{% url 'settings-account' %}">Account</a>
<a class="{% if active_section == 'demo-data' %}selected{% endif %}" href="{% url 'settings-demo-data' %}">Demo Data</a>
</nav>
<section class="settings-panel">
{% if messages %}
<ul class="settings-messages">
{% for message in messages %}<li>{{ message }}</li>{% endfor %}
</ul>
{% endif %}
{% block settings_content %}{% endblock %}
</section>
</main>
</body>
</html>

View file

@ -0,0 +1,10 @@
{% extends "artifacts/settings_base.html" %}
{% block settings_content %}
<h2>Demo Data</h2>
<p>Generate one sample artifact of every supported kind (markdown, HTML fragment, full HTML, deck, image, PDF, binary), tagged "demo" and dated today.</p>
<form method="post">
{% csrf_token %}
<button type="submit">Generate Demo Data</button>
</form>
{% endblock %}