From ba9f5eb168e330e320eeca07cbff26336b869ee4 Mon Sep 17 00:00:00 2001 From: peter Date: Thu, 11 Jun 2026 14:01:16 +0100 Subject: [PATCH] Refine AgentFeed UI and local workflow --- .dockerignore | 19 + AGENTS.md | 19 +- _archive_/hosts/README.md | 28 ++ _archive_/hosts/cloud-template.md | 85 ++++ _archive_/hosts/nuc-daybook.md | 145 +++++++ artifacts/admin.py | 6 +- ...ct_metadata_artifact_source_id_and_more.py | 41 ++ artifacts/models.py | 14 + artifacts/urls.py | 2 + artifacts/views.py | 135 ++++++- daybook_project/settings.py | 21 +- deploy/Dockerfile | 24 ++ deploy/docker-compose.yml | 19 + deploy/entrypoint.sh | 9 + deploy/env.example | 7 + deploy/proxmox_llm_deploy.md | 348 ++++++++++++++++ docs/agent_quickstart.md | 126 ++++++ docs/api.md | 332 +++++++++++++++ docs/llm_posting_helper.md | 108 +++++ planning/daybook-issues-log.md | 163 ++++++++ planning/framework_artifact_rendering.md | 275 +++++++++++++ planning/next_steps/llm-posting-skill-plan.md | 260 ++++++++++++ requirements.txt | 2 + scripts/README.md | 26 ++ scripts/daybook_post.py | 16 + skills/daybook-posting/SKILL.md | 54 +++ .../daybook-posting/scripts/daybook_post.py | 378 ++++++++++++++++++ static/daybook.css | 217 ++++++++-- templates/artifacts/feed.html | 176 +++++++- 29 files changed, 2986 insertions(+), 69 deletions(-) create mode 100644 .dockerignore create mode 100644 _archive_/hosts/README.md create mode 100644 _archive_/hosts/cloud-template.md create mode 100644 _archive_/hosts/nuc-daybook.md create mode 100644 artifacts/migrations/0002_artifact_metadata_artifact_source_id_and_more.py create mode 100644 deploy/Dockerfile create mode 100644 deploy/docker-compose.yml create mode 100644 deploy/entrypoint.sh create mode 100644 deploy/env.example create mode 100644 deploy/proxmox_llm_deploy.md create mode 100644 docs/agent_quickstart.md create mode 100644 docs/api.md create mode 100644 docs/llm_posting_helper.md create mode 100644 planning/daybook-issues-log.md create mode 100644 planning/framework_artifact_rendering.md create mode 100644 planning/next_steps/llm-posting-skill-plan.md create mode 100644 scripts/README.md create mode 100644 scripts/daybook_post.py create mode 100644 skills/daybook-posting/SKILL.md create mode 100644 skills/daybook-posting/scripts/daybook_post.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3e6ecaf --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git/ +.idea/ +.venv/ +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.pytest_cache/ +.coverage +htmlcov/ +staticfiles/ +db.sqlite3 +artifacts_store/ +django-runserver.log +django-runserver.err.log +.env +.env.* +deploy/.env +deploy/data/ diff --git a/AGENTS.md b/AGENTS.md index fdc260b..f3a95e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,11 +33,28 @@ The core product principle is: agents publish content, not files. The server own - Preserve existing planning decisions unless the user explicitly changes them. - Avoid adding backwards-compatibility code before anything has shipped. +## Development And Deployment Workflow + +- Develop and test locally in this workspace by default. +- Use the local Django commands for normal iteration: `python manage.py check`, `python manage.py migrate`, and `python manage.py runserver`. +- Run the local development server on `127.0.0.1:8001`. Port `8000` may be reserved or blocked on this workstation. +- Do not deploy to the Proxmox NUC during routine code iteration. +- Treat the NUC container as the local live/staging target. Deploy there only at explicit stable checkpoints, such as an end-of-day build. +- Before any NUC deployment, preserve `/opt/daybook/deploy/.env` and `/opt/daybook/deploy/data`. +- After any NUC deployment or configuration change, run the documented health checks and update the NUC ops knowledge base plus `planning/daybook-container-handoff.md` if takeover facts changed. + +## Repository Notes + +- `_archive_/` exists for retired or moved project docs and experiments that should not drive current implementation decisions. +- Current deployment/takeover details live in `planning/daybook-container-handoff.md`; canonical NUC infrastructure details live in the NUC ops repo. + ## Commands - Install dependencies: `pip install -r requirements.txt` - Run migrations: `python manage.py migrate` -- Start dev server: `python manage.py runserver` +- Start dev server: `python manage.py runserver 127.0.0.1:8001` +- Local app URL: `http://127.0.0.1:8001/` +- Local API health check: `http://127.0.0.1:8001/api/capabilities` - Django checks: `python manage.py check` ## API Defaults diff --git a/_archive_/hosts/README.md b/_archive_/hosts/README.md new file mode 100644 index 0000000..096f346 --- /dev/null +++ b/_archive_/hosts/README.md @@ -0,0 +1,28 @@ +# Daybook Hosts + +This directory tracks Daybook deployment targets for fast iteration. + +These notes are intentionally app-specific. They should answer: where is Daybook running, how do we update it, how do we verify it, and where does persistent Daybook data live? + +They are not a replacement for host administration docs. For the Proxmox NUC, the canonical infrastructure source remains the NUC ops repo: + +```text +C:\Users\ritte\_PR_DEV_\LIVE_ADMIN\NUC_SERVER +``` + +## Deployment Targets + +| Host | Status | Notes | +|---|---|---| +| [NUC Daybook](nuc-daybook.md) | Live | Current local/LAN deployment in Proxmox LXC `128` | +| [Cloud template](cloud-template.md) | Planned | Fill this in when a hosted/cloud instance is created | + +## Documentation Rule + +When a Daybook deployment changes, update the matching file here and the broader handoff doc if takeover facts changed: + +```text +planning/daybook-container-handoff.md +``` + +When the NUC host/container configuration changes, also update the NUC ops knowledge base. diff --git a/_archive_/hosts/cloud-template.md b/_archive_/hosts/cloud-template.md new file mode 100644 index 0000000..552427b --- /dev/null +++ b/_archive_/hosts/cloud-template.md @@ -0,0 +1,85 @@ +# Cloud Daybook Host Template + +Use this when creating a hosted/cloud Daybook deployment. + +Status: planned. + +## Summary + +| Item | Value | +|---|---| +| Provider | TBD | +| Hostname | TBD | +| Public URL | TBD | +| SSH | TBD | +| OS | TBD | +| App user | TBD | +| Deployment path | TBD | +| Persistent data path | TBD | +| API base URL | TBD | +| TLS | TBD | +| Backup location | TBD | + +## Target Runtime + +Preferred simple runtime for the first cloud deployment: + +```text +Django + Gunicorn in Docker Compose +``` + +Required persistent state: + +```text +deploy/data/db.sqlite3 +deploy/data/artifacts_store +``` + +Do not bake persistent data into the image. + +## Environment + +Expected production environment values: + +```text +DJANGO_DEBUG=0 +DJANGO_ALLOWED_HOSTS= +DAYBOOK_API_TOKEN= +DAYBOOK_DATA_DIR=/data +DATABASE_PATH=/data/db.sqlite3 +ARTIFACTS_ROOT=/data/artifacts_store +``` + +## Deployment Flow + +Initial deployment checklist: + +1. Provision host. +2. Install Docker and Docker Compose plugin. +3. Copy or clone Daybook source. +4. Create production `.env`. +5. Create persistent `deploy/data` directory. +6. Start with `docker compose -f deploy/docker-compose.yml up -d --build`. +7. Verify `/api/capabilities`. +8. Publish a smoke artifact. +9. Configure backups for `deploy/data`. + +## Health Checks + +```bash +curl -fsS /api/capabilities +docker compose -f deploy/docker-compose.yml ps +test -f deploy/data/db.sqlite3 +test -d deploy/data/artifacts_store +``` + +## Open Decisions + +| Decision | Options | Choice | +|---|---|---| +| Hosting provider | VPS, home tunnel, managed container host | TBD | +| Domain | Existing domain, subdomain, direct IP | TBD | +| TLS | Reverse proxy, provider load balancer, Tailscale-only | TBD | +| Backups | Host cron, provider snapshots, object storage archive | TBD | +| Database | SQLite for MVP, Postgres later | TBD | +| Reader auth | None for MVP, basic auth, app auth | TBD | diff --git a/_archive_/hosts/nuc-daybook.md b/_archive_/hosts/nuc-daybook.md new file mode 100644 index 0000000..ca14406 --- /dev/null +++ b/_archive_/hosts/nuc-daybook.md @@ -0,0 +1,145 @@ +# NUC Daybook Host + +Current Daybook deployment target for fast local iteration. + +Last checked: 2026-06-11. + +## Summary + +| Item | Value | +|---|---| +| Proxmox host | `pve`, Intel NUC5i3MYHE, Proxmox VE 9.1.0 / Debian 13 | +| Host LAN IP | `192.168.1.6` | +| Host Tailscale IP | `100.76.55.68` | +| Host SSH | `claude@proxmox.cygnus.internal` or `claude@nuc.cygnus.internal` | +| Container | LXC VMID `128`, hostname `daybook` | +| Container IP | `192.168.1.36/24` | +| App URL | `http://192.168.1.36:8000/` | +| API base URL | `http://192.168.1.36:8000` | +| Deployment path | `/opt/daybook` | +| Persistent data path | `/opt/daybook/deploy/data` | +| Local source tree | `C:\Users\ritte\_PR_DEV_\DEV_WEB_APPS\AGENT_DAYBOOK` | +| NUC ops repo | `C:\Users\ritte\_PR_DEV_\LIVE_ADMIN\NUC_SERVER` | + +Write requests use the Daybook bearer token configured in `/opt/daybook/deploy/.env` as `DAYBOOK_API_TOKEN`. + +## Runtime Shape + +Daybook runs in Docker Compose inside unprivileged Debian LXC `128` with `nesting=1` enabled. + +Compose root: + +```text +/opt/daybook +``` + +Compose file: + +```text +/opt/daybook/deploy/docker-compose.yml +``` + +Persistent state must stay outside the image: + +```text +/opt/daybook/deploy/data +``` + +That directory contains the SQLite database and stored artifacts. + +## Fast Update Loop + +From the Proxmox host, rebuild the running Daybook service inside CT `128`: + +```bash +sudo pct exec 128 -- bash -lc 'cd /opt/daybook && docker compose -f deploy/docker-compose.yml up -d --build' +``` + +Before copying source or rebuilding, preserve: + +```text +/opt/daybook/deploy/.env +/opt/daybook/deploy/data +``` + +The current deployment was copied from the local source tree rather than cloned from a remote Git repository. + +## Health Checks + +From a machine that can reach the LAN: + +```bash +curl -fsS http://192.168.1.36:8000/api/capabilities +``` + +From the Proxmox host: + +```bash +sudo pct status 128 +sudo pct config 128 +sudo pct exec 128 -- bash -lc 'cd /opt/daybook && docker compose -f deploy/docker-compose.yml ps' +sudo pct exec 128 -- bash -lc 'test -f /opt/daybook/deploy/data/db.sqlite3 && test -d /opt/daybook/deploy/data/artifacts_store' +``` + +Storage check: + +```bash +sudo /sbin/lvs -o vg_name,lv_name,lv_size,data_percent pve/data pve2/data +``` + +Important: `local-lvm` / `pve/data` was near the alert threshold at last check. New larger VM/CT disks should normally go on `local-lvm2`. Daybook already uses `local-lvm2`. + +## Smoke Publish + +Use this after ingest, rendering, or deployment changes. Replace the token with the value from `/opt/daybook/deploy/.env` if it changes. + +```bash +curl -fsS -X POST http://192.168.1.36:8000/api/artifacts \ + -H "Authorization: Bearer $DAYBOOK_API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "persona": "deploy-smoke", + "source_type": "agent", + "source_name": "Deployment Smoke Agent", + "kind": "markdown", + "theme": "report", + "title": "Deployment Smoke Test", + "content": "# Deployment Smoke Test\n\nDaybook accepted an artifact.", + "tags": ["deploy", "smoke"], + "metadata": {"run_id": "deploy-smoke-001"} + }' +``` + +The response should include `id`, `url`, `api_url`, and `raw_url`. Open the returned `url` or refresh the feed. + +## Backup Target + +Back up this directory: + +```text +/opt/daybook/deploy/data +``` + +Quick host-side backup command: + +```bash +sudo pct exec 128 -- bash -lc 'tar -czf /tmp/daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data' +``` + +For heavy write activity, stop the Compose service before archiving and start it again afterward. + +## Related Docs + +Broader deployment handoff: + +```text +planning/daybook-container-handoff.md +``` + +NUC infrastructure docs: + +```text +C:\Users\ritte\_PR_DEV_\LIVE_ADMIN\NUC_SERVER\knowledge_base\containers\128-daybook.md +C:\Users\ritte\_PR_DEV_\LIVE_ADMIN\NUC_SERVER\knowledge_base\daybook\api.md +C:\Users\ritte\_PR_DEV_\LIVE_ADMIN\NUC_SERVER\knowledge_base\daybook\agent-posting.md +``` diff --git a/artifacts/admin.py b/artifacts/admin.py index 1fcc62f..8a78343 100644 --- a/artifacts/admin.py +++ b/artifacts/admin.py @@ -5,6 +5,6 @@ from .models import Artifact @admin.register(Artifact) class ArtifactAdmin(admin.ModelAdmin): - list_display = ("id", "title", "persona", "kind", "day", "created_at") - list_filter = ("persona", "kind", "day") - search_fields = ("id", "title", "persona") + list_display = ("id", "title", "persona", "source_type", "source_name", "kind", "day", "created_at") + list_filter = ("persona", "source_type", "kind", "day") + search_fields = ("id", "title", "persona", "source_name", "source_id") diff --git a/artifacts/migrations/0002_artifact_metadata_artifact_source_id_and_more.py b/artifacts/migrations/0002_artifact_metadata_artifact_source_id_and_more.py new file mode 100644 index 0000000..5494a97 --- /dev/null +++ b/artifacts/migrations/0002_artifact_metadata_artifact_source_id_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 5.2.15 on 2026-06-10 15:14 + +from django.db import migrations, models + + +def fill_existing_source_names(apps, schema_editor): + Artifact = apps.get_model('artifacts', 'Artifact') + for artifact in Artifact.objects.filter(source_name=''): + artifact.source_name = artifact.persona + artifact.save(update_fields=['source_name']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('artifacts', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='artifact', + name='metadata', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AddField( + model_name='artifact', + name='source_id', + field=models.CharField(blank=True, max_length=120), + ), + migrations.AddField( + model_name='artifact', + name='source_name', + field=models.CharField(blank=True, max_length=120), + ), + migrations.AddField( + model_name='artifact', + name='source_type', + field=models.CharField(choices=[('agent', 'Agent'), ('person', 'Person'), ('system', 'System')], default='agent', max_length=20), + ), + migrations.RunPython(fill_existing_source_names, migrations.RunPython.noop), + ] diff --git a/artifacts/models.py b/artifacts/models.py index 249f728..59278b9 100644 --- a/artifacts/models.py +++ b/artifacts/models.py @@ -10,6 +10,10 @@ class Artifact(models.Model): PDF = "pdf" BINARY = "binary" + AGENT = "agent" + PERSON = "person" + SYSTEM = "system" + KIND_CHOICES = [ (HTML_FRAGMENT, "HTML fragment"), (HTML_FULL, "Full HTML"), @@ -20,6 +24,12 @@ class Artifact(models.Model): (BINARY, "Binary"), ] + SOURCE_TYPE_CHOICES = [ + (AGENT, "Agent"), + (PERSON, "Person"), + (SYSTEM, "System"), + ] + id = models.CharField(max_length=8, primary_key=True) persona = models.CharField(max_length=80, db_index=True) title = models.CharField(max_length=200) @@ -31,6 +41,10 @@ class Artifact(models.Model): original_kind = models.CharField(max_length=20) tags = models.JSONField(default=list, blank=True) source_prompt = models.TextField(blank=True) + source_type = models.CharField(max_length=20, choices=SOURCE_TYPE_CHOICES, default=AGENT) + source_name = models.CharField(max_length=120, blank=True) + source_id = models.CharField(max_length=120, blank=True) + metadata = models.JSONField(default=dict, blank=True) class Meta: ordering = ["-created_at"] diff --git a/artifacts/urls.py b/artifacts/urls.py index 4215301..fc92a8f 100644 --- a/artifacts/urls.py +++ b/artifacts/urls.py @@ -6,5 +6,7 @@ from . import views urlpatterns = [ path("", views.feed, name="feed"), path("a/", views.artifact_view, name="artifact"), + path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"), path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"), + path("api/artifacts/", views.ArtifactDetailView.as_view(), name="artifact-detail-api"), ] diff --git a/artifacts/views.py b/artifacts/views.py index 5a33a9d..93f45c8 100644 --- a/artifacts/views.py +++ b/artifacts/views.py @@ -1,4 +1,5 @@ import html +import json import mimetypes import secrets from pathlib import Path @@ -39,6 +40,10 @@ class ArtifactListCreateView(APIView): artifacts = artifacts.filter(day=day) if tag := request.query_params.get("tag"): artifacts = artifacts.filter(tags__contains=[tag]) + if kind := request.query_params.get("kind"): + artifacts = artifacts.filter(kind=kind) + if title_query := request.query_params.get("q"): + artifacts = artifacts.filter(title__icontains=title_query) return Response([serialize_artifact(artifact, request) for artifact in artifacts]) @@ -50,7 +55,7 @@ class ArtifactListCreateView(APIView): artifact = create_binary_artifact(request.data) if isinstance(artifact, Response): return artifact - return Response({"id": artifact.id, "url": reverse("artifact", args=[artifact.id])}, status=201) + return Response(serialize_artifact(artifact, request), status=201) data = request.data kind = data.get("kind") @@ -62,16 +67,81 @@ class ArtifactListCreateView(APIView): if missing: return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400) + source_error = validate_source_fields(data) + if source_error: + return source_error + artifact = create_text_artifact(data, request) - return Response({"id": artifact.id, "url": reverse("artifact", args=[artifact.id])}, status=201) + return Response(serialize_artifact(artifact, request), status=201) + + +class ArtifactDetailView(APIView): + def get(self, request, artifact_id): + artifact = get_object_or_404(Artifact, pk=artifact_id) + return Response(serialize_artifact(artifact, request)) + + def patch(self, request, artifact_id): + if not has_valid_token(request): + return HttpResponseForbidden("Invalid or missing API token") + + artifact = get_object_or_404(Artifact, pk=artifact_id) + data = request.data + if "title" in data: + if not data["title"]: + return Response({"detail": "title cannot be blank"}, status=400) + artifact.title = data["title"] + if "tags" in data: + artifact.tags = normalize_tags(data["tags"]) + if "source_prompt" in data: + artifact.source_prompt = data["source_prompt"] or "" + if "source_type" in data: + if data["source_type"] not in {choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES}: + return Response({"detail": "source_type must be agent, person, or system"}, status=400) + artifact.source_type = data["source_type"] + if "source_name" in data: + artifact.source_name = data["source_name"] or "" + if "source_id" in data: + artifact.source_id = data["source_id"] or "" + if "metadata" in data: + if not isinstance(data["metadata"], dict): + return Response({"detail": "metadata must be an object"}, status=400) + artifact.metadata = data["metadata"] + artifact.save( + update_fields=[ + "title", + "tags", + "source_prompt", + "source_type", + "source_name", + "source_id", + "metadata", + ] + ) + return Response(serialize_artifact(artifact, request)) + + +class CapabilitiesView(APIView): + def get(self, request): + return Response( + { + "artifact_kinds": [choice[0] for choice in Artifact.KIND_CHOICES], + "filters": ["persona", "day", "tag", "kind", "q"], + "views": ["rendered", "raw"], + "raw_supported_for": [Artifact.MARKDOWN, Artifact.HTML_FULL], + "source_types": [choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES], + "auth": "bearer_token", + } + ) def feed(request): persona = request.GET.get("persona", "") selected_kind = request.GET.get("kind", "") title_query = request.GET.get("q", "") - selected_day = parse_date(request.GET.get("day", "")) or timezone.localdate() + today = timezone.localdate() + selected_day = parse_date(request.GET.get("day", "")) or today selected_artifact_id = request.GET.get("artifact", "") + preview_none = request.GET.get("preview") == "none" selected_view = "raw" if request.GET.get("view") == "raw" else "rendered" all_artifacts = Artifact.objects.all() artifacts = all_artifacts @@ -83,12 +153,13 @@ def feed(request): artifacts = artifacts.filter(title__icontains=title_query) days = list(artifacts.dates("day", "day", order="DESC")) - if selected_day not in days: - days.append(selected_day) - days.sort(reverse=True) + for day in (today, selected_day): + if day not in days: + days.append(day) + days.sort(reverse=True) day_artifacts = artifacts.filter(day=selected_day).order_by("-created_at") - selected_artifact = day_artifacts.filter(pk=selected_artifact_id).first() + selected_artifact = None if preview_none else day_artifacts.filter(pk=selected_artifact_id).first() toggle_query = {"day": selected_day.isoformat(), "view": "rendered"} if persona: toggle_query["persona"] = persona @@ -116,7 +187,8 @@ def feed(request): "selected_kind": selected_kind, "selected_persona": persona, "selected_view": selected_view, - "today": timezone.localdate(), + "preview_none": preview_none, + "today": today, "title_query": title_query, "kind_choices": Artifact.KIND_CHOICES, }, @@ -177,6 +249,7 @@ def create_text_artifact(data, request): original_kind=kind, tags=normalize_tags(data.get("tags", [])), source_prompt=data.get("source_prompt", ""), + **source_fields(data, persona), ) @@ -188,6 +261,9 @@ def create_binary_artifact(data): missing.append("file") if missing: return Response({"detail": f"Missing required field(s): {', '.join(missing)}"}, status=400) + source_error = validate_source_fields(data) + if source_error: + return source_error now = timezone.localtime() artifact_id = generate_id() @@ -217,6 +293,7 @@ def create_binary_artifact(data): original_kind=kind, tags=normalize_tags(data.get("tags", [])), source_prompt=data.get("source_prompt", ""), + **source_fields(data, persona), ) @@ -275,6 +352,41 @@ def normalize_tags(tags): return [] +def validate_source_fields(data): + if source_type := data.get("source_type"): + if source_type not in {choice[0] for choice in Artifact.SOURCE_TYPE_CHOICES}: + return Response({"detail": "source_type must be agent, person, or system"}, status=400) + try: + parse_metadata_value(data.get("metadata", {})) + except ValueError as exc: + return Response({"detail": str(exc)}, status=400) + return None + + +def source_fields(data, persona): + return { + "source_type": data.get("source_type") or Artifact.AGENT, + "source_name": data.get("source_name") or persona, + "source_id": data.get("source_id") or "", + "metadata": parse_metadata_value(data.get("metadata", {})), + } + + +def parse_metadata_value(value): + if value in (None, ""): + return {} + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + parsed = json.loads(value) + except json.JSONDecodeError as exc: + raise ValueError("metadata must be an object") from exc + if isinstance(parsed, dict): + return parsed + raise ValueError("metadata must be an object") + + def binary_kind(content_type): if content_type.startswith("image/"): return Artifact.IMAGE @@ -294,6 +406,9 @@ def serialize_artifact(artifact, request): return { "id": artifact.id, "url": request.build_absolute_uri(reverse("artifact", args=[artifact.id])), + "api_url": request.build_absolute_uri(reverse("artifact-detail-api", args=[artifact.id])), + "raw_url": request.build_absolute_uri(f"{reverse('artifact', args=[artifact.id])}?raw=1"), + "raw_available": raw_artifact_path(artifact) is not None, "persona": artifact.persona, "title": artifact.title, "kind": artifact.kind, @@ -302,6 +417,10 @@ def serialize_artifact(artifact, request): "created_at": artifact.created_at.isoformat(), "tags": artifact.tags, "source_prompt": artifact.source_prompt, + "source_type": artifact.source_type, + "source_name": artifact.source_name, + "source_id": artifact.source_id, + "metadata": artifact.metadata, } diff --git a/daybook_project/settings.py b/daybook_project/settings.py index 805cbf7..f68938b 100644 --- a/daybook_project/settings.py +++ b/daybook_project/settings.py @@ -1,11 +1,13 @@ +import os from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent +DATA_DIR = Path(os.environ.get("DAYBOOK_DATA_DIR", BASE_DIR)).resolve() -SECRET_KEY = "dev-only-change-me" -DEBUG = True -ALLOWED_HOSTS = ["*"] +SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "dev-only-change-me") +DEBUG = os.environ.get("DJANGO_DEBUG", "1").lower() in {"1", "true", "yes", "on"} +ALLOWED_HOSTS = [host.strip() for host in os.environ.get("DJANGO_ALLOWED_HOSTS", "*").split(",") if host.strip()] INSTALLED_APPS = [ "django.contrib.admin", @@ -20,6 +22,7 @@ INSTALLED_APPS = [ MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -50,7 +53,7 @@ WSGI_APPLICATION = "daybook_project.wsgi.application" DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", + "NAME": os.environ.get("DATABASE_PATH", DATA_DIR / "db.sqlite3"), } } @@ -63,8 +66,14 @@ USE_TZ = True STATIC_URL = "static/" STATICFILES_DIRS = [BASE_DIR / "static"] +STATIC_ROOT = BASE_DIR / "staticfiles" +STORAGES = { + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -ARTIFACTS_ROOT = BASE_DIR / "artifacts_store" -DAYBOOK_API_TOKEN = "dev-token" +ARTIFACTS_ROOT = Path(os.environ.get("ARTIFACTS_ROOT", DATA_DIR / "artifacts_store")) +DAYBOOK_API_TOKEN = os.environ.get("DAYBOOK_API_TOKEN", "dev-token") diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..38e2602 --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt /app/requirements.txt +RUN pip install --upgrade pip \ + && pip install -r /app/requirements.txt + +COPY . /app +RUN sed -i 's/\r$//' /app/deploy/entrypoint.sh \ + && chmod +x /app/deploy/entrypoint.sh + +EXPOSE 8000 + +ENTRYPOINT ["/app/deploy/entrypoint.sh"] +CMD ["gunicorn", "daybook_project.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "2", "--timeout", "120"] diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..ee1dd7a --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,19 @@ +services: + daybook: + build: + context: .. + dockerfile: deploy/Dockerfile + container_name: daybook + restart: unless-stopped + env_file: + - .env + ports: + - "8000:8000" + volumes: + - ./data:/data + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/api/capabilities"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh new file mode 100644 index 0000000..cdd620d --- /dev/null +++ b/deploy/entrypoint.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env sh +set -eu + +mkdir -p "${DAYBOOK_DATA_DIR:-/data}" "${ARTIFACTS_ROOT:-/data/artifacts_store}" + +python manage.py migrate --noinput +python manage.py collectstatic --noinput + +exec "$@" diff --git a/deploy/env.example b/deploy/env.example new file mode 100644 index 0000000..e83e248 --- /dev/null +++ b/deploy/env.example @@ -0,0 +1,7 @@ +DJANGO_DEBUG=0 +DJANGO_SECRET_KEY=replace-with-a-long-random-secret +DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,your-proxmox-container-ip,your-domain.example +DAYBOOK_API_TOKEN=replace-with-a-long-random-token +DAYBOOK_DATA_DIR=/data +DATABASE_PATH=/data/db.sqlite3 +ARTIFACTS_ROOT=/data/artifacts_store diff --git a/deploy/proxmox_llm_deploy.md b/deploy/proxmox_llm_deploy.md new file mode 100644 index 0000000..2d01b1d --- /dev/null +++ b/deploy/proxmox_llm_deploy.md @@ -0,0 +1,348 @@ +# Proxmox Container Deployment Runbook For LLM Operators + +This document is written for an LLM or automation agent that is deploying Daybook into a Linux container on a Proxmox host. + +The recommended deployment target is a Proxmox LXC or VM running Debian/Ubuntu with Docker and Docker Compose. Daybook itself runs as a Docker container inside that guest. + +## Deployment Model + +```text +Proxmox host + Debian/Ubuntu LXC or VM + Docker Engine + daybook container + /app application code baked into image + /data persistent mounted volume +``` + +Persistent runtime data lives outside the image in `deploy/data` on the guest: + +```text +deploy/data/ + db.sqlite3 + artifacts_store/ +``` + +Do not store runtime data inside the image. + +## Files In This Directory + +| File | Purpose | +|---|---| +| `Dockerfile` | Builds the Daybook image. | +| `docker-compose.yml` | Runs Daybook with a persistent `./data:/data` bind mount. | +| `entrypoint.sh` | Runs migrations, collects static files, then starts Gunicorn. | +| `env.example` | Template for deployment environment variables. | +| `proxmox_llm_deploy.md` | This runbook. | + +## Prerequisites On The Proxmox Guest + +The guest OS should have: + +- Docker Engine +- Docker Compose plugin +- Git +- Network access to pull Python base images and Python packages + +For Debian/Ubuntu guests, install basics: + +```bash +sudo apt-get update +sudo apt-get install -y ca-certificates curl git +``` + +Install Docker using Docker's current official instructions for the guest OS. Then verify: + +```bash +docker --version +docker compose version +``` + +## Clone Or Copy The Project + +Choose a stable application path on the guest: + +```bash +sudo mkdir -p /opt/daybook +sudo chown "$USER:$USER" /opt/daybook +``` + +If using Git: + +```bash +git clone /opt/daybook +cd /opt/daybook +``` + +If files were copied by another mechanism, ensure the project root contains: + +```text +manage.py +requirements.txt +deploy/docker-compose.yml +deploy/Dockerfile +``` + +## Create The Environment File + +From the project root: + +```bash +cd /opt/daybook +cp deploy/env.example deploy/.env +``` + +Generate secrets: + +```bash +python3 - <<'PY' +import secrets +print('DJANGO_SECRET_KEY=' + secrets.token_urlsafe(50)) +print('DAYBOOK_API_TOKEN=' + secrets.token_urlsafe(32)) +PY +``` + +Edit `deploy/.env`: + +```bash +nano deploy/.env +``` + +Set at minimum: + +```text +DJANGO_DEBUG=0 +DJANGO_SECRET_KEY= +DJANGO_ALLOWED_HOSTS=,,localhost,127.0.0.1 +DAYBOOK_API_TOKEN= +DAYBOOK_DATA_DIR=/data +DATABASE_PATH=/data/db.sqlite3 +ARTIFACTS_ROOT=/data/artifacts_store +``` + +Important: + +- Do not commit `deploy/.env`. +- Do not use the development token `dev-token` for a real deployment. +- Include the actual IP or DNS name users will use in `DJANGO_ALLOWED_HOSTS`. + +## Create Persistent Data Directory + +From the project root: + +```bash +mkdir -p deploy/data +``` + +The compose file mounts this path into the container as `/data`. + +## Build And Start + +From the project root: + +```bash +docker compose -f deploy/docker-compose.yml up -d --build +``` + +Watch logs: + +```bash +docker compose -f deploy/docker-compose.yml logs -f daybook +``` + +Expected startup behavior: + +- Django migrations run. +- Static files are collected. +- Gunicorn starts on port `8000` inside the container. +- Host port `8000` maps to container port `8000`. + +## Verify Deployment + +Check container state: + +```bash +docker compose -f deploy/docker-compose.yml ps +``` + +Check capabilities endpoint from the guest: + +```bash +curl -fsS http://127.0.0.1:8000/api/capabilities +``` + +Expected JSON includes: + +```json +{ + "artifact_kinds": ["html_fragment", "html_full", "markdown", "deck", "image", "pdf", "binary"], + "auth": "bearer_token" +} +``` + +Open the app from another machine: + +```text +http://:8000/ +``` + +If this fails: + +- Confirm the Proxmox guest firewall allows TCP `8000`. +- Confirm any Proxmox firewall rules allow TCP `8000`. +- Confirm `DJANGO_ALLOWED_HOSTS` includes the hostname/IP being used. + +## Publish A Smoke-Test Artifact + +Replace `` with `DAYBOOK_API_TOKEN` from `deploy/.env`. + +```bash +curl -fsS -X POST http://127.0.0.1:8000/api/artifacts \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "persona": "deploy-smoke", + "source_type": "agent", + "source_name": "Deployment Smoke Agent", + "kind": "markdown", + "title": "Deployment Smoke Test", + "content": "# Deployment Smoke Test\n\nDaybook accepted an artifact.", + "tags": ["deploy", "smoke"], + "metadata": {"run_id": "deploy-smoke-001"} + }' +``` + +Then refresh: + +```text +http://:8000/ +``` + +## Operational Commands + +Stop: + +```bash +docker compose -f deploy/docker-compose.yml down +``` + +Start: + +```bash +docker compose -f deploy/docker-compose.yml up -d +``` + +Rebuild after code changes: + +```bash +docker compose -f deploy/docker-compose.yml up -d --build +``` + +View logs: + +```bash +docker compose -f deploy/docker-compose.yml logs -f daybook +``` + +Run Django management command: + +```bash +docker compose -f deploy/docker-compose.yml exec daybook python manage.py check +``` + +Open shell: + +```bash +docker compose -f deploy/docker-compose.yml exec daybook sh +``` + +## Backup + +Back up the guest directory: + +```text +/opt/daybook/deploy/data +``` + +It contains: + +- SQLite database +- Artifact files + +Simple backup command: + +```bash +tar -czf daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data +``` + +For consistent backups during heavy writes, stop the container first: + +```bash +docker compose -f /opt/daybook/deploy/docker-compose.yml down +tar -czf daybook-data-$(date +%Y%m%d-%H%M%S).tar.gz -C /opt/daybook/deploy data +docker compose -f /opt/daybook/deploy/docker-compose.yml up -d +``` + +## Restore + +Stop Daybook: + +```bash +docker compose -f /opt/daybook/deploy/docker-compose.yml down +``` + +Replace `/opt/daybook/deploy/data` with the restored data directory, then start: + +```bash +docker compose -f /opt/daybook/deploy/docker-compose.yml up -d +``` + +## Updating The App + +If deployed from Git: + +```bash +cd /opt/daybook +git pull +docker compose -f deploy/docker-compose.yml up -d --build +``` + +The container entrypoint runs migrations on startup. + +## Reverse Proxy Notes + +If putting Daybook behind Nginx, Caddy, Traefik, or a Proxmox-level reverse proxy: + +- Proxy to `http://:8000`. +- Add the public hostname to `DJANGO_ALLOWED_HOSTS`. +- Terminate TLS at the proxy. +- Keep `DJANGO_DEBUG=0`. + +Example `DJANGO_ALLOWED_HOSTS`: + +```text +DJANGO_ALLOWED_HOSTS=daybook.example.com,localhost,127.0.0.1 +``` + +## LLM Deployment Checklist + +Before declaring deployment complete, verify all items: + +- `deploy/.env` exists and does not use placeholder secrets. +- `DJANGO_DEBUG=0`. +- `DJANGO_ALLOWED_HOSTS` includes the access hostname/IP. +- `deploy/data/` exists. +- `docker compose -f deploy/docker-compose.yml ps` shows `daybook` running or healthy. +- `curl -fsS http://127.0.0.1:8000/api/capabilities` succeeds on the guest. +- Browser opens `http://:8000/`. +- Smoke-test artifact POST succeeds with the configured bearer token. +- `deploy/data/db.sqlite3` exists after startup. +- `deploy/data/artifacts_store/` exists after posting an artifact. + +## Known MVP Constraints + +- SQLite is used by default. This is acceptable for local/self-hosted MVP usage. +- The API uses a single shared bearer token. +- No reader authentication is implemented yet. +- Manual drag/drop upload is deferred. +- Content revisions are deferred. diff --git a/docs/agent_quickstart.md b/docs/agent_quickstart.md new file mode 100644 index 0000000..e266ca9 --- /dev/null +++ b/docs/agent_quickstart.md @@ -0,0 +1,126 @@ +# Agent Quickstart + +This is the shortest path for an agent to publish and retrieve artifacts from Daybook. + +## 1. Check Capabilities + +```powershell +Invoke-RestMethod "http://127.0.0.1:8001/api/capabilities" +``` + +## 2. Publish Markdown + +```powershell +$body = @{ + persona = "hermes" + source_type = "agent" + source_name = "Hermes Agent" + source_id = "hermes-local" + kind = "markdown" + title = "Agent Status Update" + content = "# Status Update`n`nThe agent completed its run successfully." + tags = @("status", "agent") + metadata = @{ + run_id = "run-001" + model = "gpt-5.5" + } +} | ConvertTo-Json -Depth 5 + +$artifact = Invoke-RestMethod ` + -Uri "http://127.0.0.1:8001/api/artifacts" ` + -Method Post ` + -Headers @{ Authorization = "Bearer dev-token" } ` + -ContentType "application/json" ` + -Body $body + +$artifact +``` + +Open the artifact in the browser: + +```powershell +$artifact.url +``` + +## 3. Publish HTML Fragment + +```powershell +$body = @{ + persona = "athena" + source_type = "agent" + source_name = "Athena Agent" + kind = "html_fragment" + theme = "tasks" + title = "Task List" + content = "

