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:
parent
203f9b578e
commit
37cd96fa7a
33 changed files with 1614 additions and 367 deletions
13
.claude/commands/seed-demo.md
Normal file
13
.claude/commands/seed-demo.md
Normal 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.
|
||||||
20
CLAUDE.md
20
CLAUDE.md
|
|
@ -2,15 +2,18 @@
|
||||||
|
|
||||||
## Project
|
## 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.
|
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
|
## Current MVP Decisions
|
||||||
|
|
||||||
- Use a single shared ingest API token for now.
|
- Each user has a personal ingest API token (DRF `Token` model), viewable/regenerable from Settings → Account.
|
||||||
- Require `persona` in every artifact payload.
|
- 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.
|
||||||
- Do not add reader authentication in the local/self-hosted MVP.
|
- `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.
|
- Use random 8-character artifact IDs.
|
||||||
- Start as a plain Django project; add Docker after core ingest and viewer work.
|
- Start as a plain Django project; add Docker after core ingest and viewer work.
|
||||||
- Preserve original markdown as `.md` alongside rendered `.html`.
|
- 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/`.
|
- Main app: `artifacts/`.
|
||||||
- Theme CSS: `static/themes/`.
|
- Theme CSS: `static/themes/`.
|
||||||
- Feed template: `templates/artifacts/feed.html`.
|
- 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
|
## 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.
|
- 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`.
|
- 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.
|
- 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.
|
- 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.
|
- 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`.
|
- 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
|
## API Defaults
|
||||||
|
|
||||||
- MVP ingest endpoint: `POST /api/artifacts`
|
- 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}`
|
- 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
|
## Claude Code / OpenCode
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,6 @@ from .models import Artifact
|
||||||
|
|
||||||
@admin.register(Artifact)
|
@admin.register(Artifact)
|
||||||
class ArtifactAdmin(admin.ModelAdmin):
|
class ArtifactAdmin(admin.ModelAdmin):
|
||||||
list_display = ("id", "title", "persona", "source_type", "source_name", "kind", "day", "created_at")
|
list_display = ("id", "title", "owner", "source", "source_type", "source_name", "kind", "day", "created_at")
|
||||||
list_filter = ("persona", "source_type", "kind", "day")
|
list_filter = ("owner", "source", "source_type", "kind", "day")
|
||||||
search_fields = ("id", "title", "persona", "source_name", "source_id")
|
search_fields = ("id", "title", "source", "source_name", "source_id")
|
||||||
|
|
|
||||||
87
artifacts/demo_data.py
Normal file
87
artifacts/demo_data.py
Normal 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
6
artifacts/forms.py
Normal 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)
|
||||||
20
artifacts/migrations/0003_tagcolor.py
Normal file
20
artifacts/migrations/0003_tagcolor.py
Normal 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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
16
artifacts/migrations/0004_rename_persona_to_source.py
Normal file
16
artifacts/migrations/0004_rename_persona_to_source.py
Normal 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",
|
||||||
|
),
|
||||||
|
]
|
||||||
24
artifacts/migrations/0005_add_owner_nullable.py
Normal file
24
artifacts/migrations/0005_add_owner_nullable.py
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
27
artifacts/migrations/0006_backfill_owner.py
Normal file
27
artifacts/migrations/0006_backfill_owner.py
Normal 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),
|
||||||
|
]
|
||||||
23
artifacts/migrations/0007_owner_required.py
Normal file
23
artifacts/migrations/0007_owner_required.py
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
artifacts/migrations/0008_artifact_read_at.py
Normal file
18
artifacts/migrations/0008_artifact_read_at.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from django.conf import settings
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -31,7 +32,8 @@ class Artifact(models.Model):
|
||||||
]
|
]
|
||||||
|
|
||||||
id = models.CharField(max_length=8, primary_key=True)
|
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)
|
title = models.CharField(max_length=200)
|
||||||
kind = models.CharField(max_length=20, choices=KIND_CHOICES)
|
kind = models.CharField(max_length=20, choices=KIND_CHOICES)
|
||||||
theme = models.CharField(max_length=40, blank=True)
|
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_name = models.CharField(max_length=120, blank=True)
|
||||||
source_id = models.CharField(max_length=120, blank=True)
|
source_id = models.CharField(max_length=120, blank=True)
|
||||||
metadata = models.JSONField(default=dict, 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:
|
class Meta:
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
def __str__(self):
|
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})"
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,13 @@ from . import views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.feed, name="feed"),
|
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("a/<str:artifact_id>", views.artifact_view, name="artifact"),
|
||||||
path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"),
|
path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"),
|
||||||
path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"),
|
path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"),
|
||||||
path("api/artifacts/<str:artifact_id>", views.ArtifactDetailView.as_view(), name="artifact-detail-api"),
|
path("api/artifacts/<str:artifact_id>", views.ArtifactDetailView.as_view(), name="artifact-detail-api"),
|
||||||
|
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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,48 @@
|
||||||
|
import hashlib
|
||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import markdown
|
import markdown
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import FileResponse, Http404, HttpResponse, HttpResponseForbidden
|
from django.contrib import messages
|
||||||
from django.shortcuts import get_object_or_404, render
|
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.template.defaultfilters import slugify
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.dateparse import parse_date
|
from django.utils.dateparse import parse_date
|
||||||
from django.utils import timezone
|
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.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.parsers import FormParser, JSONParser, MultiPartParser
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
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}
|
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 = {
|
DEFAULT_THEMES = {
|
||||||
Artifact.HTML_FRAGMENT: "report",
|
Artifact.HTML_FRAGMENT: "report",
|
||||||
Artifact.MARKDOWN: "report",
|
Artifact.MARKDOWN: "report",
|
||||||
|
|
@ -29,17 +50,18 @@ DEFAULT_THEMES = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(login_required, name="get")
|
||||||
class ArtifactListCreateView(APIView):
|
class ArtifactListCreateView(APIView):
|
||||||
parser_classes = [JSONParser, FormParser, MultiPartParser]
|
parser_classes = [JSONParser, FormParser, MultiPartParser]
|
||||||
|
|
||||||
def get(self, request):
|
def get(self, request):
|
||||||
artifacts = Artifact.objects.all()
|
artifacts = Artifact.objects.filter(owner=request.user)
|
||||||
if persona := request.query_params.get("persona"):
|
if source := request.query_params.get("source"):
|
||||||
artifacts = artifacts.filter(persona=persona)
|
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"):
|
if day := request.query_params.get("day"):
|
||||||
artifacts = artifacts.filter(day=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"):
|
if kind := request.query_params.get("kind"):
|
||||||
artifacts = artifacts.filter(kind=kind)
|
artifacts = artifacts.filter(kind=kind)
|
||||||
if title_query := request.query_params.get("q"):
|
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])
|
return Response([serialize_artifact(artifact, request) for artifact in artifacts])
|
||||||
|
|
||||||
def post(self, request):
|
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")
|
return HttpResponseForbidden("Invalid or missing API token")
|
||||||
|
|
||||||
if request.content_type and request.content_type.startswith("multipart/"):
|
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):
|
if isinstance(artifact, Response):
|
||||||
return artifact
|
return artifact
|
||||||
|
apply_tag_colors(request.data)
|
||||||
return Response(serialize_artifact(artifact, request), status=201)
|
return Response(serialize_artifact(artifact, request), status=201)
|
||||||
|
|
||||||
data = request.data
|
data = request.data
|
||||||
|
|
@ -62,7 +86,7 @@ class ArtifactListCreateView(APIView):
|
||||||
if kind not in TEXT_KINDS:
|
if kind not in TEXT_KINDS:
|
||||||
return Response({"detail": "kind must be html_fragment, html_full, markdown, or deck"}, status=400)
|
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)]
|
missing = [field for field in required if not data.get(field)]
|
||||||
if missing:
|
if missing:
|
||||||
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400)
|
return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400)
|
||||||
|
|
@ -71,20 +95,23 @@ class ArtifactListCreateView(APIView):
|
||||||
if source_error:
|
if source_error:
|
||||||
return 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)
|
return Response(serialize_artifact(artifact, request), status=201)
|
||||||
|
|
||||||
|
|
||||||
|
@method_decorator(login_required, name="get")
|
||||||
class ArtifactDetailView(APIView):
|
class ArtifactDetailView(APIView):
|
||||||
def get(self, request, artifact_id):
|
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))
|
return Response(serialize_artifact(artifact, request))
|
||||||
|
|
||||||
def patch(self, request, artifact_id):
|
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")
|
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
|
data = request.data
|
||||||
if "title" in data:
|
if "title" in data:
|
||||||
if not data["title"]:
|
if not data["title"]:
|
||||||
|
|
@ -106,6 +133,11 @@ class ArtifactDetailView(APIView):
|
||||||
if not isinstance(data["metadata"], dict):
|
if not isinstance(data["metadata"], dict):
|
||||||
return Response({"detail": "metadata must be an object"}, status=400)
|
return Response({"detail": "metadata must be an object"}, status=400)
|
||||||
artifact.metadata = data["metadata"]
|
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(
|
artifact.save(
|
||||||
update_fields=[
|
update_fields=[
|
||||||
"title",
|
"title",
|
||||||
|
|
@ -121,11 +153,16 @@ class ArtifactDetailView(APIView):
|
||||||
|
|
||||||
|
|
||||||
class CapabilitiesView(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):
|
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(
|
return Response(
|
||||||
{
|
{
|
||||||
"artifact_kinds": [choice[0] for choice in Artifact.KIND_CHOICES],
|
"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"],
|
"views": ["rendered", "raw"],
|
||||||
"raw_supported_for": [Artifact.MARKDOWN, Artifact.HTML_FULL],
|
"raw_supported_for": [Artifact.MARKDOWN, Artifact.HTML_FULL],
|
||||||
"source_types": [choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES],
|
"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):
|
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", "")
|
selected_kind = request.GET.get("kind", "")
|
||||||
title_query = request.GET.get("q", "")
|
title_query = request.GET.get("q", "")
|
||||||
today = timezone.localdate()
|
today = timezone.localdate()
|
||||||
selected_day = parse_date(request.GET.get("day", "")) or today
|
|
||||||
selected_artifact_id = request.GET.get("artifact", "")
|
selected_artifact_id = request.GET.get("artifact", "")
|
||||||
preview_none = request.GET.get("preview") == "none"
|
preview_none = request.GET.get("preview") == "none"
|
||||||
selected_view = "raw" if request.GET.get("view") == "raw" else "rendered"
|
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"))
|
all_artifacts = Artifact.objects.filter(owner=request.user)
|
||||||
for day in (today, selected_day):
|
artifacts = apply_feed_filters(
|
||||||
if day not in days:
|
all_artifacts, selected_source, selected_label, selected_kind, title_query
|
||||||
days.append(day)
|
)
|
||||||
days.sort(reverse=True)
|
|
||||||
|
|
||||||
day_artifacts = artifacts.filter(day=selected_day).order_by("-created_at")
|
window_start = today - timedelta(days=FEED_WINDOW_DAYS - 1)
|
||||||
selected_artifact = None if preview_none else day_artifacts.filter(pk=selected_artifact_id).first()
|
if not artifacts.filter(day__gte=window_start).exists():
|
||||||
toggle_query = {"day": selected_day.isoformat(), "view": "rendered"}
|
# Nothing recent for these filters: fall back to the most recent days that
|
||||||
if persona:
|
# do match, so selecting a source is never met with a blank list.
|
||||||
toggle_query["persona"] = persona
|
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:
|
if selected_kind:
|
||||||
toggle_query["kind"] = selected_kind
|
toggle_query["kind"] = selected_kind
|
||||||
if title_query:
|
if title_query:
|
||||||
|
|
@ -172,20 +245,36 @@ def feed(request):
|
||||||
rendered_url = f"?{urlencode(toggle_query)}"
|
rendered_url = f"?{urlencode(toggle_query)}"
|
||||||
toggle_query["view"] = "raw"
|
toggle_query["view"] = "raw"
|
||||||
raw_url = f"?{urlencode(toggle_query)}"
|
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(
|
return render(
|
||||||
request,
|
request,
|
||||||
"artifacts/feed.html",
|
"artifacts/feed.html",
|
||||||
{
|
{
|
||||||
"artifacts": day_artifacts,
|
"artifacts": window_artifacts,
|
||||||
"days": days,
|
"day_groups": day_groups,
|
||||||
"personas": personas,
|
"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,
|
"raw_url": raw_url,
|
||||||
"rendered_url": rendered_url,
|
"rendered_url": rendered_url,
|
||||||
"selected_artifact": selected_artifact,
|
"selected_artifact": selected_artifact,
|
||||||
"selected_day": selected_day,
|
|
||||||
"selected_kind": selected_kind,
|
"selected_kind": selected_kind,
|
||||||
"selected_persona": persona,
|
"selected_label": selected_label,
|
||||||
|
"selected_source": selected_source,
|
||||||
"selected_view": selected_view,
|
"selected_view": selected_view,
|
||||||
"preview_none": preview_none,
|
"preview_none": preview_none,
|
||||||
"today": today,
|
"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
|
@xframe_options_sameorigin
|
||||||
def artifact_view(request, artifact_id):
|
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":
|
if request.GET.get("raw") == "1":
|
||||||
raw_path = raw_artifact_path(artifact)
|
raw_path = raw_artifact_path(artifact)
|
||||||
if raw_path and raw_path.exists():
|
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")
|
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()
|
now = timezone.localtime()
|
||||||
artifact_id = generate_id()
|
artifact_id = generate_id()
|
||||||
persona = slugify(data["persona"]) or "unknown"
|
source = slugify(data["source"]) or "unknown"
|
||||||
kind = data["kind"]
|
kind = data["kind"]
|
||||||
theme = data.get("theme") or DEFAULT_THEMES.get(kind, "base")
|
theme = data.get("theme") or DEFAULT_THEMES.get(kind, "base")
|
||||||
title = data["title"]
|
title = data["title"]
|
||||||
content = data["content"]
|
content = data["content"]
|
||||||
|
|
||||||
directory = Path(persona) / now.date().isoformat()
|
directory = Path(source) / now.date().isoformat()
|
||||||
target_dir = settings.ARTIFACTS_ROOT / directory
|
target_dir = settings.ARTIFACTS_ROOT / directory
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
@ -240,7 +379,8 @@ def create_text_artifact(data, request):
|
||||||
|
|
||||||
return Artifact.objects.create(
|
return Artifact.objects.create(
|
||||||
id=artifact_id,
|
id=artifact_id,
|
||||||
persona=persona,
|
owner=user,
|
||||||
|
source=source,
|
||||||
title=title,
|
title=title,
|
||||||
kind=kind,
|
kind=kind,
|
||||||
theme=theme,
|
theme=theme,
|
||||||
|
|
@ -249,13 +389,13 @@ def create_text_artifact(data, request):
|
||||||
original_kind=kind,
|
original_kind=kind,
|
||||||
tags=normalize_tags(data.get("tags", [])),
|
tags=normalize_tags(data.get("tags", [])),
|
||||||
source_prompt=data.get("source_prompt", ""),
|
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")
|
uploaded = data.get("file")
|
||||||
required = ["persona", "title"]
|
required = ["source", "title"]
|
||||||
missing = [field for field in required if not data.get(field)]
|
missing = [field for field in required if not data.get(field)]
|
||||||
if not uploaded:
|
if not uploaded:
|
||||||
missing.append("file")
|
missing.append("file")
|
||||||
|
|
@ -267,12 +407,12 @@ def create_binary_artifact(data):
|
||||||
|
|
||||||
now = timezone.localtime()
|
now = timezone.localtime()
|
||||||
artifact_id = generate_id()
|
artifact_id = generate_id()
|
||||||
persona = slugify(data["persona"]) or "unknown"
|
source = slugify(data["source"]) or "unknown"
|
||||||
title = data["title"]
|
title = data["title"]
|
||||||
content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or ""
|
content_type = getattr(uploaded, "content_type", "") or mimetypes.guess_type(uploaded.name)[0] or ""
|
||||||
kind = binary_kind(content_type)
|
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 = settings.ARTIFACTS_ROOT / directory
|
||||||
target_dir.mkdir(parents=True, exist_ok=True)
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
@ -284,7 +424,8 @@ def create_binary_artifact(data):
|
||||||
|
|
||||||
return Artifact.objects.create(
|
return Artifact.objects.create(
|
||||||
id=artifact_id,
|
id=artifact_id,
|
||||||
persona=persona,
|
owner=user,
|
||||||
|
source=source,
|
||||||
title=title,
|
title=title,
|
||||||
kind=kind,
|
kind=kind,
|
||||||
theme="",
|
theme="",
|
||||||
|
|
@ -293,7 +434,7 @@ def create_binary_artifact(data):
|
||||||
original_kind=kind,
|
original_kind=kind,
|
||||||
tags=normalize_tags(data.get("tags", [])),
|
tags=normalize_tags(data.get("tags", [])),
|
||||||
source_prompt=data.get("source_prompt", ""),
|
source_prompt=data.get("source_prompt", ""),
|
||||||
**source_fields(data, persona),
|
**source_fields(data, source),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -338,10 +479,17 @@ def generate_id():
|
||||||
return artifact_id
|
return artifact_id
|
||||||
|
|
||||||
|
|
||||||
def has_valid_token(request):
|
def resolve_token_user(request):
|
||||||
expected = getattr(settings, "DAYBOOK_API_TOKEN", "")
|
|
||||||
auth = request.headers.get("Authorization", "")
|
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):
|
def normalize_tags(tags):
|
||||||
|
|
@ -352,6 +500,39 @@ def normalize_tags(tags):
|
||||||
return []
|
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):
|
def validate_source_fields(data):
|
||||||
if source_type := data.get("source_type"):
|
if source_type := data.get("source_type"):
|
||||||
if source_type not in {choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES}:
|
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", {}))
|
parse_metadata_value(data.get("metadata", {}))
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return Response({"detail": str(exc)}, status=400)
|
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
|
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 {
|
return {
|
||||||
"source_type": data.get("source_type") or Artifact.AGENT,
|
"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 "",
|
"source_id": data.get("source_id") or "",
|
||||||
"metadata": parse_metadata_value(data.get("metadata", {})),
|
"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])),
|
"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_url": request.build_absolute_uri(f"{reverse('artifact', args=[artifact.id])}?raw=1"),
|
||||||
"raw_available": raw_artifact_path(artifact) is not None,
|
"raw_available": raw_artifact_path(artifact) is not None,
|
||||||
"persona": artifact.persona,
|
"source": artifact.source,
|
||||||
"title": artifact.title,
|
"title": artifact.title,
|
||||||
"kind": artifact.kind,
|
"kind": artifact.kind,
|
||||||
"theme": artifact.theme,
|
"theme": artifact.theme,
|
||||||
"day": artifact.day.isoformat(),
|
"day": artifact.day.isoformat(),
|
||||||
"created_at": artifact.created_at.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_prompt": artifact.source_prompt,
|
||||||
"source_type": artifact.source_type,
|
"source_type": artifact.source_type,
|
||||||
"source_name": artifact.source_name,
|
"source_name": artifact.source_name,
|
||||||
"source_id": artifact.source_id,
|
"source_id": artifact.source_id,
|
||||||
"metadata": artifact.metadata,
|
"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
|
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):
|
def raw_unavailable_html(artifact):
|
||||||
return f"""<!doctype html>
|
return f"""<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ INSTALLED_APPS = [
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"rest_framework",
|
"rest_framework",
|
||||||
|
"rest_framework.authtoken",
|
||||||
"artifacts",
|
"artifacts",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -59,6 +60,9 @@ DATABASES = {
|
||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = []
|
AUTH_PASSWORD_VALIDATORS = []
|
||||||
|
|
||||||
|
LOGIN_URL = "login"
|
||||||
|
LOGIN_REDIRECT_URL = "feed"
|
||||||
|
|
||||||
LANGUAGE_CODE = "en-us"
|
LANGUAGE_CODE = "en-us"
|
||||||
TIME_ZONE = "UTC"
|
TIME_ZONE = "UTC"
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
@ -76,4 +80,3 @@ STORAGES = {
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
ARTIFACTS_ROOT = Path(os.environ.get("ARTIFACTS_ROOT", DATA_DIR / "artifacts_store"))
|
ARTIFACTS_ROOT = Path(os.environ.get("ARTIFACTS_ROOT", DATA_DIR / "artifacts_store"))
|
||||||
DAYBOOK_API_TOKEN = os.environ.get("DAYBOOK_API_TOKEN", "dev-token")
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,13 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.contrib.auth import views as auth_views
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
|
from artifacts.views import DaybookLoginView
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
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")),
|
path("", include("artifacts.urls")),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,26 @@
|
||||||
|
|
||||||
This is the shortest path for an agent to publish and retrieve artifacts from Daybook.
|
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
|
## 1. Check Capabilities
|
||||||
|
|
||||||
|
Also confirms your token is valid:
|
||||||
|
|
||||||
```powershell
|
```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
|
## 2. Publish Markdown
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$body = @{
|
$body = @{
|
||||||
persona = "hermes"
|
source = "hermes-research"
|
||||||
source_type = "agent"
|
source_type = "agent"
|
||||||
source_name = "Hermes Agent"
|
source_name = "Hermes Agent"
|
||||||
source_id = "hermes-local"
|
source_id = "hermes-local"
|
||||||
|
|
@ -29,7 +38,7 @@ $body = @{
|
||||||
$artifact = Invoke-RestMethod `
|
$artifact = Invoke-RestMethod `
|
||||||
-Uri "http://127.0.0.1:8001/api/artifacts" `
|
-Uri "http://127.0.0.1:8001/api/artifacts" `
|
||||||
-Method Post `
|
-Method Post `
|
||||||
-Headers @{ Authorization = "Bearer dev-token" } `
|
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
|
||||||
-ContentType "application/json" `
|
-ContentType "application/json" `
|
||||||
-Body $body
|
-Body $body
|
||||||
|
|
||||||
|
|
@ -46,7 +55,7 @@ $artifact.url
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$body = @{
|
$body = @{
|
||||||
persona = "athena"
|
source = "athena"
|
||||||
source_type = "agent"
|
source_type = "agent"
|
||||||
source_name = "Athena Agent"
|
source_name = "Athena Agent"
|
||||||
kind = "html_fragment"
|
kind = "html_fragment"
|
||||||
|
|
@ -59,7 +68,7 @@ $body = @{
|
||||||
Invoke-RestMethod `
|
Invoke-RestMethod `
|
||||||
-Uri "http://127.0.0.1:8001/api/artifacts" `
|
-Uri "http://127.0.0.1:8001/api/artifacts" `
|
||||||
-Method Post `
|
-Method Post `
|
||||||
-Headers @{ Authorization = "Bearer dev-token" } `
|
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
|
||||||
-ContentType "application/json" `
|
-ContentType "application/json" `
|
||||||
-Body $body
|
-Body $body
|
||||||
```
|
```
|
||||||
|
|
@ -68,8 +77,8 @@ Invoke-RestMethod `
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" `
|
curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" `
|
||||||
-H "Authorization: Bearer dev-token" `
|
-H "Authorization: Bearer <your-personal-token>" `
|
||||||
-F "persona=orion" `
|
-F "source=orion" `
|
||||||
-F "source_type=agent" `
|
-F "source_type=agent" `
|
||||||
-F "source_name=Orion Agent" `
|
-F "source_name=Orion Agent" `
|
||||||
-F "title=Generated PDF" `
|
-F "title=Generated PDF" `
|
||||||
|
|
@ -79,8 +88,13 @@ curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" `
|
||||||
|
|
||||||
## 5. Find Recent 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
|
```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"
|
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?kind=markdown&q=status"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -105,7 +119,7 @@ $body = @{
|
||||||
Invoke-RestMethod `
|
Invoke-RestMethod `
|
||||||
-Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" `
|
-Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" `
|
||||||
-Method Patch `
|
-Method Patch `
|
||||||
-Headers @{ Authorization = "Bearer dev-token" } `
|
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
|
||||||
-ContentType "application/json" `
|
-ContentType "application/json" `
|
||||||
-Body $body
|
-Body $body
|
||||||
```
|
```
|
||||||
|
|
@ -114,7 +128,7 @@ Invoke-RestMethod `
|
||||||
|
|
||||||
Recommended fields for every agent post:
|
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_type`: usually `agent`.
|
||||||
- `source_name`: readable agent name.
|
- `source_name`: readable agent name.
|
||||||
- `source_id`: stable ID if the agent has one.
|
- `source_id`: stable ID if the agent has one.
|
||||||
|
|
|
||||||
111
docs/api.md
111
docs/api.md
|
|
@ -8,13 +8,19 @@ Base URL while running locally:
|
||||||
http://127.0.0.1:8001
|
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
|
```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
|
## Artifact Kinds
|
||||||
|
|
||||||
|
|
@ -32,13 +38,18 @@ Currently supported kinds:
|
||||||
|
|
||||||
## Source Fields
|
## 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 |
|
| 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_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. |
|
| `source_id` | No | Empty string | Stable external agent/person/system ID. |
|
||||||
| `metadata` | No | `{}` | Free-form object for model/run/tool metadata. |
|
| `metadata` | No | `{}` | Free-form object for model/run/tool metadata. |
|
||||||
|
|
||||||
|
|
@ -46,7 +57,7 @@ Example source metadata:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"persona": "hermes",
|
"source": "hermes-research",
|
||||||
"source_type": "agent",
|
"source_type": "agent",
|
||||||
"source_name": "Hermes Agent",
|
"source_name": "Hermes Agent",
|
||||||
"source_id": "agent-hermes-prod",
|
"source_id": "agent-hermes-prod",
|
||||||
|
|
@ -65,14 +76,14 @@ Endpoint:
|
||||||
```http
|
```http
|
||||||
POST /api/artifacts
|
POST /api/artifacts
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
Authorization: Bearer dev-token
|
Authorization: Bearer <your-personal-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
Required fields:
|
Required fields:
|
||||||
|
|
||||||
| Field | Notes |
|
| Field | Notes |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `persona` | Required. |
|
| `source` | Required. |
|
||||||
| `kind` | One of `html_fragment`, `html_full`, `markdown`, `deck`. |
|
| `kind` | One of `html_fragment`, `html_full`, `markdown`, `deck`. |
|
||||||
| `title` | Required. |
|
| `title` | Required. |
|
||||||
| `content` | Required text content. |
|
| `content` | Required text content. |
|
||||||
|
|
@ -83,17 +94,33 @@ Optional fields:
|
||||||
|---|---|
|
|---|---|
|
||||||
| `theme` | Defaults by kind. Common values: `report`, `tasks`, `deck`, `base`. |
|
| `theme` | Defaults by kind. Common values: `report`, `tasks`, `deck`, `base`. |
|
||||||
| `tags` | Array or comma-separated string. |
|
| `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_prompt` | Prompt or instruction that led to the artifact. |
|
||||||
| `source_type` | Defaults to `agent`. |
|
| `source_type` | Defaults to `agent`. |
|
||||||
| `source_name` | Defaults to `persona`. |
|
| `source_name` | Defaults to `source`. |
|
||||||
| `source_id` | Optional stable source ID. |
|
| `source_id` | Optional stable source ID. |
|
||||||
| `metadata` | Optional object. |
|
| `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 example:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
$body = @{
|
$body = @{
|
||||||
persona = "hermes"
|
source = "hermes-research"
|
||||||
source_type = "agent"
|
source_type = "agent"
|
||||||
source_name = "Hermes Agent"
|
source_name = "Hermes Agent"
|
||||||
source_id = "agent-hermes-local"
|
source_id = "agent-hermes-local"
|
||||||
|
|
@ -112,7 +139,7 @@ $body = @{
|
||||||
Invoke-RestMethod `
|
Invoke-RestMethod `
|
||||||
-Uri "http://127.0.0.1:8001/api/artifacts" `
|
-Uri "http://127.0.0.1:8001/api/artifacts" `
|
||||||
-Method Post `
|
-Method Post `
|
||||||
-Headers @{ Authorization = "Bearer dev-token" } `
|
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
|
||||||
-ContentType "application/json" `
|
-ContentType "application/json" `
|
||||||
-Body $body
|
-Body $body
|
||||||
```
|
```
|
||||||
|
|
@ -126,13 +153,18 @@ Response shape:
|
||||||
"api_url": "http://127.0.0.1:8001/api/artifacts/a1b2c3d4",
|
"api_url": "http://127.0.0.1:8001/api/artifacts/a1b2c3d4",
|
||||||
"raw_url": "http://127.0.0.1:8001/a/a1b2c3d4?raw=1",
|
"raw_url": "http://127.0.0.1:8001/a/a1b2c3d4?raw=1",
|
||||||
"raw_available": true,
|
"raw_available": true,
|
||||||
"persona": "hermes",
|
"source": "hermes-research",
|
||||||
"title": "Morning Brief",
|
"title": "Morning Brief",
|
||||||
"kind": "markdown",
|
"kind": "markdown",
|
||||||
"theme": "report",
|
"theme": "report",
|
||||||
"day": "2026-06-10",
|
"day": "2026-06-10",
|
||||||
"created_at": "2026-06-10T15:00:00+00:00",
|
"created_at": "2026-06-10T15:00:00+00:00",
|
||||||
"tags": ["brief", "daily"],
|
"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_prompt": "Prepare a concise morning brief.",
|
||||||
"source_type": "agent",
|
"source_type": "agent",
|
||||||
"source_name": "Hermes Agent",
|
"source_name": "Hermes Agent",
|
||||||
|
|
@ -140,10 +172,15 @@ Response shape:
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"model": "gpt-5.5",
|
"model": "gpt-5.5",
|
||||||
"run_id": "brief-001"
|
"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
|
## Create File Artifact
|
||||||
|
|
||||||
Endpoint:
|
Endpoint:
|
||||||
|
|
@ -151,14 +188,14 @@ Endpoint:
|
||||||
```http
|
```http
|
||||||
POST /api/artifacts
|
POST /api/artifacts
|
||||||
Content-Type: multipart/form-data
|
Content-Type: multipart/form-data
|
||||||
Authorization: Bearer dev-token
|
Authorization: Bearer <your-personal-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
Required fields:
|
Required fields:
|
||||||
|
|
||||||
| Field | Notes |
|
| Field | Notes |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `persona` | Required. |
|
| `source` | Required. |
|
||||||
| `title` | Required. |
|
| `title` | Required. |
|
||||||
| `file` | Required file part. |
|
| `file` | Required file part. |
|
||||||
|
|
||||||
|
|
@ -174,8 +211,8 @@ PowerShell/curl example:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" `
|
curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" `
|
||||||
-H "Authorization: Bearer dev-token" `
|
-H "Authorization: Bearer <your-personal-token>" `
|
||||||
-F "persona=orion" `
|
-F "source=orion" `
|
||||||
-F "source_type=agent" `
|
-F "source_type=agent" `
|
||||||
-F "source_name=Orion Agent" `
|
-F "source_name=Orion Agent" `
|
||||||
-F "title=Architecture Diagram" `
|
-F "title=Architecture Diagram" `
|
||||||
|
|
@ -191,23 +228,28 @@ Endpoint:
|
||||||
GET /api/artifacts
|
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 |
|
| 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`. |
|
| `day` | Exact day, `YYYY-MM-DD`. |
|
||||||
| `tag` | Matches artifacts containing the tag. |
|
|
||||||
| `kind` | Exact artifact kind. |
|
| `kind` | Exact artifact kind. |
|
||||||
| `q` | Case-insensitive title fragment. |
|
| `q` | Case-insensitive title fragment. |
|
||||||
|
|
||||||
|
`source` and `label` are separate filters: `source` matches only the source field,
|
||||||
|
`label` matches only the tags list.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts"
|
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?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
|
## Get Artifact Metadata
|
||||||
|
|
@ -231,7 +273,7 @@ Endpoint:
|
||||||
```http
|
```http
|
||||||
PATCH /api/artifacts/{id}
|
PATCH /api/artifacts/{id}
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
Authorization: Bearer dev-token
|
Authorization: Bearer <your-personal-token>
|
||||||
```
|
```
|
||||||
|
|
||||||
Editable fields:
|
Editable fields:
|
||||||
|
|
@ -240,18 +282,21 @@ Editable fields:
|
||||||
|---|---|
|
|---|---|
|
||||||
| `title` | Must not be blank. |
|
| `title` | Must not be blank. |
|
||||||
| `tags` | Array or comma-separated string. |
|
| `tags` | Array or comma-separated string. |
|
||||||
|
| `tag_colors` | Object mapping tag name → `#RRGGBB`. Updates the global colour registry. |
|
||||||
| `source_prompt` | String. |
|
| `source_prompt` | String. |
|
||||||
| `source_type` | `agent`, `person`, or `system`. |
|
| `source_type` | `agent`, `person`, or `system`. |
|
||||||
| `source_name` | String. |
|
| `source_name` | String. |
|
||||||
| `source_id` | String. |
|
| `source_id` | String. |
|
||||||
| `metadata` | Object. Replaces existing metadata. |
|
| `metadata` | Object. Replaces existing metadata. |
|
||||||
|
|
||||||
|
You can only PATCH artifacts your own token owns; others return `404`.
|
||||||
|
|
||||||
Not editable yet:
|
Not editable yet:
|
||||||
|
|
||||||
| Field | Reason |
|
| Field | Reason |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `content` | Content replacement/revisions are intentionally deferred. |
|
| `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. |
|
| `kind` | Affects renderer and storage behavior. |
|
||||||
| `day` | Affects time-ordered feed and storage layout. |
|
| `day` | Affects time-ordered feed and storage layout. |
|
||||||
|
|
||||||
|
|
@ -271,7 +316,7 @@ $body = @{
|
||||||
Invoke-RestMethod `
|
Invoke-RestMethod `
|
||||||
-Uri "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" `
|
-Uri "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" `
|
||||||
-Method Patch `
|
-Method Patch `
|
||||||
-Headers @{ Authorization = "Bearer dev-token" } `
|
-Headers @{ Authorization = "Bearer <your-personal-token>" } `
|
||||||
-ContentType "application/json" `
|
-ContentType "application/json" `
|
||||||
-Body $body
|
-Body $body
|
||||||
```
|
```
|
||||||
|
|
@ -312,7 +357,7 @@ Example response:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"artifact_kinds": ["html_fragment", "html_full", "markdown", "deck", "image", "pdf", "binary"],
|
"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"],
|
"views": ["rendered", "raw"],
|
||||||
"raw_supported_for": ["markdown", "html_full"],
|
"raw_supported_for": ["markdown", "html_full"],
|
||||||
"source_types": ["agent", "person", "system"],
|
"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
|
## Current MVP Constraints
|
||||||
|
|
||||||
- A single shared ingest token is used: `dev-token`.
|
- Each user has a personal ingest API token; there is no shared token.
|
||||||
- Reader/browser authentication is not implemented.
|
- Reader/browser access requires login; feeds are private per user.
|
||||||
- Content modification/revisions are deferred.
|
- Content modification/revisions are deferred.
|
||||||
- Office document conversion is not implemented.
|
- Office document conversion is not implemented.
|
||||||
- Manual browser upload/drag-and-drop is deferred.
|
- Manual browser upload/drag-and-drop is deferred.
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,12 @@ The helper is intentionally zero-dependency and uses only Python's standard libr
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
```text
|
```text
|
||||||
DAYBOOK_URL=http://127.0.0.1:8000
|
DAYBOOK_URL=http://127.0.0.1:8001
|
||||||
DAYBOOK_TOKEN=dev-token
|
DAYBOOK_TOKEN=your-personal-api-token
|
||||||
DAYBOOK_PERSONA=agent
|
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
|
## Commands
|
||||||
|
|
||||||
|
|
|
||||||
145
planning/mcp_research.md
Normal file
145
planning/mcp_research.md
Normal 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
|
||||||
|
|
@ -7,11 +7,13 @@ The helper is for agents and scripts that need to publish artifacts to Daybook w
|
||||||
Configuration:
|
Configuration:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
DAYBOOK_URL=http://127.0.0.1:8000
|
DAYBOOK_URL=http://127.0.0.1:8001
|
||||||
DAYBOOK_TOKEN=dev-token
|
DAYBOOK_TOKEN=your-personal-api-token
|
||||||
DAYBOOK_PERSONA=agent
|
DAYBOOK_SOURCE=agent
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`DAYBOOK_TOKEN` is a personal per-user token from Settings → Account in the app, not shared across users.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
111
scripts/seed_demo_artifacts.py
Normal file
111
scripts/seed_demo_artifacts.py
Normal 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())
|
||||||
|
|
@ -27,11 +27,13 @@ The artifact URL is printed as the final stdout line.
|
||||||
Use environment variables:
|
Use environment variables:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
DAYBOOK_URL=http://127.0.0.1:8000
|
DAYBOOK_URL=http://127.0.0.1:8001
|
||||||
DAYBOOK_TOKEN=dev-token
|
DAYBOOK_TOKEN=your-personal-api-token
|
||||||
DAYBOOK_PERSONA=agent
|
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 Cheat Sheet
|
||||||
|
|
||||||
| Kind | Use for | Content shape |
|
| Kind | Use for | Content shape |
|
||||||
|
|
@ -48,7 +50,7 @@ For CSV, JSON, or code that should render today, use `--file path --as-markdown`
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Run `--check` first if unsure Daybook is reachable.
|
- 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 `--tags a,b,c` for filtering.
|
||||||
- Use `--meta run_id=... --meta model=...` for machine-readable context.
|
- Use `--meta run_id=... --meta model=...` for machine-readable context.
|
||||||
- Never inline multiline content into the shell command; use a file or stdin.
|
- Never inline multiline content into the shell command; use a file or stdin.
|
||||||
|
|
|
||||||
|
|
@ -20,26 +20,39 @@ from urllib.request import Request, urlopen
|
||||||
|
|
||||||
TEXT_KINDS = {"markdown", "html_fragment", "html_full", "deck"}
|
TEXT_KINDS = {"markdown", "html_fragment", "html_full", "deck"}
|
||||||
TEXT_SUFFIXES = {".md", ".markdown", ".txt", ".html", ".htm"}
|
TEXT_SUFFIXES = {".md", ".markdown", ".txt", ".html", ".htm"}
|
||||||
DEFAULT_URL = "http://127.0.0.1:8000"
|
DEFAULT_URL = "http://127.0.0.1:8001"
|
||||||
DEFAULT_TOKEN = "dev-token"
|
DEFAULT_SOURCE = "agent"
|
||||||
DEFAULT_PERSONA = "agent"
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
base_url = os.environ.get("DAYBOOK_URL", DEFAULT_URL).rstrip("/")
|
base_url = os.environ.get("DAYBOOK_URL", DEFAULT_URL).rstrip("/")
|
||||||
token = os.environ.get("DAYBOOK_TOKEN", DEFAULT_TOKEN)
|
# DAYBOOK_TOKEN is your personal per-user token (Settings -> Account in the app);
|
||||||
persona = args.persona or os.environ.get("DAYBOOK_PERSONA", DEFAULT_PERSONA)
|
# 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:
|
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))
|
print(json.dumps(response, indent=2))
|
||||||
return 0
|
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:
|
try:
|
||||||
if args.path:
|
if args.path:
|
||||||
resolve_auto_path(args)
|
resolve_auto_path(args)
|
||||||
payload = build_payload(args, persona)
|
payload = build_payload(args, source)
|
||||||
validate_payload(payload, args)
|
validate_payload(payload, args)
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
print_dry_run(base_url, token, payload, args)
|
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("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("--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("--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("--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("--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("--tags", help="Comma-separated tags.")
|
||||||
parser.add_argument("--theme", help="Theme for markdown/html_fragment. Example: report, tasks, bootstrap.")
|
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", action="append", default=[], help="Metadata key=value. Repeatable.")
|
||||||
parser.add_argument("--meta-json", help="Metadata JSON object. Merged with --meta values.")
|
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-id", default="", help="Stable source identifier.")
|
||||||
parser.add_argument("--source-prompt", default="", help="Prompt/instruction that produced the artifact.")
|
parser.add_argument("--source-prompt", default="", help="Prompt/instruction that produced the artifact.")
|
||||||
parser.add_argument("--source-type", choices=["agent", "person", "system"], default="agent")
|
parser.add_argument("--source-type", choices=["agent", "person", "system"], default="agent")
|
||||||
|
|
@ -160,14 +173,14 @@ def humanize(stem):
|
||||||
return cleaned.title() if cleaned else stem
|
return cleaned.title() if cleaned else stem
|
||||||
|
|
||||||
|
|
||||||
def build_payload(args, persona):
|
def build_payload(args, source):
|
||||||
metadata = parse_metadata(args)
|
metadata = parse_metadata(args)
|
||||||
common = {
|
common = {
|
||||||
"persona": persona,
|
"source": source,
|
||||||
"title": args.title,
|
"title": args.title,
|
||||||
"tags": normalize_tags(args.tags),
|
"tags": normalize_tags(args.tags),
|
||||||
"source_type": args.source_type,
|
"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_id": args.source_id,
|
||||||
"source_prompt": args.source_prompt,
|
"source_prompt": args.source_prompt,
|
||||||
"metadata": metadata,
|
"metadata": metadata,
|
||||||
|
|
@ -357,9 +370,11 @@ def content_type_for(path):
|
||||||
return mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
return mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
||||||
|
|
||||||
|
|
||||||
def request_json(method, url):
|
def request_json(method, url, token=""):
|
||||||
request = Request(url, method=method)
|
request = Request(url, method=method)
|
||||||
request.add_header("Accept", "application/json")
|
request.add_header("Accept", "application/json")
|
||||||
|
if token:
|
||||||
|
request.add_header("Authorization", f"Bearer {token}")
|
||||||
with urlopen(request, timeout=15) as response:
|
with urlopen(request, timeout=15) as response:
|
||||||
return json.loads(response.read().decode("utf-8"))
|
return json.loads(response.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
--text: #2f343d;
|
--text: #2f343d;
|
||||||
--muted: #7b8491;
|
--muted: #7b8491;
|
||||||
--selected-day-bg: #3d4552;
|
--selected-day-bg: #3d4552;
|
||||||
|
--day-tile-bg: #eef0f4;
|
||||||
--selected-artifact-bg: #eaf3ff;
|
--selected-artifact-bg: #eaf3ff;
|
||||||
--selected-artifact-rail: #dcecff;
|
--selected-artifact-rail: #dcecff;
|
||||||
--control-border: #d9dee6;
|
--control-border: #d9dee6;
|
||||||
|
|
@ -37,10 +38,73 @@ body {
|
||||||
display: grid;
|
display: grid;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
grid-template-columns: max-content 1fr;
|
grid-template-columns: max-content 1fr max-content;
|
||||||
padding: 0;
|
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 {
|
.brandline {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -48,6 +112,15 @@ body {
|
||||||
padding: 0.38rem 0.8rem;
|
padding: 0.38rem 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-link {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-link:hover {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
|
|
@ -142,14 +215,14 @@ h1 {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-sidebar,
|
.source-sidebar,
|
||||||
.artifact-sidebar,
|
.artifact-sidebar,
|
||||||
.preview-pane {
|
.preview-pane {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-sidebar,
|
.source-sidebar,
|
||||||
.artifact-sidebar {
|
.artifact-sidebar {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|
@ -157,7 +230,7 @@ h1 {
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-sidebar {
|
.source-sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
|
|
@ -170,7 +243,7 @@ h1 {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-list,
|
.source-list,
|
||||||
.artifact-list {
|
.artifact-list {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|
@ -179,34 +252,34 @@ h1 {
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-sidebar:hover .day-list,
|
.source-sidebar:hover .source-list,
|
||||||
.artifact-sidebar:hover .artifact-list {
|
.artifact-sidebar:hover .artifact-list {
|
||||||
scrollbar-color: rgba(61, 69, 82, 0.35) transparent;
|
scrollbar-color: rgba(61, 69, 82, 0.35) transparent;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-list::-webkit-scrollbar,
|
.source-list::-webkit-scrollbar,
|
||||||
.artifact-list::-webkit-scrollbar {
|
.artifact-list::-webkit-scrollbar {
|
||||||
width: 0;
|
width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-sidebar:hover .day-list::-webkit-scrollbar,
|
.source-sidebar:hover .source-list::-webkit-scrollbar,
|
||||||
.artifact-sidebar:hover .artifact-list::-webkit-scrollbar {
|
.artifact-sidebar:hover .artifact-list::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-list::-webkit-scrollbar-thumb,
|
.source-list::-webkit-scrollbar-thumb,
|
||||||
.artifact-list::-webkit-scrollbar-thumb {
|
.artifact-list::-webkit-scrollbar-thumb {
|
||||||
background: rgba(61, 69, 82, 0.35);
|
background: rgba(61, 69, 82, 0.35);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-list::-webkit-scrollbar-track,
|
.source-list::-webkit-scrollbar-track,
|
||||||
.artifact-list::-webkit-scrollbar-track {
|
.artifact-list::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-sidebar {
|
.source-sidebar {
|
||||||
background: var(--surface-alt);
|
background: var(--surface-alt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,7 +323,7 @@ h1 {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-sidebar h2,
|
.source-sidebar h2,
|
||||||
.pane-heading h2 {
|
.pane-heading h2 {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
|
|
@ -259,64 +332,97 @@ h1 {
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-list,
|
.source-list {
|
||||||
.artifact-list {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-list {
|
.source-list {
|
||||||
align-content: start;
|
align-content: start;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
padding: 0.55rem;
|
padding: 0.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-link,
|
.source-link,
|
||||||
.artifact-row {
|
.artifact-row {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-link {
|
.source-link {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--day-tile-bg);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
display: grid;
|
display: flex;
|
||||||
gap: 0.1rem;
|
gap: 0.4rem;
|
||||||
padding: 0.48rem;
|
justify-content: space-between;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-link:hover,
|
.source-link:hover {
|
||||||
.day-link.selected {
|
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-link:focus-visible {
|
.source-link:focus-visible {
|
||||||
outline: 2px solid #8ab4f8;
|
outline: 2px solid #8ab4f8;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-link.selected {
|
.source-link.selected {
|
||||||
background: var(--selected-day-bg);
|
background: var(--selected-day-bg);
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-link.selected span,
|
.source-link.selected .source-name,
|
||||||
.day-link.selected strong {
|
.source-link.selected .source-count {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-link.selected .today-badge {
|
.source-name {
|
||||||
background: #d8f1df;
|
font-size: 0.8rem;
|
||||||
color: #28583a;
|
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,
|
.pane-heading span,
|
||||||
.artifact-time {
|
.artifact-time {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.7rem;
|
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 {
|
.today-badge {
|
||||||
background: #d8f1df;
|
background: #d8f1df;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
|
@ -329,10 +435,6 @@ h1 {
|
||||||
padding: 0.08rem 0.38rem;
|
padding: 0.08rem 0.38rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-link strong {
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pane-bar {
|
.pane-bar {
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
|
|
@ -353,7 +455,7 @@ h1 {
|
||||||
border-bottom: 1px solid var(--grid-line);
|
border-bottom: 1px solid var(--grid-line);
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-sidebar .pane-bar,
|
.source-sidebar .pane-bar,
|
||||||
.artifact-sidebar .pane-bar {
|
.artifact-sidebar .pane-bar {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -365,30 +467,6 @@ h1 {
|
||||||
gap: 0.04rem;
|
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 {
|
.artifact-list {
|
||||||
align-content: start;
|
align-content: start;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|
@ -402,7 +480,7 @@ h1 {
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0;
|
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;
|
min-height: 2.7rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
@ -427,55 +505,30 @@ h1 {
|
||||||
background: var(--selected-artifact-rail);
|
background: var(--selected-artifact-rail);
|
||||||
}
|
}
|
||||||
|
|
||||||
.artifact-row.selected .artifact-row-main span,
|
.artifact-row.selected .artifact-title,
|
||||||
.artifact-row.selected .artifact-time {
|
.artifact-row.selected .artifact-time {
|
||||||
color: var(--text);
|
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 {
|
.artifact-row-main {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-right: 1px solid var(--grid-line);
|
border-right: 1px solid var(--grid-line);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.32rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 0.42rem 0.55rem;
|
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-box-orient: vertical;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
|
@ -484,6 +537,39 @@ h1 {
|
||||||
overflow: hidden;
|
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 {
|
.artifact-time {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: #f3f5f7;
|
background: #f3f5f7;
|
||||||
|
|
@ -646,10 +732,120 @@ kbd {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-sidebar,
|
.source-sidebar,
|
||||||
.artifact-sidebar {
|
.artifact-sidebar {
|
||||||
border-bottom: 1px solid #d9c9ad;
|
border-bottom: 1px solid #d9c9ad;
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
max-height: 32vh;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
34
templates/artifacts/_day_groups.html
Normal file
34
templates/artifacts/_day_groups.html
Normal 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 }}&{% endif %}{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}artifact={{ artifact.id }}&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 %}
|
||||||
28
templates/artifacts/_feed_toolbar.html
Normal file
28
templates/artifacts/_feed_toolbar.html
Normal 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>
|
||||||
37
templates/artifacts/_topbar.html
Normal file
37
templates/artifacts/_topbar.html
Normal 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 }}">
|
||||||
|
⚙ {{ 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>
|
||||||
|
|
@ -7,94 +7,51 @@
|
||||||
<link rel="stylesheet" href="/static/daybook.css">
|
<link rel="stylesheet" href="/static/daybook.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
{% include "artifacts/_topbar.html" with topbar_toolbar="artifacts/_feed_toolbar.html" %}
|
||||||
<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>
|
|
||||||
|
|
||||||
<main class="feed"
|
<main class="feed"
|
||||||
data-selected-day="{{ selected_day|date:'Y-m-d' }}"
|
data-selected-source="{{ selected_source }}"
|
||||||
data-selected-persona="{{ selected_persona }}"
|
data-selected-label="{{ selected_label }}"
|
||||||
data-selected-kind="{{ selected_kind }}"
|
data-selected-kind="{{ selected_kind }}"
|
||||||
data-title-query="{{ title_query }}">
|
data-title-query="{{ title_query }}"
|
||||||
<aside class="day-sidebar" aria-label="Days">
|
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">
|
<div class="pane-bar">
|
||||||
<h2>Days</h2>
|
<h2>Sources</h2>
|
||||||
</div>
|
</div>
|
||||||
<nav class="day-list">
|
<nav class="source-list">
|
||||||
{% for day in days %}
|
<a class="source-link {% if not selected_source %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}view={{ selected_view }}&preview=none">
|
||||||
<a class="day-link {% if day == selected_day %}selected{% endif %}" href="?day={{ day|date:'Y-m-d' }}{% if selected_persona %}&persona={{ selected_persona|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&view={{ selected_view }}&preview=none">
|
<span class="source-name">All sources</span>
|
||||||
<span>{{ day|date:"D" }}{% if day == today %}<em class="today-badge">Today</em>{% endif %}</span>
|
{% if total_count %}<span class="source-count">{{ total_count }}</span>{% endif %}
|
||||||
<strong>{{ day|date:"Y-m-d" }}</strong>
|
</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 %}&label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&view={{ selected_view }}&preview=none">
|
||||||
|
<span class="source-name">{{ entry.source }}</span>
|
||||||
|
{% if entry.count %}<span class="source-count">{{ entry.count }}</span>{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</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">
|
<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>
|
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="new-artifacts-banner" type="button" hidden data-new-artifacts-banner>
|
<div class="artifact-list" data-artifact-list>
|
||||||
<strong data-new-artifacts-count>0</strong> new artifact<span data-new-artifacts-plural>s</span> available
|
{% if day_groups %}
|
||||||
</button>
|
{% include "artifacts/_day_groups.html" with has_more=older_count next_before=window_start|date:"Y-m-d" %}
|
||||||
|
{% else %}
|
||||||
<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 %}&persona={{ selected_persona|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&artifact={{ artifact.id }}&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="empty compact">
|
<div class="empty compact">
|
||||||
<h3>No artifacts</h3>
|
<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>
|
</div>
|
||||||
{% endfor %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|
@ -195,12 +152,11 @@
|
||||||
|
|
||||||
const previewPane = document.querySelector(".preview-pane");
|
const previewPane = document.querySelector(".preview-pane");
|
||||||
const previewBody = document.querySelector(".preview-body");
|
const previewBody = document.querySelector(".preview-body");
|
||||||
const rows = Array.from(document.querySelectorAll(".artifact-row"));
|
const artifactList = document.querySelector("[data-artifact-list]");
|
||||||
const dayLinks = Array.from(document.querySelectorAll(".day-link"));
|
// Rows grow as older pages load, so always read them live rather than caching an array.
|
||||||
const initialArtifactIds = new Set(rows.map((row) => row.dataset.artifactId));
|
const allRows = () => Array.from(document.querySelectorAll(".artifact-row"));
|
||||||
const newArtifactsBanner = document.querySelector("[data-new-artifacts-banner]");
|
const sourceLinks = Array.from(document.querySelectorAll(".source-link"));
|
||||||
const newArtifactsCount = document.querySelector("[data-new-artifacts-count]");
|
const csrfToken = document.querySelector("[name=csrfmiddlewaretoken]")?.value || "";
|
||||||
const newArtifactsPlural = document.querySelector("[data-new-artifacts-plural]");
|
|
||||||
|
|
||||||
const escapeHtml = (value) => value.replace(/[&<>"]/g, (character) => ({
|
const escapeHtml = (value) => value.replace(/[&<>"]/g, (character) => ({
|
||||||
"&": "&",
|
"&": "&",
|
||||||
|
|
@ -222,7 +178,7 @@
|
||||||
}, 1200);
|
}, 1200);
|
||||||
};
|
};
|
||||||
const selectedRow = () => document.querySelector(".artifact-row.selected");
|
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 urlFor = (row, view) => view === "raw" ? row.dataset.rawUrl : row.dataset.renderedUrl;
|
||||||
const pageUrlFor = (row, view) => {
|
const pageUrlFor = (row, view) => {
|
||||||
const url = new URL(row.href, window.location.origin);
|
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) => {
|
const renderPreview = (row, pushState = true) => {
|
||||||
navigationMode = "artifacts";
|
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 source = urlFor(row, selectedView);
|
||||||
const title = escapeHtml(row.dataset.title);
|
const title = escapeHtml(row.dataset.title);
|
||||||
const kind = row.dataset.kind;
|
const kind = row.dataset.kind;
|
||||||
|
|
@ -306,26 +292,28 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearPreview = () => {
|
const clearPreview = () => {
|
||||||
navigationMode = "days";
|
navigationMode = "sources";
|
||||||
if (document.activeElement && document.activeElement.classList.contains("artifact-row")) {
|
if (document.activeElement && document.activeElement.classList.contains("artifact-row")) {
|
||||||
document.activeElement.blur();
|
document.activeElement.blur();
|
||||||
}
|
}
|
||||||
rows.forEach((candidate) => candidate.classList.remove("selected"));
|
allRows().forEach((candidate) => candidate.classList.remove("selected"));
|
||||||
disableActions();
|
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>`;
|
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());
|
window.history.pushState({}, "", noPreviewUrl());
|
||||||
const currentDay = document.querySelector(".day-link.selected");
|
const currentSource = document.querySelector(".source-link.selected");
|
||||||
if (currentDay) currentDay.focus({preventScroll: true});
|
if (currentSource) currentSource.focus({preventScroll: true});
|
||||||
};
|
};
|
||||||
|
|
||||||
rows.forEach((row) => {
|
// Delegated so rows appended by infinite scroll work without rebinding.
|
||||||
row.addEventListener("click", (event) => {
|
artifactList.addEventListener("click", (event) => {
|
||||||
|
const row = event.target.closest(".artifact-row");
|
||||||
|
if (!row) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
renderPreview(row);
|
renderPreview(row);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const moveSelection = (direction) => {
|
const moveSelection = (direction) => {
|
||||||
|
const rows = allRows();
|
||||||
if (!rows.length) return;
|
if (!rows.length) return;
|
||||||
navigationMode = "artifacts";
|
navigationMode = "artifacts";
|
||||||
const current = selectedRow();
|
const current = selectedRow();
|
||||||
|
|
@ -337,14 +325,14 @@
|
||||||
nextRow.scrollIntoView({block: "nearest"});
|
nextRow.scrollIntoView({block: "nearest"});
|
||||||
};
|
};
|
||||||
|
|
||||||
const moveDay = (direction) => {
|
const moveSource = (direction) => {
|
||||||
if (!dayLinks.length) return;
|
if (!sourceLinks.length) return;
|
||||||
navigationMode = "days";
|
navigationMode = "sources";
|
||||||
const current = document.querySelector(".day-link.selected");
|
const current = document.querySelector(".source-link.selected");
|
||||||
const currentIndex = current ? dayLinks.indexOf(current) : 0;
|
const currentIndex = current ? sourceLinks.indexOf(current) : 0;
|
||||||
const nextIndex = Math.max(0, Math.min(dayLinks.length - 1, currentIndex + direction));
|
const nextIndex = Math.max(0, Math.min(sourceLinks.length - 1, currentIndex + direction));
|
||||||
const nextDay = dayLinks[nextIndex];
|
const nextSource = sourceLinks[nextIndex];
|
||||||
if (nextDay && nextDay !== current) window.location.href = nextDay.href;
|
if (nextSource && nextSource !== current) window.location.href = nextSource.href;
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("keydown", (event) => {
|
document.addEventListener("keydown", (event) => {
|
||||||
|
|
@ -353,23 +341,23 @@
|
||||||
|
|
||||||
if (event.altKey && event.key === "ArrowDown") {
|
if (event.altKey && event.key === "ArrowDown") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
moveDay(1);
|
moveSource(1);
|
||||||
} else if (event.altKey && event.key === "ArrowUp") {
|
} else if (event.altKey && event.key === "ArrowUp") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
moveDay(-1);
|
moveSource(-1);
|
||||||
} else if (event.altKey) {
|
} else if (event.altKey) {
|
||||||
return;
|
return;
|
||||||
} else if (event.key === "ArrowDown") {
|
} else if (event.key === "ArrowDown") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (navigationMode === "days") {
|
if (navigationMode === "sources") {
|
||||||
moveDay(1);
|
moveSource(1);
|
||||||
} else {
|
} else {
|
||||||
moveSelection(1);
|
moveSelection(1);
|
||||||
}
|
}
|
||||||
} else if (event.key === "ArrowUp") {
|
} else if (event.key === "ArrowUp") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (navigationMode === "days") {
|
if (navigationMode === "sources") {
|
||||||
moveDay(-1);
|
moveSource(-1);
|
||||||
} else {
|
} else {
|
||||||
moveSelection(-1);
|
moveSelection(-1);
|
||||||
}
|
}
|
||||||
|
|
@ -384,6 +372,7 @@
|
||||||
moveSelection(-5);
|
moveSelection(-5);
|
||||||
}
|
}
|
||||||
} else if (event.key === "ArrowRight") {
|
} else if (event.key === "ArrowRight") {
|
||||||
|
const rows = allRows();
|
||||||
if (!selectedRow() && rows.length) {
|
if (!selectedRow() && rows.length) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
renderPreview(rows[0]);
|
renderPreview(rows[0]);
|
||||||
|
|
@ -397,46 +386,59 @@
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
clearPreview();
|
clearPreview();
|
||||||
} else {
|
} else {
|
||||||
navigationMode = "days";
|
navigationMode = "sources";
|
||||||
const currentDay = document.querySelector(".day-link.selected");
|
const currentSource = document.querySelector(".source-link.selected");
|
||||||
if (currentDay) currentDay.focus({preventScroll: true});
|
if (currentSource) currentSource.focus({preventScroll: true});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const artifactListUrl = () => {
|
// Infinite scroll: when the sentinel at the end of the list scrolls into
|
||||||
const url = new URL("/api/artifacts", window.location.origin);
|
// 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 url = new URL("{% url 'feed-older' %}", window.location.origin);
|
||||||
const params = url.searchParams;
|
const params = url.searchParams;
|
||||||
if (feed.dataset.selectedDay) params.set("day", feed.dataset.selectedDay);
|
params.set("before", sentinel.dataset.nextBefore);
|
||||||
if (feed.dataset.selectedPersona) params.set("persona", feed.dataset.selectedPersona);
|
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.selectedKind) params.set("kind", feed.dataset.selectedKind);
|
||||||
if (feed.dataset.titleQuery) params.set("q", feed.dataset.titleQuery);
|
if (feed.dataset.titleQuery) params.set("q", feed.dataset.titleQuery);
|
||||||
return url;
|
const response = await fetch(url, {headers: {"X-Requested-With": "fetch"}});
|
||||||
};
|
|
||||||
|
|
||||||
const showNewArtifactsBanner = (count) => {
|
|
||||||
if (!newArtifactsBanner || count < 1) return;
|
|
||||||
newArtifactsCount.textContent = String(count);
|
|
||||||
newArtifactsPlural.hidden = count === 1;
|
|
||||||
newArtifactsBanner.hidden = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkForNewArtifacts = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(artifactListUrl(), {headers: {Accept: "application/json"}});
|
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
const artifacts = await response.json();
|
const markup = (await response.text()).trim();
|
||||||
const unseenCount = artifacts.filter((artifact) => !initialArtifactIds.has(artifact.id)).length;
|
sentinel.remove();
|
||||||
showNewArtifactsBanner(unseenCount);
|
if (markup) {
|
||||||
|
artifactList.insertAdjacentHTML("beforeend", markup);
|
||||||
|
observeSentinel();
|
||||||
|
}
|
||||||
} catch {
|
} 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) {
|
const olderObserver = new IntersectionObserver((entries) => {
|
||||||
newArtifactsBanner.addEventListener("click", () => window.location.reload());
|
entries.forEach((entry) => {
|
||||||
window.setInterval(checkForNewArtifacts, 5000);
|
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) => {
|
previewPane.addEventListener("click", (event) => {
|
||||||
const copyButton = event.target.closest("[data-copy-url]");
|
const copyButton = event.target.closest("[data-copy-url]");
|
||||||
|
|
|
||||||
34
templates/artifacts/login.html
Normal file
34
templates/artifacts/login.html
Normal 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>
|
||||||
15
templates/artifacts/settings_account.html
Normal file
15
templates/artifacts/settings_account.html
Normal 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 <token></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 %}
|
||||||
28
templates/artifacts/settings_base.html
Normal file
28
templates/artifacts/settings_base.html
Normal 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' %}">← 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>
|
||||||
10
templates/artifacts/settings_demo_data.html
Normal file
10
templates/artifacts/settings_demo_data.html
Normal 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 %}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue