From 37cd96fa7aeca55edbd765504d1620f2ba312556 Mon Sep 17 00:00:00 2001 From: ritte Date: Fri, 24 Jul 2026 09:06:46 +0200 Subject: [PATCH] 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 --- .claude/commands/seed-demo.md | 13 + CLAUDE.md | 20 +- artifacts/admin.py | 6 +- artifacts/demo_data.py | 87 ++++ artifacts/forms.py | 6 + artifacts/migrations/0003_tagcolor.py | 20 + .../0004_rename_persona_to_source.py | 16 + .../migrations/0005_add_owner_nullable.py | 24 ++ artifacts/migrations/0006_backfill_owner.py | 27 ++ artifacts/migrations/0007_owner_required.py | 23 + artifacts/migrations/0008_artifact_read_at.py | 18 + artifacts/models.py | 19 +- artifacts/urls.py | 5 + artifacts/views.py | 377 ++++++++++++++--- daybook_project/settings.py | 5 +- daybook_project/urls.py | 5 + docs/agent_quickstart.md | 34 +- docs/api.md | 111 +++-- docs/llm_posting_helper.md | 8 +- planning/mcp_research.md | 145 +++++++ scripts/README.md | 8 +- scripts/seed_demo_artifacts.py | 111 +++++ skills/daybook-posting/SKILL.md | 10 +- .../daybook-posting/scripts/daybook_post.py | 41 +- static/daybook.css | 392 +++++++++++++----- templates/artifacts/_day_groups.html | 34 ++ templates/artifacts/_feed_toolbar.html | 28 ++ templates/artifacts/_topbar.html | 37 ++ templates/artifacts/feed.html | 264 ++++++------ templates/artifacts/login.html | 34 ++ templates/artifacts/settings_account.html | 15 + templates/artifacts/settings_base.html | 28 ++ templates/artifacts/settings_demo_data.html | 10 + 33 files changed, 1614 insertions(+), 367 deletions(-) create mode 100644 .claude/commands/seed-demo.md create mode 100644 artifacts/demo_data.py create mode 100644 artifacts/forms.py create mode 100644 artifacts/migrations/0003_tagcolor.py create mode 100644 artifacts/migrations/0004_rename_persona_to_source.py create mode 100644 artifacts/migrations/0005_add_owner_nullable.py create mode 100644 artifacts/migrations/0006_backfill_owner.py create mode 100644 artifacts/migrations/0007_owner_required.py create mode 100644 artifacts/migrations/0008_artifact_read_at.py create mode 100644 planning/mcp_research.md create mode 100644 scripts/seed_demo_artifacts.py create mode 100644 templates/artifacts/_day_groups.html create mode 100644 templates/artifacts/_feed_toolbar.html create mode 100644 templates/artifacts/_topbar.html create mode 100644 templates/artifacts/login.html create mode 100644 templates/artifacts/settings_account.html create mode 100644 templates/artifacts/settings_base.html create mode 100644 templates/artifacts/settings_demo_data.html diff --git a/.claude/commands/seed-demo.md b/.claude/commands/seed-demo.md new file mode 100644 index 0000000..ccca3ce --- /dev/null +++ b/.claude/commands/seed-demo.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index a8a2c83..e5041c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `. Get one by creating a superuser (`python manage.py createsuperuser`), logging in, and visiting Settings → Account. ## Claude Code / OpenCode diff --git a/artifacts/admin.py b/artifacts/admin.py index 8a78343..c7366e1 100644 --- a/artifacts/admin.py +++ b/artifacts/admin.py @@ -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") diff --git a/artifacts/demo_data.py b/artifacts/demo_data.py new file mode 100644 index 0000000..2ac4f24 --- /dev/null +++ b/artifacts/demo_data.py @@ -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", + "

HTML Fragment

This is a seed HTML fragment artifact.

", + "html-fragment", + ), + ( + "html_full", + "Seed: Full HTML Sample", + "Seed Full HTML" + "

Full HTML Doc

Seed artifact.

", + "html-full", + ), + ( + "deck", + "Seed: Deck Sample", + "

Slide One

Seed deck artifact.

" + "

Slide Two

More content.

", + "deck", + ), +] + +SEED_PNG_BYTES = bytes.fromhex( + "89504e470d0a1a0a0000000d494844520000000100000001080600000" + "01f15c4890000000a49444154789c6360000002000155e59a1a0000000049454e44ae426082" +) + +SEED_PDF_BYTES = ( + b"%PDF-1.1\n1 0 obj<>endobj\n" + b"2 0 obj<>endobj\n" + b"3 0 obj<>endobj\n" + b"trailer<>\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 diff --git a/artifacts/forms.py b/artifacts/forms.py new file mode 100644 index 0000000..44f8e98 --- /dev/null +++ b/artifacts/forms.py @@ -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) diff --git a/artifacts/migrations/0003_tagcolor.py b/artifacts/migrations/0003_tagcolor.py new file mode 100644 index 0000000..a0c52ac --- /dev/null +++ b/artifacts/migrations/0003_tagcolor.py @@ -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)), + ], + ), + ] diff --git a/artifacts/migrations/0004_rename_persona_to_source.py b/artifacts/migrations/0004_rename_persona_to_source.py new file mode 100644 index 0000000..5c2480c --- /dev/null +++ b/artifacts/migrations/0004_rename_persona_to_source.py @@ -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", + ), + ] diff --git a/artifacts/migrations/0005_add_owner_nullable.py b/artifacts/migrations/0005_add_owner_nullable.py new file mode 100644 index 0000000..f3da6dc --- /dev/null +++ b/artifacts/migrations/0005_add_owner_nullable.py @@ -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, + ), + ), + ] diff --git a/artifacts/migrations/0006_backfill_owner.py b/artifacts/migrations/0006_backfill_owner.py new file mode 100644 index 0000000..941bab7 --- /dev/null +++ b/artifacts/migrations/0006_backfill_owner.py @@ -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), + ] diff --git a/artifacts/migrations/0007_owner_required.py b/artifacts/migrations/0007_owner_required.py new file mode 100644 index 0000000..f588a3f --- /dev/null +++ b/artifacts/migrations/0007_owner_required.py @@ -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, + ), + ), + ] diff --git a/artifacts/migrations/0008_artifact_read_at.py b/artifacts/migrations/0008_artifact_read_at.py new file mode 100644 index 0000000..d86ddf0 --- /dev/null +++ b/artifacts/migrations/0008_artifact_read_at.py @@ -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), + ), + ] diff --git a/artifacts/models.py b/artifacts/models.py index 59278b9..94a981e 100644 --- a/artifacts/models.py +++ b/artifacts/models.py @@ -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})" diff --git a/artifacts/urls.py b/artifacts/urls.py index fc92a8f..4f10c79 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -5,8 +5,13 @@ from . import views urlpatterns = [ path("", views.feed, name="feed"), + path("feed/older", views.feed_older, name="feed-older"), + path("a//read", views.mark_artifact_read, name="artifact-mark-read"), path("a/", 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/", 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"), ] diff --git a/artifacts/views.py b/artifacts/views.py index 93f45c8..ec2f567 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -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""" diff --git a/daybook_project/settings.py b/daybook_project/settings.py index f68938b..3b396a6 100644 --- a/daybook_project/settings.py +++ b/daybook_project/settings.py @@ -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") diff --git a/daybook_project/urls.py b/daybook_project/urls.py index 07b927f..4204d51 100644 --- a/daybook_project/urls.py +++ b/daybook_project/urls.py @@ -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")), ] diff --git a/docs/agent_quickstart.md b/docs/agent_quickstart.md index e266ca9..28d4fe6 100644 --- a/docs/agent_quickstart.md +++ b/docs/agent_quickstart.md @@ -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 " } ``` ## 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 " } ` -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 " } ` -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 " ` + -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 " } ` -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. diff --git a/docs/api.md b/docs/api.md index 5296050..9285708 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 ``` -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 ``` 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 " } ` -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 ``` 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 " ` + -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 ``` 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 " } ` -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 " 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. diff --git a/docs/llm_posting_helper.md b/docs/llm_posting_helper.md index 380c139..cd8cf1d 100644 --- a/docs/llm_posting_helper.md +++ b/docs/llm_posting_helper.md @@ -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 diff --git a/planning/mcp_research.md b/planning/mcp_research.md new file mode 100644 index 0000000..3fe7aad --- /dev/null +++ b/planning/mcp_research.md @@ -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 \ No newline at end of file diff --git a/scripts/README.md b/scripts/README.md index 9e5d964..e681cd0 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 diff --git a/scripts/seed_demo_artifacts.py b/scripts/seed_demo_artifacts.py new file mode 100644 index 0000000..dba250f --- /dev/null +++ b/scripts/seed_demo_artifacts.py @@ -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", + "