Tasks

  • Review API docs
  • Publish next artifact
" + tags = @("tasks") +} | ConvertTo-Json -Depth 5 + +Invoke-RestMethod ` + -Uri "http://127.0.0.1:8001/api/artifacts" ` + -Method Post ` + -Headers @{ Authorization = "Bearer dev-token" } ` + -ContentType "application/json" ` + -Body $body +``` + +## 4. Publish A File + +```powershell +curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" ` + -H "Authorization: Bearer dev-token" ` + -F "persona=orion" ` + -F "source_type=agent" ` + -F "source_name=Orion Agent" ` + -F "title=Generated PDF" ` + -F "tags=pdf,report" ` + -F "file=@C:\path\to\report.pdf;type=application/pdf" +``` + +## 5. Find Recent Artifacts + +```powershell +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?persona=hermes" +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?kind=markdown&q=status" +``` + +## 6. Read One Artifact Metadata + +```powershell +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" +``` + +## 7. Update Metadata + +```powershell +$body = @{ + tags = @("status", "agent", "reviewed") + metadata = @{ + run_id = "run-001" + model = "gpt-5.5" + reviewed = $true + } +} | ConvertTo-Json -Depth 5 + +Invoke-RestMethod ` + -Uri "http://127.0.0.1:8001/api/artifacts/$($artifact.id)" ` + -Method Patch ` + -Headers @{ Authorization = "Bearer dev-token" } ` + -ContentType "application/json" ` + -Body $body +``` + +## Agent Payload Checklist + +Recommended fields for every agent post: + +- `persona`: stable feed identity. +- `source_type`: usually `agent`. +- `source_name`: readable agent name. +- `source_id`: stable ID if the agent has one. +- `title`: useful human title. +- `kind`: artifact type. +- `content` or `file`: artifact content. +- `tags`: small set of useful filters. +- `metadata.run_id`: lets later artifacts be correlated to the same agent run. +- `metadata.model`: model or system that generated the artifact. diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..5296050 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,332 @@ +# Daybook API + +Daybook is built around agents publishing artifacts. Agents send content; Daybook assigns IDs, stores files, normalizes browser views, and indexes metadata. + +Base URL while running locally: + +```text +http://127.0.0.1:8001 +``` + +Authentication for write endpoints: + +```http +Authorization: Bearer dev-token +``` + +Read endpoints are currently unauthenticated for the local/self-hosted MVP. + +## Artifact Kinds + +Currently supported kinds: + +| Kind | Upload type | Rendered behavior | Raw support | +|---|---|---|---| +| `html_fragment` | JSON text | Wrapped with a Daybook theme | No | +| `html_full` | JSON text | Stored as complete HTML | Yes | +| `markdown` | JSON text | Rendered to HTML and original `.md` is preserved | Yes | +| `deck` | JSON text | Wrapped as a simple HTML slide/deck page | No | +| `image` | Multipart file | Browser image preview | No | +| `pdf` | Multipart file | Browser PDF preview | No | +| `binary` | Multipart file | Stored and downloadable | No | + +## Source Fields + +Every artifact can identify who or what produced it. + +| Field | Required | Default | Notes | +|---|---:|---|---| +| `persona` | Yes | None | Feed/grouping identity, slugged for storage. | +| `source_type` | No | `agent` | One of `agent`, `person`, `system`. | +| `source_name` | No | `persona` | Human-readable source name. | +| `source_id` | No | Empty string | Stable external agent/person/system ID. | +| `metadata` | No | `{}` | Free-form object for model/run/tool metadata. | + +Example source metadata: + +```json +{ + "persona": "hermes", + "source_type": "agent", + "source_name": "Hermes Agent", + "source_id": "agent-hermes-prod", + "metadata": { + "model": "gpt-5.5", + "run_id": "run-2026-06-10-001", + "tool": "nightly-brief" + } +} +``` + +## Create Text Artifact + +Endpoint: + +```http +POST /api/artifacts +Content-Type: application/json +Authorization: Bearer dev-token +``` + +Required fields: + +| Field | Notes | +|---|---| +| `persona` | Required. | +| `kind` | One of `html_fragment`, `html_full`, `markdown`, `deck`. | +| `title` | Required. | +| `content` | Required text content. | + +Optional fields: + +| Field | Notes | +|---|---| +| `theme` | Defaults by kind. Common values: `report`, `tasks`, `deck`, `base`. | +| `tags` | Array or comma-separated string. | +| `source_prompt` | Prompt or instruction that led to the artifact. | +| `source_type` | Defaults to `agent`. | +| `source_name` | Defaults to `persona`. | +| `source_id` | Optional stable source ID. | +| `metadata` | Optional object. | + +PowerShell example: + +```powershell +$body = @{ + persona = "hermes" + source_type = "agent" + source_name = "Hermes Agent" + source_id = "agent-hermes-local" + kind = "markdown" + theme = "report" + title = "Morning Brief" + content = "# Morning Brief`n`n- Review overnight output`n- Plan next actions" + tags = @("brief", "daily") + source_prompt = "Prepare a concise morning brief." + metadata = @{ + model = "gpt-5.5" + run_id = "brief-001" + } +} | ConvertTo-Json -Depth 5 + +Invoke-RestMethod ` + -Uri "http://127.0.0.1:8001/api/artifacts" ` + -Method Post ` + -Headers @{ Authorization = "Bearer dev-token" } ` + -ContentType "application/json" ` + -Body $body +``` + +Response shape: + +```json +{ + "id": "a1b2c3d4", + "url": "http://127.0.0.1:8001/a/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_available": true, + "persona": "hermes", + "title": "Morning Brief", + "kind": "markdown", + "theme": "report", + "day": "2026-06-10", + "created_at": "2026-06-10T15:00:00+00:00", + "tags": ["brief", "daily"], + "source_prompt": "Prepare a concise morning brief.", + "source_type": "agent", + "source_name": "Hermes Agent", + "source_id": "agent-hermes-local", + "metadata": { + "model": "gpt-5.5", + "run_id": "brief-001" + } +} +``` + +## Create File Artifact + +Endpoint: + +```http +POST /api/artifacts +Content-Type: multipart/form-data +Authorization: Bearer dev-token +``` + +Required fields: + +| Field | Notes | +|---|---| +| `persona` | Required. | +| `title` | Required. | +| `file` | Required file part. | + +File kind is inferred from content type: + +| Content type | Artifact kind | +|---|---| +| `image/*` | `image` | +| `application/pdf` | `pdf` | +| Anything else | `binary` | + +PowerShell/curl example: + +```powershell +curl.exe -X POST "http://127.0.0.1:8001/api/artifacts" ` + -H "Authorization: Bearer dev-token" ` + -F "persona=orion" ` + -F "source_type=agent" ` + -F "source_name=Orion Agent" ` + -F "title=Architecture Diagram" ` + -F "tags=diagram,architecture" ` + -F "file=@C:\path\to\diagram.svg;type=image/svg+xml" +``` + +## List Artifacts + +Endpoint: + +```http +GET /api/artifacts +``` + +Supported filters: + +| Query param | Meaning | +|---|---| +| `persona` | Exact persona match. | +| `day` | Exact day, `YYYY-MM-DD`. | +| `tag` | Matches artifacts containing the tag. | +| `kind` | Exact artifact kind. | +| `q` | Case-insensitive title fragment. | + +Examples: + +```powershell +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts" +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?persona=hermes" +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?kind=markdown&q=brief" +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts?day=2026-06-10&tag=daily" +``` + +## Get Artifact Metadata + +Endpoint: + +```http +GET /api/artifacts/{id} +``` + +Example: + +```powershell +Invoke-RestMethod "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" +``` + +## Update Artifact Metadata + +Endpoint: + +```http +PATCH /api/artifacts/{id} +Content-Type: application/json +Authorization: Bearer dev-token +``` + +Editable fields: + +| Field | Notes | +|---|---| +| `title` | Must not be blank. | +| `tags` | Array or comma-separated string. | +| `source_prompt` | String. | +| `source_type` | `agent`, `person`, or `system`. | +| `source_name` | String. | +| `source_id` | String. | +| `metadata` | Object. Replaces existing metadata. | + +Not editable yet: + +| Field | Reason | +|---|---| +| `content` | Content replacement/revisions are intentionally deferred. | +| `persona` | Affects storage layout and feed identity. | +| `kind` | Affects renderer and storage behavior. | +| `day` | Affects time-ordered feed and storage layout. | + +PowerShell example: + +```powershell +$body = @{ + title = "Morning Brief - Reviewed" + tags = @("brief", "daily", "reviewed") + metadata = @{ + model = "gpt-5.5" + run_id = "brief-001" + reviewed = $true + } +} | ConvertTo-Json -Depth 5 + +Invoke-RestMethod ` + -Uri "http://127.0.0.1:8001/api/artifacts/a1b2c3d4" ` + -Method Patch ` + -Headers @{ Authorization = "Bearer dev-token" } ` + -ContentType "application/json" ` + -Body $body +``` + +## Rendered And Raw Content + +Rendered browser view: + +```http +GET /a/{id} +``` + +Raw/source view: + +```http +GET /a/{id}?raw=1 +``` + +Raw currently exists for: + +| Kind | Raw content | +|---|---| +| `markdown` | Preserved `.md` source. | +| `html_full` | Stored HTML file served as text. | + +If raw content is unavailable, Daybook returns a small HTML fallback page. + +## Capabilities + +Endpoint: + +```http +GET /api/capabilities +``` + +Example response: + +```json +{ + "artifact_kinds": ["html_fragment", "html_full", "markdown", "deck", "image", "pdf", "binary"], + "filters": ["persona", "day", "tag", "kind", "q"], + "views": ["rendered", "raw"], + "raw_supported_for": ["markdown", "html_full"], + "source_types": ["agent", "person", "system"], + "auth": "bearer_token" +} +``` + +Agents should call this endpoint if they need to adapt to the current Daybook instance. + +## Current MVP Constraints + +- A single shared ingest token is used: `dev-token`. +- Reader/browser authentication is not implemented. +- Content modification/revisions are deferred. +- Office document conversion is not implemented. +- Manual browser upload/drag-and-drop is deferred. +- Pagination/cursors are not implemented yet. diff --git a/docs/llm_posting_helper.md b/docs/llm_posting_helper.md new file mode 100644 index 0000000..380c139 --- /dev/null +++ b/docs/llm_posting_helper.md @@ -0,0 +1,108 @@ +# LLM Posting Helper + +Agents should publish to Daybook with `skills/daybook-posting/scripts/daybook_post.py` instead of hand-building HTTP, JSON, curl, or PowerShell multipart requests. + +The project also keeps `scripts/daybook_post.py` as a compatibility wrapper. + +The helper is intentionally zero-dependency and uses only Python's standard library. It works on Windows and Linux. + +## Configuration + +```text +DAYBOOK_URL=http://127.0.0.1:8000 +DAYBOOK_TOKEN=dev-token +DAYBOOK_PERSONA=agent +``` + +Override these per environment. For deployed instances, use the token from `deploy/.env`. + +## Commands + +Check the instance: + +```bash +python skills/daybook-posting/scripts/daybook_post.py --check +``` + +Post Markdown from a file: + +```bash +python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" --content-file report.md --tags build,report +``` + +Post Markdown from stdin: + +```bash +cat report.md | python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" +``` + +Post a deck: + +```bash +python skills/daybook-posting/scripts/daybook_post.py --kind deck --title "Deployment Plan" --content-file deck.html +``` + +`deck.html` must contain top-level `
...
` blocks. + +Post an image/PDF/binary file: + +```bash +python skills/daybook-posting/scripts/daybook_post.py --file diagram.svg --title "Architecture Diagram" +python skills/daybook-posting/scripts/daybook_post.py --file report.pdf --title "Generated Report" +``` + +Render CSV/JSON/code as Markdown today: + +```bash +python skills/daybook-posting/scripts/daybook_post.py --file results.csv --as-markdown --title "Results CSV" +``` + +Dry run: + +```bash +python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Draft" --content-file report.md --dry-run +``` + +The artifact URL is printed as the final stdout line. + +## Validation Guardrails + +The helper validates common LLM mistakes before sending: + +| Kind/input | Validation | +|---|---| +| `deck` | Must contain ``) so it fails loud at post time rather than + rendering broken later. This complements, not replaces, the client guard. + +--- + +## Fixed — Client (recorded for traceability) + +### C1 · Empty `metadata` broke all multipart uploads — **Fixed** + +- The helper serialized empty `metadata` as the string `"{}"` into the multipart + body, triggering S1's 400 even when no metadata was supplied. +- **Fix:** omit empty optional fields (`metadata`, `tags`, `source_id`, + `source_prompt`) from the multipart body, restoring the minimal known-good shape. + +### C2 · `html_full` client validation was over-strict — **Fixed** + +- Originally required BOTH ``; an auto-sniffed full page + lacking an explicit doctype was rejected. +- **Fix:** require `` only (doctype recommended, not mandatory). + +--- + +## Minor / Cosmetic + +### M1 · `deck` title inference picks the first slide heading — **Low / Open** + +- Auto mode derived the title "Slide One" from a deck's first `

` (`ac6da43f`). + Correct but awkward as a title. +- **Fix:** for `deck`, prefer the filename over the first `

`. + +### M2 · CSV content-type guessed as `application/vnd.ms-excel` on Windows — **Low / Open** + +- Python's `mimetypes` maps `.csv` to `application/vnd.ms-excel` on this host rather + than `text/csv`. Harmless — the server still stores it as `binary` — but + inconsistent with the earlier manual `text/csv` upload. +- **Fix (optional):** force `text/csv` for `.csv` in the client's `content_type_for`. + +--- + +## Test Artifacts Created (for cleanup once S2 lands) + +Auto-mode validation posts under persona `claude`, tag `auto-test`: +`e7a769c5` (markdown), `2d162697` (html_full), `49291e85` (html_fragment), +`ac6da43f` (deck), `3ad914cc` (image), `794d29e8` (pdf), `c562128b` (binary). +Plus earlier manual smoke-test artifacts and the malformed deck `5df27410`. diff --git a/planning/framework_artifact_rendering.md b/planning/framework_artifact_rendering.md new file mode 100644 index 0000000..8a53060 --- /dev/null +++ b/planning/framework_artifact_rendering.md @@ -0,0 +1,275 @@ +# Framework-Aware Artifact Rendering Ideas + +Daybook currently supports a useful split between server-owned rendering and agent-owned rendering: + +- `markdown`: agent sends Markdown; Daybook renders and wraps it. +- `html_fragment`: agent sends body-only HTML; Daybook owns the shell and theme CSS. +- `html_full`: agent sends the complete HTML document and owns all presentation. +- `deck`: agent sends slide sections; Daybook wraps them as a simple deck. + +The next design question is how to let agents use established CSS/component frameworks without making every artifact depend on arbitrary external assets or custom page shells. + +## Core Principle + +Agents should publish content, not storage paths or app internals. + +For framework-aware artifacts, the preferred model is: + +```text +Agent sends framework-compatible body markup +Daybook selects/owns the framework CSS and page shell +Browser renders a consistent artifact preview +``` + +This preserves the Daybook principle while giving LLMs a known design vocabulary. + +## Current Option: `html_full` + +Agents can already use any framework by posting `html_full` and including the framework assets themselves. + +Example with Bootstrap CDN: + +```json +{ + "persona": "athena", + "kind": "html_full", + "title": "Bootstrap Report", + "content": "Bootstrap Report
Bootstrap artifact rendered by Daybook.
" +} +``` + +Pros: + +- Works today. +- Gives the agent total layout control. +- Supports any CSS/JS framework. + +Cons: + +- CDN dependencies may fail offline. +- External assets can change or disappear. +- Agents must generate a correct full HTML document. +- Visual consistency across artifacts is weaker. +- Security/trust boundaries are looser if arbitrary scripts are included. + +## Preferred Future Option: Framework Themes For `html_fragment` + +Add Daybook-owned themes that include vendored framework CSS. + +Example payload: + +```json +{ + "persona": "athena", + "kind": "html_fragment", + "theme": "bootstrap", + "title": "Run Summary", + "content": "
Run complete
" +} +``` + +In this model: + +- Agent sends body-only framework-compatible markup. +- Daybook wraps it in the HTML shell. +- Daybook links local framework CSS from `static/vendor/...` or `static/themes/...`. +- Daybook remains responsible for stable rendering. + +Pros: + +- Keeps artifacts self-hosted and predictable. +- Lets agents use known class vocabularies. +- Avoids requiring every agent to write `` boilerplate. +- Preserves the current `html_fragment` contract. + +Cons: + +- Daybook must vendor and maintain selected frameworks. +- Agents must know which theme/framework is selected. +- Some frameworks expect JavaScript or build tooling. + +## Candidate Frameworks + +| Framework | Best for | Fit for Daybook | +|---|---|---| +| Bootstrap | Reports, cards, grids, alerts, forms | Strong first candidate. Familiar to LLMs and humans. | +| Pico CSS | Clean semantic documents | Excellent for low-class, readable reports. | +| Bulma | CSS-only layouts/components | Good middle ground; no JS required. | +| Tufte CSS | Research notes, essays, analytical reports | Good specialized report style. | +| Water.css | Simple semantic HTML styling | Good for zero-effort readable HTML. | +| MVP.css | Semantic HTML documents | Good for basic generated reports. | +| Sakura.css | Minimal text-heavy documents | Good for simple readable pages. | +| Tailwind CSS | Highly custom layouts | Powerful but less ideal without a build step. CDN possible for experiments. | +| DaisyUI | Tailwind components | Useful but depends on Tailwind. Better later. | +| Flowbite | Tailwind components | Useful but heavier and Tailwind-dependent. | +| UIKit | Polished dashboards/docs | Possible but less common. | +| Shoelace | Web components | Rich components, but requires JS custom elements. Better later. | +| Reveal.js | Real slide decks | Strong future replacement/upgrade for current `deck`. | +| Mermaid | Diagrams | Not CSS, but important for diagram artifacts. | +| Prism.js / Highlight.js | Code artifacts | Needed for future code renderer. | +| Tabulator / DataTables | CSV/table artifacts | Needed for future table renderer. | +| JSON viewer libraries | JSON artifacts | Needed for future structured JSON renderer. | + +## Recommended Initial Set + +Start small with CSS-only or mostly CSS-only frameworks: + +1. `bootstrap` +2. `pico` +3. `bulma` +4. `tufte` + +Reasons: + +- No build step required. +- Easy for LLMs to emit usable classes/semantic HTML. +- Useful for the most common artifact types: reports, cards, summaries, dashboards, and notes. +- Can be vendored locally and served through Django static files. + +Defer heavier or JS-dependent frameworks until there is a concrete artifact need. + +## Theme Naming Idea + +Existing themes: + +- `report` +- `tasks` +- `deck` +- `base` + +Potential additions: + +- `bootstrap` +- `pico` +- `bulma` +- `tufte` +- `water` + +Agents would specify the theme in the existing `theme` field for `html_fragment` and `markdown` where appropriate. + +## Storage/Vendoring Idea + +Use local static assets rather than CDN links: + +```text +static/ + vendor/ + bootstrap/ + bootstrap.min.css + bulma/ + bulma.min.css + pico/ + pico.min.css + tufte/ + tufte.min.css + themes/ + bootstrap.css + bulma.css + pico.css + tufte.css +``` + +Theme files can either be the vendored framework directly or thin wrappers that import vendor CSS and add Daybook-specific print/preview fixes. + +Example: + +```css +@import url("/static/vendor/bootstrap/bootstrap.min.css"); + +body.artifact { + background: #f8f9fa; +} +``` + +## CDN Policy + +CDN links are acceptable for quick experiments using `html_full`, but should not be the default for reliable self-hosted deployments. + +Recommended policy: + +- MVP experimentation: allow `html_full` with CDN links. +- Durable/self-hosted rendering: prefer vendored local CSS themes. +- Deployment docs should warn that CDN-dependent artifacts may not render offline. + +## Security Notes + +Framework CSS is relatively safe. Framework JavaScript needs more care. + +Riskier artifact types: + +- `html_full` with arbitrary scripts. +- JS component frameworks. +- Web components loaded from CDNs. + +Current MVP assumes trusted local/self-hosted use. If Daybook later supports untrusted artifact authors or sharing, it should revisit sandboxing and CSP policies. + +## Relationship To Future Renderers + +Framework themes are separate from future dedicated renderers. + +Future renderers likely need their own artifact kinds: + +- `json`: collapsible JSON viewer, raw source preserved. +- `csv`: searchable/sortable table, raw CSV preserved. +- `code`: syntax-highlighted code, raw source preserved. +- `diagram`: Mermaid/Graphviz/PlantUML source rendered to SVG/HTML, raw source preserved. +- `audio`: browser audio player. +- `video`: browser video player. + +These should use established libraries rather than custom viewers: + +- JSON viewer library for JSON. +- Tabulator or DataTables for CSV/table artifacts. +- Prism.js or Highlight.js for code. +- Mermaid for Mermaid diagrams. +- Native `

", text, re.IGNORECASE | re.DOTALL) + if match: + inner = re.sub(r"<[^>]+>", "", match.group(1)).strip() + if inner: + return inner + return humanize(path.stem) + + +def humanize(stem): + cleaned = stem.replace("-", " ").replace("_", " ").strip() + return cleaned.title() if cleaned else stem + + +def build_payload(args, persona): + metadata = parse_metadata(args) + common = { + "persona": persona, + "title": args.title, + "tags": normalize_tags(args.tags), + "source_type": args.source_type, + "source_name": args.source_name or persona, + "source_id": args.source_id, + "source_prompt": args.source_prompt, + "metadata": metadata, + } + + if args.file and not args.as_markdown: + file_path = Path(args.file) + if not file_path.is_file(): + raise ValueError(f"upload file does not exist: {file_path}") + return {**common, "upload_path": file_path} + + kind = args.kind or "markdown" + if args.file and args.as_markdown: + file_path = Path(args.file) + if not file_path.is_file(): + raise ValueError(f"content file does not exist: {file_path}") + content = markdown_from_file(file_path) + kind = "markdown" + else: + content = read_text_content(args.content_file) + + payload = {**common, "kind": kind, "content": content} + if args.theme: + payload["theme"] = args.theme + return payload + + +def parse_metadata(args): + metadata = {} + if args.meta_json: + try: + metadata = json.loads(args.meta_json) + except json.JSONDecodeError as exc: + raise ValueError(f"--meta-json is invalid JSON: {exc}") from exc + if not isinstance(metadata, dict): + raise ValueError("--meta-json must be a JSON object") + for item in args.meta: + if "=" not in item: + raise ValueError(f"--meta must be key=value, got: {item}") + key, value = item.split("=", 1) + if not key: + raise ValueError("--meta key cannot be blank") + metadata[key] = value + return metadata + + +def normalize_tags(tags): + if not tags: + return [] + return [tag.strip() for tag in tags.split(",") if tag.strip()] + + +def read_text_content(content_file): + if content_file: + path = Path(content_file) + if not path.is_file(): + raise ValueError(f"content file does not exist: {path}") + return path.read_text(encoding="utf-8") + if sys.stdin.isatty(): + raise ValueError("provide --content-file, --file, or pipe content on stdin") + return sys.stdin.read() + + +def markdown_from_file(path): + content = path.read_text(encoding="utf-8") + language = language_for_suffix(path.suffix) + return f"# {path.name}\n\n```{language}\n{content}\n```\n" + + +def language_for_suffix(suffix): + return { + ".csv": "csv", + ".json": "json", + ".js": "javascript", + ".ts": "typescript", + ".py": "python", + ".ps1": "powershell", + ".sh": "bash", + ".cpp": "cpp", + ".cc": "cpp", + ".cxx": "cpp", + ".c": "c", + ".h": "c", + ".hpp": "cpp", + ".html": "html", + ".css": "css", + ".xml": "xml", + ".svg": "xml", + ".md": "markdown", + ".txt": "text", + }.get(suffix.lower(), "text") + + +def validate_payload(payload, args): + if "upload_path" in payload: + return + kind = payload["kind"] + content_lower = payload["content"].lower() + if kind == "deck" and "... blocks, not Markdown or unwrapped headings") + if kind == "html_fragment" and (" (and ideally )") + if kind == "markdown" and not payload["content"].strip(): + raise ValueError("markdown content cannot be blank") + + +def print_dry_run(base_url, token, payload, args): + target = f"{base_url}/api/artifacts" + print(f"POST {target}") + print(f"Authorization: Bearer {mask(token)}") + if "upload_path" in payload: + path = payload["upload_path"] + print("Content-Type: multipart/form-data") + print(f"file: {path} ({content_type_for(path)})") + printable = {key: value for key, value in payload.items() if key != "upload_path"} + print(json.dumps(printable, indent=2, default=str)) + else: + print("Content-Type: application/json") + printable = dict(payload) + printable["content"] = f"" + print(json.dumps(printable, indent=2)) + + +def mask(token): + if len(token) <= 8: + return "***" + return f"{token[:4]}...{token[-4:]}" + + +def post_payload(base_url, token, payload, args): + url = f"{base_url}/api/artifacts" + if "upload_path" in payload: + body, content_type = multipart_body(payload) + request = Request(url, data=body, method="POST") + request.add_header("Content-Type", content_type) + else: + body = json.dumps(payload).encode("utf-8") + request = Request(url, data=body, method="POST") + request.add_header("Content-Type", "application/json") + request.add_header("Authorization", f"Bearer {token}") + request.add_header("Accept", "application/json") + with urlopen(request, timeout=60) as response: + return json.loads(response.read().decode("utf-8")) + + +def multipart_body(payload): + boundary = f"----daybook-{uuid.uuid4().hex}" + chunks = [] + upload_path = payload["upload_path"] + for key, value in payload.items(): + if key == "upload_path": + continue + if key == "metadata": + if not value: + continue + value = json.dumps(value) + elif key == "tags": + if not value: + continue + value = ",".join(value) + elif value in (None, ""): + continue + chunks.append(f"--{boundary}\r\n".encode("utf-8")) + chunks.append(f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode("utf-8")) + chunks.append(str(value).encode("utf-8")) + chunks.append(b"\r\n") + + chunks.append(f"--{boundary}\r\n".encode("utf-8")) + chunks.append( + f'Content-Disposition: form-data; name="file"; filename="{upload_path.name}"\r\n'.encode("utf-8") + ) + chunks.append(f"Content-Type: {content_type_for(upload_path)}\r\n\r\n".encode("utf-8")) + chunks.append(upload_path.read_bytes()) + chunks.append(b"\r\n") + chunks.append(f"--{boundary}--\r\n".encode("utf-8")) + return b"".join(chunks), f"multipart/form-data; boundary={boundary}" + + +def content_type_for(path): + suffix = path.suffix.lower() + if suffix == ".svg": + return "image/svg+xml" + if suffix == ".pdf": + return "application/pdf" + return mimetypes.guess_type(path.name)[0] or "application/octet-stream" + + +def request_json(method, url): + request = Request(url, method=method) + request.add_header("Accept", "application/json") + with urlopen(request, timeout=15) as response: + return json.loads(response.read().decode("utf-8")) + + +def print_http_error(exc): + if isinstance(exc, HTTPError): + body = exc.read().decode("utf-8", errors="replace") + print(f"error: HTTP {exc.code} {exc.reason}", file=sys.stderr) + if body: + print(body, file=sys.stderr) + else: + print(f"error: {exc}", file=sys.stderr) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/static/daybook.css b/static/daybook.css index c142547..f1f50b2 100644 --- a/static/daybook.css +++ b/static/daybook.css @@ -5,7 +5,15 @@ color: #1d2528; } +html, body { + height: 100%; + overflow: hidden; +} + +body { + display: flex; + flex-direction: column; margin: 0; } @@ -14,23 +22,17 @@ body { background: #17313b; color: #fffaf0; display: grid; + flex: 0 0 auto; gap: 0; - grid-template-columns: minmax(13rem, 25%) 1fr; + grid-template-columns: max-content 1fr; padding: 0; } .brandline { - align-items: baseline; + align-items: center; display: flex; gap: 0.55rem; - padding: 0.38rem 0.65rem; -} - -.brandline p { - color: #d9e5e8; - font-size: 0.78rem; - font-weight: 600; - margin: 0; + padding: 0.38rem 0.8rem; } .eyebrow { @@ -119,8 +121,10 @@ h1 { --artifacts-width: 280px; --days-width: 144px; display: grid; + flex: 1 1 auto; grid-template-columns: var(--days-width) 1px var(--artifacts-width) 1px minmax(0, 1fr); - min-height: calc(100vh - 3.2rem); + min-height: 0; + overflow: hidden; } .day-sidebar, @@ -130,6 +134,60 @@ h1 { padding: 0.55rem; } +.day-sidebar, +.artifact-sidebar { + min-height: 0; + overflow-x: hidden; + scrollbar-color: transparent transparent; + scrollbar-width: none; +} + +.day-sidebar { + overflow-y: auto; +} + +.artifact-sidebar { + display: flex; + flex-direction: column; + overflow-y: hidden; + padding: 0; +} + +.artifact-list { + min-height: 0; + overflow-x: hidden; + overflow-y: auto; + scrollbar-color: transparent transparent; + scrollbar-width: none; +} + +.day-sidebar:hover, +.artifact-sidebar:hover .artifact-list { + scrollbar-color: rgba(23, 49, 59, 0.35) transparent; + scrollbar-width: thin; +} + +.day-sidebar::-webkit-scrollbar, +.artifact-list::-webkit-scrollbar { + width: 0; +} + +.day-sidebar:hover::-webkit-scrollbar, +.artifact-sidebar:hover .artifact-list::-webkit-scrollbar { + width: 6px; +} + +.day-sidebar::-webkit-scrollbar-thumb, +.artifact-list::-webkit-scrollbar-thumb { + background: rgba(23, 49, 59, 0.35); + border-radius: 999px; +} + +.day-sidebar::-webkit-scrollbar-track, +.artifact-list::-webkit-scrollbar-track { + background: transparent; +} + .day-sidebar { background: #ebe4d5; } @@ -143,6 +201,7 @@ h1 { display: flex; flex-direction: column; justify-content: center; + min-height: 0; padding: 0; } @@ -185,6 +244,9 @@ h1 { .day-list, .artifact-list { display: grid; +} + +.day-list { gap: 0.35rem; } @@ -195,7 +257,7 @@ h1 { } .day-link { - border-radius: 10px; + border-radius: 6px; display: grid; gap: 0.1rem; padding: 0.48rem; @@ -207,6 +269,11 @@ h1 { box-shadow: 0 10px 24px rgba(57, 44, 28, 0.08); } +.day-link:focus-visible { + outline: 2px solid #e2b66a; + outline-offset: 2px; +} + .day-link.selected { background: #17313b; color: #fffaf0; @@ -224,7 +291,7 @@ h1 { .day-link span, .pane-heading span, -.artifact-row-main strong { +.artifact-time { color: #7b6b55; font-size: 0.7rem; } @@ -250,84 +317,136 @@ h1 { align-items: baseline; display: flex; justify-content: space-between; - margin-bottom: 0.45rem; + margin: 0; + padding: 0.55rem; +} + +.new-artifacts-banner { + background: #245238; + border: 0; + border-radius: 999px; + color: #f6fff2; + 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: 100%; +} + +.new-artifacts-banner[hidden] { + display: none; +} + +.new-artifacts-banner:hover { + background: #17313b; } .artifact-list { align-content: start; + flex: 1 1 auto; } .artifact-row { - align-items: center; + align-items: stretch; background: #fffaf0; - border: 1px solid #e5d8c0; - border-radius: 11px; - display: flex; - gap: 0.45rem; - padding: 0.45rem; + border: 0; + border-radius: 0; + display: grid; + gap: 0; + grid-template-columns: 3.15rem minmax(0, 1fr) 2.6rem; + min-height: 2.7rem; + overflow: hidden; + padding: 0; } .artifact-row:hover { - border-color: #c9b38d; - box-shadow: 0 12px 28px rgba(57, 44, 28, 0.08); + background: #fff6e5; + box-shadow: none; +} + +.artifact-row:focus-visible { + outline: 0; + box-shadow: inset 0 0 0 1px #e2b66a; } .artifact-row.selected { background: #17313b; - border-color: #17313b; color: #fffaf0; } -.artifact-row.selected .artifact-row-main strong, -.artifact-row.selected .artifact-row-main span { +.artifact-row.selected .artifact-time { + background: #10262f; +} + +.artifact-row.selected .artifact-row-main span, +.artifact-row.selected .artifact-time { + color: #fffaf0; +} + +.artifact-row.selected .kind-icon { + background: rgba(255, 250, 240, 0.12); color: #fffaf0; } .kind-icon { align-items: center; - background: #dfe9ea; - border-radius: 7px; - color: #17313b; + background: rgba(23, 49, 59, 0.04); + color: #657173; display: inline-flex; - flex: 0 0 2.25rem; - font-size: 0.58rem; + font-size: 0.56rem; font-weight: 800; - height: 1.75rem; justify-content: center; letter-spacing: 0.04em; + line-height: 1; + padding: 0 0.42rem; + white-space: nowrap; } .kind-image { - background: #dfeee2; color: #265336; } .kind-pdf { - background: #f3dedb; color: #78352f; } .kind-markdown { - background: #e7e1f2; color: #443265; } .kind-deck { - background: #f6e5bf; color: #6e4a12; } .artifact-row-main { - display: grid; - gap: 0.1rem; + align-items: center; + display: flex; min-width: 0; + padding: 0.42rem 0.55rem; } .artifact-row-main span { + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; color: #17313b; + display: -webkit-box; font-size: 0.86rem; overflow: hidden; - text-overflow: ellipsis; +} + +.artifact-time { + align-items: center; + background: #efe5d3; + display: flex; + justify-content: flex-end; + justify-self: stretch; + line-height: 1; + padding: 0 0.48rem; + text-align: right; white-space: nowrap; } @@ -350,6 +469,20 @@ h1 { max-width: 34rem; } +kbd { + background: #ebe4d5; + border: 1px solid #d3c3a7; + border-radius: 4px; + color: #17313b; + font: 0.85em ui-monospace, SFMono-Regular, Consolas, monospace; + padding: 0.08rem 0.28rem; +} + +.preview-pane > .preview-empty { + align-self: center; + margin: auto; +} + .preview-frame-wrap { align-items: center; display: flex; @@ -410,6 +543,12 @@ h1 { } @media (max-width: 720px) { + html, + body { + height: auto; + overflow: auto; + } + .topbar { align-items: stretch; grid-template-columns: 1fr; @@ -424,6 +563,7 @@ h1 { .feed { grid-template-columns: 1fr; + overflow: visible; } .splitter { @@ -434,5 +574,6 @@ h1 { .artifact-sidebar { border-bottom: 1px solid #d9c9ad; border-right: 0; + max-height: 32vh; } } diff --git a/templates/artifacts/feed.html b/templates/artifacts/feed.html index 83626da..88f3347 100644 --- a/templates/artifacts/feed.html +++ b/templates/artifacts/feed.html @@ -3,14 +3,13 @@ - Daybook + AgentFeed
-

Daybook

-

Agent Artifacts

+

AgentFeed

@@ -44,12 +43,16 @@
-
+