HTML Fragment

This is a seed HTML fragment artifact.

", + "html-fragment", + )) + + exit_codes.append(post_text( + "html_full", + "Seed: Full HTML Sample", + "Seed Full HTML" + "

Full HTML Doc

Seed artifact.

", + "html-full", + )) + + exit_codes.append(post_text( + "deck", + "Seed: Deck Sample", + "

Slide One

Seed deck artifact.

" + "

Slide Two

More content.

", + "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<>endobj\n" + b"2 0 obj<>endobj\n" + b"3 0 obj<>endobj\n" + b"trailer<>\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()) diff --git a/skills/daybook-posting/SKILL.md b/skills/daybook-posting/SKILL.md index 98bef0e..ba73adc 100644 --- a/skills/daybook-posting/SKILL.md +++ b/skills/daybook-posting/SKILL.md @@ -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. diff --git a/skills/daybook-posting/scripts/daybook_post.py b/skills/daybook-posting/scripts/daybook_post.py index cd86c12..b2e7563 100644 --- a/skills/daybook-posting/scripts/daybook_post.py +++ b/skills/daybook-posting/scripts/daybook_post.py @@ -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")) diff --git a/static/daybook.css b/static/daybook.css index bfc4b6d..ab1f742 100644 --- a/static/daybook.css +++ b/static/daybook.css @@ -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; +} diff --git a/templates/artifacts/_day_groups.html b/templates/artifacts/_day_groups.html new file mode 100644 index 0000000..39920b4 --- /dev/null +++ b/templates/artifacts/_day_groups.html @@ -0,0 +1,34 @@ +{% for group in day_groups %} + +{% endfor %} +{% if has_more %} +
+{% endif %} diff --git a/templates/artifacts/_feed_toolbar.html b/templates/artifacts/_feed_toolbar.html new file mode 100644 index 0000000..904c23d --- /dev/null +++ b/templates/artifacts/_feed_toolbar.html @@ -0,0 +1,28 @@ +
+ + {% if selected_source %}{% endif %} + {% if selected_artifact %}{% endif %} + + + + +
diff --git a/templates/artifacts/_topbar.html b/templates/artifacts/_topbar.html new file mode 100644 index 0000000..6f13d6d --- /dev/null +++ b/templates/artifacts/_topbar.html @@ -0,0 +1,37 @@ +
+
+

AgentFeed

+
+
+ {% if topbar_toolbar %}{% include topbar_toolbar %}{% endif %} +
+
+ + +
+
+ diff --git a/templates/artifacts/feed.html b/templates/artifacts/feed.html index 31bdc59..667765b 100644 --- a/templates/artifacts/feed.html +++ b/templates/artifacts/feed.html @@ -7,94 +7,51 @@ -
-
-

AgentFeed

-
-
-
- - - {% if selected_artifact %}{% endif %} - - - - -
-
-
+ {% include "artifacts/_topbar.html" with topbar_toolbar="artifacts/_feed_toolbar.html" %}
-