Refine AgentFeed UI and local workflow
This commit is contained in:
parent
3179f832fc
commit
ba9f5eb168
29 changed files with 2986 additions and 69 deletions
19
.dockerignore
Normal file
19
.dockerignore
Normal file
|
|
@ -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/
|
||||
19
AGENTS.md
19
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
|
||||
|
|
|
|||
28
_archive_/hosts/README.md
Normal file
28
_archive_/hosts/README.md
Normal file
|
|
@ -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.
|
||||
85
_archive_/hosts/cloud-template.md
Normal file
85
_archive_/hosts/cloud-template.md
Normal file
|
|
@ -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=<hostnames and IPs>
|
||||
DAYBOOK_API_TOKEN=<write 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-base-url>/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 |
|
||||
145
_archive_/hosts/nuc-daybook.md
Normal file
145
_archive_/hosts/nuc-daybook.md
Normal file
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
]
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -6,5 +6,7 @@ from . import views
|
|||
urlpatterns = [
|
||||
path("", views.feed, name="feed"),
|
||||
path("a/<str:artifact_id>", views.artifact_view, name="artifact"),
|
||||
path("api/capabilities", views.CapabilitiesView.as_view(), name="capabilities-api"),
|
||||
path("api/artifacts", views.ArtifactListCreateView.as_view(), name="artifact-api"),
|
||||
path("api/artifacts/<str:artifact_id>", views.ArtifactDetailView.as_view(), name="artifact-detail-api"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
24
deploy/Dockerfile
Normal file
24
deploy/Dockerfile
Normal file
|
|
@ -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"]
|
||||
19
deploy/docker-compose.yml
Normal file
19
deploy/docker-compose.yml
Normal file
|
|
@ -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
|
||||
9
deploy/entrypoint.sh
Normal file
9
deploy/entrypoint.sh
Normal file
|
|
@ -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 "$@"
|
||||
7
deploy/env.example
Normal file
7
deploy/env.example
Normal file
|
|
@ -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
|
||||
348
deploy/proxmox_llm_deploy.md
Normal file
348
deploy/proxmox_llm_deploy.md
Normal file
|
|
@ -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 <REPO_URL> /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=<generated-secret>
|
||||
DJANGO_ALLOWED_HOSTS=<container-ip>,<dns-name-if-any>,localhost,127.0.0.1
|
||||
DAYBOOK_API_TOKEN=<generated-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://<guest-ip>: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 `<TOKEN>` with `DAYBOOK_API_TOKEN` from `deploy/.env`.
|
||||
|
||||
```bash
|
||||
curl -fsS -X POST http://127.0.0.1:8000/api/artifacts \
|
||||
-H "Authorization: Bearer <TOKEN>" \
|
||||
-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://<guest-ip>: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://<guest-ip>: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://<guest-ip>: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.
|
||||
126
docs/agent_quickstart.md
Normal file
126
docs/agent_quickstart.md
Normal file
|
|
@ -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 = "<h1>Tasks</h1><ul><li>Review API docs</li><li>Publish next artifact</li></ul>"
|
||||
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.
|
||||
332
docs/api.md
Normal file
332
docs/api.md
Normal file
|
|
@ -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.
|
||||
108
docs/llm_posting_helper.md
Normal file
108
docs/llm_posting_helper.md
Normal file
|
|
@ -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 `<section>...</section>` 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 `<section`. |
|
||||
| `html_fragment` | Must not contain `<!doctype`, `<html`, or `<body`. |
|
||||
| `html_full` | Must contain `<!doctype` and `<html`. |
|
||||
| `markdown` | Must not be blank. |
|
||||
| `.svg` upload | Forces `image/svg+xml`. |
|
||||
| `.pdf` upload | Forces `application/pdf`. |
|
||||
|
||||
## Metadata
|
||||
|
||||
Use repeatable `--meta key=value` flags:
|
||||
|
||||
```bash
|
||||
python skills/daybook-posting/scripts/daybook_post.py \
|
||||
--kind markdown \
|
||||
--title "Run Summary" \
|
||||
--content-file summary.md \
|
||||
--meta run_id=run-001 \
|
||||
--meta model=gpt-5.5
|
||||
```
|
||||
|
||||
Or pass a JSON object:
|
||||
|
||||
```bash
|
||||
python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Run Summary" --content-file summary.md --meta-json '{"run_id":"run-001"}'
|
||||
```
|
||||
|
||||
## Skill Wrapper
|
||||
|
||||
A thin LLM-facing wrapper lives at:
|
||||
|
||||
```text
|
||||
skills/daybook-posting/SKILL.md
|
||||
```
|
||||
|
||||
Its key instruction is: do not hand-build requests; use `scripts/daybook_post.py`.
|
||||
163
planning/daybook-issues-log.md
Normal file
163
planning/daybook-issues-log.md
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
# Daybook — Issues Found During LLM-Posting Testing
|
||||
|
||||
**Date:** 2026-06-10
|
||||
**Context:** Found while smoke-testing the deployed instance (`192.168.1.36:8000`)
|
||||
and hardening the `daybook-posting` skill's `daybook_post.py` helper. Auth/
|
||||
reachability items are tracked separately in
|
||||
`planning/next_steps/llm-posting-skill-plan.md` and not duplicated here.
|
||||
|
||||
Severity key: **High** = blocks a stated feature · **Med** = degrades UX or
|
||||
correctness · **Low** = cosmetic. Status: **Open** / **Fixed** / **By design**.
|
||||
|
||||
---
|
||||
|
||||
## Open — Server-Side
|
||||
|
||||
### S1 · Multipart `metadata` field is not parsed as a JSON object — **High / Open**
|
||||
|
||||
- **Symptom:** Any file upload (multipart) that includes a `metadata` field returns
|
||||
`HTTP 400 {"detail":"metadata must be an object"}`.
|
||||
- **Repro:** `python daybook_post.py test.svg --meta run_id=1` (or any `--file` +
|
||||
`--meta`). Text artifacts (JSON path) are unaffected — they send a real object.
|
||||
- **Root cause:** The multipart handler reads `metadata` as a raw form string and
|
||||
type-checks it against `dict` without `json.loads`. A stringified `{}` or
|
||||
`{"k":"v"}` therefore fails the check.
|
||||
- **Impact:** Machine-readable metadata (run_id, model, etc.) cannot be attached to
|
||||
image/pdf/binary artifacts — only to text artifacts.
|
||||
- **Fix:** In the multipart code path, `json.loads` the `metadata` form field before
|
||||
validating, mirroring the JSON path. Confirm `tags` is handled consistently
|
||||
(currently sent as a comma string and split server-side).
|
||||
- **Client workaround in place:** the helper now omits `metadata` from multipart
|
||||
when empty, so metadata-less uploads work. Non-empty metadata on uploads still
|
||||
needs this server fix.
|
||||
|
||||
### S2 · Users cannot delete or archive artifacts (artifacts are immutable) — **High / Open**
|
||||
|
||||
- **Requirement:** Users need to be able to **archive** and **delete** artifacts —
|
||||
both as API endpoints and as controls in the web UI.
|
||||
- **Symptom:** There is no delete endpoint, and `content`/`kind`/`persona`/`day`
|
||||
are not editable. The malformed test deck `5df27410` and the session's test
|
||||
artifacts can only be superseded, never removed or hidden.
|
||||
- **Impact:** Test and mistake artifacts accumulate permanently in the feed with no
|
||||
way for a user to clean it up.
|
||||
- **Fix — two distinct actions:**
|
||||
- **Archive:** user-initiated, hides the artifact from the default feed but
|
||||
retains it and allows restore. Needs an archive/unarchive endpoint, a default
|
||||
feed filter that excludes archived items, and a UI control (plus an
|
||||
"archived" view).
|
||||
- **Delete:** user-initiated permanent removal (or a soft-delete tombstone).
|
||||
Needs a `DELETE` endpoint, a UI control with a confirm step, and removal of the
|
||||
stored content/raw file.
|
||||
- **Related:** consider allowing content replacement for an existing id (revision),
|
||||
which would also address re-posting a corrected artifact.
|
||||
|
||||
### S3 · No renderer for CSV / JSON / code — **Med / Open**
|
||||
|
||||
- **Symptom:** These upload as download-only `binary` (e.g. `test.csv` →
|
||||
`c562128b`, kind `binary`).
|
||||
- **Impact:** Tabular/code artifacts aren't viewable inline.
|
||||
- **Fix:** Add a renderer, or formally bless the client's `--as-markdown` fenced-
|
||||
block convention as the interim path and document it.
|
||||
|
||||
### S4 · Favorites and project/group labels — **Med / Open**
|
||||
|
||||
- **Requirement:** Users want to (a) mark artifacts as **favorites**, and (b) label
|
||||
artifacts as belonging to a **group / project**. The group/project label must be
|
||||
**settable at upload time** (and editable afterward). Favorite is primarily a UI
|
||||
toggle but should also be settable at upload.
|
||||
- **Design notes:**
|
||||
- **Keep this distinct from `tags`.** Tags are many free-form filter labels; a
|
||||
project/group is a first-class, usually single container used for navigation and
|
||||
grouping. Recommend a dedicated `project` (or `group`) field rather than
|
||||
overloading tags. `favorite` is a boolean, not a tag.
|
||||
- **Data model:** add `favorite: bool = false` and `project: str | null` to the
|
||||
artifact record.
|
||||
- **Scope — server / client / UI:**
|
||||
- **Server:** accept `favorite` and `project` on POST; add endpoints to toggle
|
||||
favorite and set/clear project after upload; support `?project=` and
|
||||
`?favorite=true` feed filters.
|
||||
- **Client (`daybook_post.py`):** add `--project NAME` and `--favorite` flags,
|
||||
sent as first-class JSON/form fields — NOT inside `metadata` (see S1, which
|
||||
breaks for multipart string fields).
|
||||
- **UI:** a favorite (star) toggle and a favorites view; group/filter the feed by
|
||||
project.
|
||||
|
||||
---
|
||||
|
||||
## Open — Web UI
|
||||
|
||||
### U1 · Feed does not refresh when new artifacts arrive — **Med / Open**
|
||||
|
||||
- **Symptom:** The web UI renders a static snapshot of the feed. Artifacts posted
|
||||
after the page loads do not appear until the user manually reloads.
|
||||
- **Impact:** Agents publish continuously, but a human watching the feed silently
|
||||
misses new arrivals — which undercuts Daybook's purpose as a live journal of
|
||||
agent output. Observed throughout this session: each test post required a manual
|
||||
browser refresh to show up.
|
||||
- **Fix options (simplest first):**
|
||||
- Client-side polling of the list endpoint on an interval (e.g. every few
|
||||
seconds), diffing against the loaded set.
|
||||
- Server-sent events (SSE) for push updates — a natural fit for this
|
||||
publish-only, append-mostly model and lighter than WebSockets.
|
||||
- To avoid disrupting scroll position, surface a "N new artifacts — click to
|
||||
load" indicator rather than auto-injecting items at the top.
|
||||
|
||||
---
|
||||
|
||||
## By Design — Worth a Decision
|
||||
|
||||
### D1 · Strict rendering, no normalization — **By design**
|
||||
|
||||
- **Behavior:** "Daybook does not fix malformed content." Wrong-shaped content is
|
||||
stored and rendered as-is.
|
||||
- **Observed consequence:** Markdown sent to `deck` (`5df27410`) was stored and
|
||||
rendered malformed — no error, just broken output.
|
||||
- **Implication:** A validating client is load-bearing. The server's per-kind
|
||||
structure rules and the client's pre-flight checks must stay in sync; if the
|
||||
server adds a kind, the client's validation/sniffing must follow.
|
||||
- **Optional improvement:** Consider rejecting obviously-malformed content with a
|
||||
`422` (e.g. `deck` with no `<section>`) 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 `<!doctype` AND `<html>`; an auto-sniffed full page
|
||||
lacking an explicit doctype was rejected.
|
||||
- **Fix:** require `<html>` 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 `<h1>` (`ac6da43f`).
|
||||
Correct but awkward as a title.
|
||||
- **Fix:** for `deck`, prefer the filename over the first `<h1>`.
|
||||
|
||||
### 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`.
|
||||
275
planning/framework_artifact_rendering.md
Normal file
275
planning/framework_artifact_rendering.md
Normal file
|
|
@ -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": "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>Bootstrap Report</title><link href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css\" rel=\"stylesheet\"></head><body><main class=\"container py-4\"><div class=\"alert alert-success\">Bootstrap artifact rendered by Daybook.</div></main></body></html>"
|
||||
}
|
||||
```
|
||||
|
||||
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": "<main class=\"container py-4\"><div class=\"alert alert-success\">Run complete</div></main>"
|
||||
}
|
||||
```
|
||||
|
||||
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 `<head>` 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 `<audio controls>` and `<video controls>` for media.
|
||||
|
||||
## Recommended Implementation Order
|
||||
|
||||
1. Keep `html_full` as the escape hatch for total agent control.
|
||||
2. Add one vendored CSS framework theme first, probably Bootstrap.
|
||||
3. Document exact usage for `theme=bootstrap` with `html_fragment`.
|
||||
4. Add a sample Bootstrap artifact.
|
||||
5. Add Pico CSS as a low-complexity report/document theme.
|
||||
6. Add Bulma or Tufte only if real artifacts need them.
|
||||
7. Later, add dedicated renderers for JSON, CSV, code, and diagrams.
|
||||
|
||||
## Example Bootstrap Fragment
|
||||
|
||||
```html
|
||||
<main class="container py-4">
|
||||
<div class="row g-3">
|
||||
<section class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h1 class="card-title">Run Summary</h1>
|
||||
<p class="card-text">The agent completed all checks successfully.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<aside class="col-md-4">
|
||||
<div class="alert alert-success">Status: green</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
```
|
||||
|
||||
Payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"persona": "athena",
|
||||
"kind": "html_fragment",
|
||||
"theme": "bootstrap",
|
||||
"title": "Bootstrap Run Summary",
|
||||
"content": "<main class=\"container py-4\">...</main>"
|
||||
}
|
||||
```
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should framework themes be available for `markdown`, or only `html_fragment`?
|
||||
- Should Daybook allow framework JavaScript, or CSS only?
|
||||
- Should `html_full` artifacts be sandboxed more strictly in iframes later?
|
||||
- Should agents be able to query `/api/capabilities` for supported themes?
|
||||
- Should themes be exposed as a first-class API field in capabilities?
|
||||
260
planning/next_steps/llm-posting-skill-plan.md
Normal file
260
planning/next_steps/llm-posting-skill-plan.md
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
# Daybook — LLM Posting Skill: Design & Next Steps
|
||||
|
||||
**Status:** planning / pre-implementation
|
||||
**Date:** 2026-06-10
|
||||
**Author:** drafted by Claude (Opus 4.8) after a live smoke test of the deployed instance
|
||||
|
||||
## What This Document Is
|
||||
|
||||
A consolidated design recommendation for making **publishing to Daybook trivially
|
||||
easy for LLM agents**, plus notes on missing functionality surfaced during testing.
|
||||
Goal: an agent should be able to publish a correct artifact out of the box via a
|
||||
single command, without hand-crafting HTTP, JSON, or multipart requests.
|
||||
|
||||
## What Was Validated (Live Smoke Test)
|
||||
|
||||
Tested against the prod instance `http://192.168.1.36:8000` via desktop-commander
|
||||
(the sandbox cannot reach the `192.168.1.x` LAN, so all calls were driven on-host).
|
||||
|
||||
All seven artifact kinds were posted and verified under persona `claude`:
|
||||
|
||||
| Kind | Path | Result |
|
||||
|---|---|---|
|
||||
| markdown | JSON | OK (`2db087a7`) |
|
||||
| html_full | JSON | OK (`8cd79771`) |
|
||||
| deck (markdown — WRONG) | JSON | stored but malformed (`5df27410`) |
|
||||
| image (svg) | multipart | OK (`83ab938c`) |
|
||||
| pdf | multipart | OK (`8c82b15c`) |
|
||||
| binary (csv) | multipart | OK, download-only (`2cceceaf`) |
|
||||
| deck (`<section>` blocks) | JSON | OK (`60d26e5a`) |
|
||||
| html_fragment | JSON | OK (`fd42816f`) |
|
||||
|
||||
Confirmed working: capabilities endpoint, bearer auth, content-type inference
|
||||
(svg→image, pdf→pdf, csv→binary), persona/tag/kind filtering, read-back by id.
|
||||
|
||||
## The Core Problem To Design Around
|
||||
|
||||
Every failure during testing was the same class of problem: **an LLM
|
||||
hand-assembling a request.** Three concrete failure modes, all observed:
|
||||
|
||||
1. **Inline content in shell commands breaks repeatedly.** `powershell -Command`
|
||||
mangled `$`-expansion, nested quotes, and backtick newlines on three separate
|
||||
attempts. This is not incidental — every model re-derives the shell incantation
|
||||
and gets it wrong. It is the single biggest source of friction.
|
||||
2. **Multipart is environment-dependent.** Windows PowerShell 5.1 has no
|
||||
`Invoke-RestMethod -Form`; it needs `curl.exe` or PowerShell 7+. An agent cannot
|
||||
reliably know which host it is running on.
|
||||
3. **Per-kind structure rules are easy to violate.** Markdown was sent to `deck`
|
||||
and rendered malformed — precisely because request-building is where judgment
|
||||
leaks in. The docs' stance is "Daybook does not fix malformed content," so the
|
||||
client must guarantee structure instead.
|
||||
|
||||
**Design conclusion:** the skill must NOT teach the model how to build the request.
|
||||
It must give the model ONE command to run. All fiddliness (auth, JSON-vs-multipart,
|
||||
content-type, escaping, kind validation) belongs inside a helper, not in the prompt.
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
A **bundled CLI helper + a thin SKILL.md**.
|
||||
|
||||
- The skill folder ships a single **zero-dependency Python script**
|
||||
(`scripts/daybook_post.py`, stdlib only). Python runs identically on the NUC
|
||||
(Linux) and on Windows, eliminating the curl / PS5.1 split entirely.
|
||||
- `SKILL.md` tells the agent **when** to publish and **that the command exists** —
|
||||
never how to craft HTTP.
|
||||
- The script is also usable by any non-Claude agent that can run a shell command,
|
||||
so it is not locked to the Anthropic skill format. SKILL.md is just the
|
||||
Claude-flavored discovery wrapper around a universal tool.
|
||||
|
||||
## Why This Is "Easy Out Of The Box"
|
||||
|
||||
1. **Content never touches the command line.** Content comes from a file or stdin,
|
||||
so escaping / newlines / quotes never arise:
|
||||
|
||||
```bash
|
||||
cat report.md | python daybook_post.py --kind markdown --title "Build Report"
|
||||
python daybook_post.py --file diagram.svg --title "Architecture Diagram"
|
||||
```
|
||||
|
||||
2. **One entry point, no branching decisions.** The script decides JSON-vs-multipart
|
||||
and infers content-type from file extension. The model never picks.
|
||||
3. **Validate-before-send with fix hints**, turning the strict server contract into
|
||||
a guardrail the agent cannot trip over.
|
||||
4. **Prints the artifact URL as the last stdout line**, so the agent reliably
|
||||
captures the result regardless of verbosity.
|
||||
|
||||
### Pre-flight validation table
|
||||
|
||||
| Kind / input | Check | Behavior on mismatch |
|
||||
|---|---|---|
|
||||
| `deck` | content contains `<section` | error: "deck needs top-level `<section>...</section>` blocks, not Markdown" |
|
||||
| `html_fragment` | no `<html>` / `<!doctype` | error: "fragment must be body-only HTML" |
|
||||
| `html_full` | has `<!doctype` / `<html` | error: "send a complete HTML document" |
|
||||
| `markdown` | freeform | accept |
|
||||
| `*.svg` upload | force `image/svg+xml` | auto-fix (not an error) |
|
||||
| `*.pdf` upload | force `application/pdf` | auto-fix |
|
||||
| other upload | extension→mime, else `octet-stream` | store as binary |
|
||||
|
||||
## Proposed CLI Surface (Deliberately Small)
|
||||
|
||||
```
|
||||
daybook_post.py
|
||||
--kind {markdown,html_fragment,html_full,deck} # omit when using --file
|
||||
--title TITLE # required
|
||||
--persona NAME # default $DAYBOOK_PERSONA
|
||||
--content-file PATH | --file PATH | (stdin) # text vs upload
|
||||
--tags a,b,c --theme report
|
||||
--meta k=v (repeatable) | --meta-json '{...}'
|
||||
--source-name ... --source-id ... --source-prompt ...
|
||||
--check # GET /api/capabilities and exit
|
||||
--dry-run # print the request it would send, do not post
|
||||
```
|
||||
|
||||
Config via env with prod fallbacks: `DAYBOOK_URL`, `DAYBOOK_TOKEN`,
|
||||
`DAYBOOK_PERSONA`. Zero-config by default, fully overridable.
|
||||
|
||||
## SKILL.md Design
|
||||
|
||||
The single most important field for out-of-the-box use is the **description**,
|
||||
because that is what makes an agent reach for the skill unprompted. Key on *intent*,
|
||||
not mechanism:
|
||||
|
||||
> Use when you have produced a finished deliverable — a report, summary, brief,
|
||||
> diagram, generated HTML, PDF, or status update — that a human should be able to
|
||||
> review later outside this chat. Publishes it to Daybook and returns a URL.
|
||||
|
||||
The body of SKILL.md should be short:
|
||||
- One line on what Daybook is (publish-only artifact journal).
|
||||
- The single command pattern (file/stdin in, URL out).
|
||||
- A 4-row kind cheat-sheet (markdown / fragment / full / deck → what content shape).
|
||||
- "Run `--check` first if unsure the instance is up."
|
||||
- Explicitly: do NOT hand-build curl/JSON; always use the script.
|
||||
|
||||
Proposed skill folder layout:
|
||||
|
||||
```
|
||||
agent-daybook/ (skill root)
|
||||
SKILL.md
|
||||
scripts/
|
||||
daybook_post.py # the one helper, stdlib only
|
||||
.env.example # DAYBOOK_URL / DAYBOOK_TOKEN / DAYBOOK_PERSONA
|
||||
```
|
||||
|
||||
## Open Decisions (Need Your Call)
|
||||
|
||||
1. **Token handling.** Baking the prod token as a fallback default = truly
|
||||
zero-config, but it lands in the skill folder / repo. Cleaner: require
|
||||
`DAYBOOK_TOKEN` and ship `.env.example`. Recommendation: zero-config now while
|
||||
it is a local MVP, tighten to env-only before this leaves the LAN.
|
||||
2. **Helper location.** Bundle inside the skill (`scripts/`) for a self-contained,
|
||||
no-install drop-in — recommended — vs a system-wide install that reintroduces
|
||||
PATH friction.
|
||||
3. **Auto-wrap convenience kinds.** Should the script auto-wrap a `.csv`/`.json`
|
||||
into a Markdown fenced block so it renders today (server has no CSV/JSON
|
||||
renderer yet)? Convenient, but slightly magic. Suggest: opt-in flag
|
||||
`--as-markdown`, not default.
|
||||
|
||||
## Server-Side / Missing Functionality Notes
|
||||
|
||||
Observed during testing. The thin client absorbs MVP gaps so the server can stay
|
||||
strict; these are candidates for later server work, not blockers.
|
||||
|
||||
- **No DELETE / no content revision.** Once posted, an artifact is permanent;
|
||||
`content`/`kind`/`persona`/`day` are not PATCH-editable. The malformed test deck
|
||||
`5df27410` can only be superseded, not removed. Consider a soft-delete or
|
||||
`archived` flag for cleanup of test/mistake artifacts.
|
||||
- **No CSV/JSON/code renderer.** These store as download-only `binary`. Fine for
|
||||
now; the client can wrap them as Markdown on request. A dedicated renderer is
|
||||
the eventual fix.
|
||||
- **Single shared token, no reader auth.** Acceptable on a trusted LAN; revisit
|
||||
before any wider exposure.
|
||||
- **No pagination/cursors on list.** Fine at current volume; will matter once
|
||||
feeds grow.
|
||||
- **Strict-by-design rendering.** "Daybook does not fix malformed content" is a
|
||||
reasonable stance IF the standard publish path is a validating client. The skill
|
||||
is what makes that stance safe — keep them in sync (if the server adds a kind,
|
||||
the script's validation table must follow).
|
||||
|
||||
## Suggested Build Order
|
||||
|
||||
1. Write `scripts/daybook_post.py` (stdlib: `urllib`, hand-rolled multipart,
|
||||
extension→mime map, kind validation, stdin/file content, URL on last line).
|
||||
2. Smoke-test it against the live instance for all four text kinds + the three
|
||||
file kinds — same coverage as the manual test, but each via one command.
|
||||
3. Write `SKILL.md` (intent-keyed description + the cheat-sheet) and `.env.example`.
|
||||
4. Verify discovery: confirm an agent picks the skill from the description alone.
|
||||
5. Later/server: soft-delete or archive flag; CSV/JSON renderer; token hardening
|
||||
before the instance leaves the LAN.
|
||||
|
||||
## Appendix: Environment Facts (from test host)
|
||||
|
||||
- Prod instance reachable only on LAN `192.168.1.36:8000` — agents must be on-network.
|
||||
- Host is Windows PowerShell **5.1** (no `-Form`); `curl.exe` present via Git;
|
||||
Python **3.12** present. Python helper avoids all shell-specific pitfalls.
|
||||
- Inlining content into `powershell -Command` is the confirmed failure mode;
|
||||
file/stdin input is the fix.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Decision: Public REST API as the Universal Substrate
|
||||
|
||||
**Decided:** 2026-06-10 · **Status:** accepted direction; hardening deferred (see below)
|
||||
|
||||
### Core conclusion
|
||||
|
||||
The private LAN address (`192.168.1.36`) was the *only* thing forcing MCP and
|
||||
desktop-commander workarounds. Nothing about Daybook intrinsically requires them.
|
||||
Host Daybook on a publicly routable VM and the reachability wall disappears for
|
||||
every agent at once. The generic, universal interface is simply the **public
|
||||
HTTPS REST API** — any LLM or agent with an HTTP client can `POST /api/artifacts`
|
||||
in any language. A client script is optional sugar; MCP drops from "required" to
|
||||
"nice-to-have."
|
||||
|
||||
### The layering (everything hangs off the REST API)
|
||||
|
||||
| Layer | Role | Required? |
|
||||
|---|---|---|
|
||||
| Public HTTPS REST API | The universal substrate. This is the product. | Yes — core |
|
||||
| Thin client (`daybook_post.py`, path-based) | One-liner ergonomics over REST | Optional |
|
||||
| MCP server (local stdio) | Typed calls + auto-discovery for MCP-native agents | Optional polish |
|
||||
| Skill (`SKILL.md`) | Claude-specific pointer to the script/REST | Optional |
|
||||
|
||||
Build bottom-up: REST alone is enough to be generic. Add the others only where a
|
||||
specific client benefits.
|
||||
|
||||
### Reachability by environment (once Daybook is public)
|
||||
|
||||
| Environment | Can it POST directly? | Notes |
|
||||
|---|---|---|
|
||||
| Claude Code / laptop / CI / arbitrary agent | Yes | Open internet + HTTP client. The 95% case. |
|
||||
| claude.ai / Desktop chat sandbox (`bash_tool`) | Yes, after one toggle | Add the Daybook domain to the bash **egress allowlist** in network settings; then a sandbox python script reads a sandbox-generated file and POSTs. No desktop-commander, no MCP. |
|
||||
| claude.ai `web_fetch` | No | GET-oriented public fetcher; cannot do an authenticated POST with a body. Use `bash_tool` instead. |
|
||||
|
||||
Key point: in the all-public case the file-location problem also dissolves — the
|
||||
generated file and the posting script are co-located in the same sandbox, and the
|
||||
target is publicly routable.
|
||||
|
||||
### Deferred: auth hardening for the LAN -> public transition
|
||||
|
||||
The reachability story gets *simpler* when public; the auth story gets *stricter*.
|
||||
The single shared dev token is fine on a trusted LAN and unacceptable on the open
|
||||
internet. Before any real public exposure:
|
||||
|
||||
- [ ] Per-agent API keys (revocable, attributable), not one shared token
|
||||
- [ ] TLS / HTTPS only
|
||||
- [ ] Rate limiting + upload size/abuse guards
|
||||
- [ ] Write-scoping per persona (an agent can only post as its own persona)
|
||||
- [ ] Basic audit log of who posted what
|
||||
|
||||
### NOT needed for current testing (explicit)
|
||||
|
||||
For testing the basic idea and playing with dummy documents, **none of the above
|
||||
is required**. The current setup is sufficient:
|
||||
|
||||
- Keep running on the LAN (`192.168.1.36:8000`) with the single dev token.
|
||||
- Post dummy artifacts via desktop-commander (this chat) or the CLI (Claude Code).
|
||||
- Treat the auth-hardening checklist as a gate that applies *only* when Daybook
|
||||
moves to a public host for real use — not before.
|
||||
|
||||
Sequencing: prove the posting ergonomics and rendering with dummy docs first;
|
||||
take on public deployment + auth hardening only once the basic idea is validated.
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
Django>=5.0,<6.0
|
||||
djangorestframework>=3.15,<4.0
|
||||
Markdown>=3.6,<4.0
|
||||
gunicorn>=23.0,<24.0
|
||||
whitenoise>=6.7,<7.0
|
||||
|
|
|
|||
26
scripts/README.md
Normal file
26
scripts/README.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Daybook Posting Helper
|
||||
|
||||
`scripts/daybook_post.py` is a compatibility wrapper for the canonical zero-dependency helper at `skills/daybook-posting/scripts/daybook_post.py`.
|
||||
|
||||
The helper is for agents and scripts that need to publish artifacts to Daybook without hand-crafting JSON, curl, or multipart requests.
|
||||
|
||||
Configuration:
|
||||
|
||||
```text
|
||||
DAYBOOK_URL=http://127.0.0.1:8000
|
||||
DAYBOOK_TOKEN=dev-token
|
||||
DAYBOOK_PERSONA=agent
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
python skills/daybook-posting/scripts/daybook_post.py --check
|
||||
python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" --content-file report.md --tags build,report
|
||||
cat report.md | python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report"
|
||||
python skills/daybook-posting/scripts/daybook_post.py --kind deck --title "Plan" --content-file deck.html
|
||||
python skills/daybook-posting/scripts/daybook_post.py --file diagram.svg --title "Architecture Diagram"
|
||||
python skills/daybook-posting/scripts/daybook_post.py --file results.csv --as-markdown --title "Results CSV"
|
||||
```
|
||||
|
||||
The artifact browser URL is printed as the final stdout line.
|
||||
16
scripts/daybook_post.py
Normal file
16
scripts/daybook_post.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Compatibility wrapper for the Daybook posting helper.
|
||||
|
||||
The canonical helper lives inside the self-contained skill folder:
|
||||
skills/daybook-posting/scripts/daybook_post.py
|
||||
"""
|
||||
|
||||
import runpy
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
HELPER = Path(__file__).resolve().parent.parent / "skills" / "daybook-posting" / "scripts" / "daybook_post.py"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
runpy.run_path(str(HELPER), run_name="__main__")
|
||||
54
skills/daybook-posting/SKILL.md
Normal file
54
skills/daybook-posting/SKILL.md
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
---
|
||||
name: daybook-posting
|
||||
description: Use when you have produced a finished deliverable such as a report, summary, brief, diagram, generated HTML, PDF, deck, or status update that a human should review later outside this chat. Publishes the artifact to Daybook and returns a URL.
|
||||
---
|
||||
|
||||
# Daybook Posting
|
||||
|
||||
Daybook is a private artifact journal for completed agent outputs. It stores, indexes, and displays artifacts for human review. It does not execute commands, run workflows, or continue conversations.
|
||||
|
||||
Always use the helper script. Do not hand-build curl, JSON, PowerShell multipart, or inline shell content.
|
||||
|
||||
From the project root:
|
||||
|
||||
```bash
|
||||
python skills/daybook-posting/scripts/daybook_post.py --check
|
||||
python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report" --content-file report.md
|
||||
cat report.md | python skills/daybook-posting/scripts/daybook_post.py --kind markdown --title "Build Report"
|
||||
python skills/daybook-posting/scripts/daybook_post.py --file diagram.svg --title "Architecture Diagram"
|
||||
```
|
||||
|
||||
The project also keeps a compatibility wrapper at `scripts/daybook_post.py`, but the canonical helper is bundled inside this skill.
|
||||
|
||||
The artifact URL is printed as the final stdout line.
|
||||
|
||||
## Configuration
|
||||
|
||||
Use environment variables:
|
||||
|
||||
```text
|
||||
DAYBOOK_URL=http://127.0.0.1:8000
|
||||
DAYBOOK_TOKEN=dev-token
|
||||
DAYBOOK_PERSONA=agent
|
||||
```
|
||||
|
||||
## Kind Cheat Sheet
|
||||
|
||||
| Kind | Use for | Content shape |
|
||||
|---|---|---|
|
||||
| `markdown` | Reports, briefs, notes | Markdown text. |
|
||||
| `html_fragment` | Themed HTML reports/cards/tables | Body-only HTML. No `html/head/body`. |
|
||||
| `html_full` | Complete custom pages/frameworks | Full HTML document with doctype/html/head/body. |
|
||||
| `deck` | Simple slide-like artifact | Top-level `<section>...</section>` blocks. |
|
||||
|
||||
For images, PDFs, SVGs, and other files, use `--file`; the helper infers multipart and content type.
|
||||
|
||||
For CSV, JSON, or code that should render today, use `--file path --as-markdown` to post it as a Markdown fenced code block. Without `--as-markdown`, these upload as downloadable binary files.
|
||||
|
||||
## Rules
|
||||
|
||||
- Run `--check` first if unsure Daybook is reachable.
|
||||
- Use a stable `DAYBOOK_PERSONA` or pass `--persona`.
|
||||
- Use `--tags a,b,c` for filtering.
|
||||
- Use `--meta run_id=... --meta model=...` for machine-readable context.
|
||||
- Never inline multiline content into the shell command; use a file or stdin.
|
||||
378
skills/daybook-posting/scripts/daybook_post.py
Normal file
378
skills/daybook-posting/scripts/daybook_post.py
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Post artifacts to Daybook without shell-specific JSON or multipart quoting.
|
||||
|
||||
Zero third-party dependencies. Content comes from a file or stdin so agents do not
|
||||
need to inline multiline JSON/HTML/Markdown in shell commands.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urljoin
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
TEXT_KINDS = {"markdown", "html_fragment", "html_full", "deck"}
|
||||
TEXT_SUFFIXES = {".md", ".markdown", ".txt", ".html", ".htm"}
|
||||
DEFAULT_URL = "http://127.0.0.1:8000"
|
||||
DEFAULT_TOKEN = "dev-token"
|
||||
DEFAULT_PERSONA = "agent"
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
base_url = os.environ.get("DAYBOOK_URL", DEFAULT_URL).rstrip("/")
|
||||
token = os.environ.get("DAYBOOK_TOKEN", DEFAULT_TOKEN)
|
||||
persona = args.persona or os.environ.get("DAYBOOK_PERSONA", DEFAULT_PERSONA)
|
||||
|
||||
if args.check:
|
||||
response = request_json("GET", f"{base_url}/api/capabilities")
|
||||
print(json.dumps(response, indent=2))
|
||||
return 0
|
||||
|
||||
try:
|
||||
if args.path:
|
||||
resolve_auto_path(args)
|
||||
payload = build_payload(args, persona)
|
||||
validate_payload(payload, args)
|
||||
if args.dry_run:
|
||||
print_dry_run(base_url, token, payload, args)
|
||||
return 0
|
||||
response = post_payload(base_url, token, payload, args)
|
||||
except ValueError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
except (HTTPError, URLError) as exc:
|
||||
print_http_error(exc)
|
||||
return 1
|
||||
|
||||
print(json.dumps(response, indent=2))
|
||||
print(response.get("url", ""))
|
||||
return 0
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="Publish an artifact to Daybook.")
|
||||
parser.add_argument("path", nargs="?", help="Auto mode: one file path. Kind, content-type, and title are inferred. The simplest way to post.")
|
||||
parser.add_argument("--kind", choices=sorted(TEXT_KINDS), help="Text artifact kind. Omit when using --file or a positional path.")
|
||||
parser.add_argument("--title", help="Artifact title. Required unless --check is used.")
|
||||
parser.add_argument("--persona", help="Artifact persona. Defaults to DAYBOOK_PERSONA or 'agent'.")
|
||||
parser.add_argument("--content-file", help="Read text artifact content from this file.")
|
||||
parser.add_argument("--file", help="Upload this file as image/pdf/binary, or render as markdown with --as-markdown.")
|
||||
parser.add_argument("--tags", help="Comma-separated tags.")
|
||||
parser.add_argument("--theme", help="Theme for markdown/html_fragment. Example: report, tasks, bootstrap.")
|
||||
parser.add_argument("--meta", action="append", default=[], help="Metadata key=value. Repeatable.")
|
||||
parser.add_argument("--meta-json", help="Metadata JSON object. Merged with --meta values.")
|
||||
parser.add_argument("--source-name", help="Human-readable source name. Defaults to persona.")
|
||||
parser.add_argument("--source-id", default="", help="Stable source identifier.")
|
||||
parser.add_argument("--source-prompt", default="", help="Prompt/instruction that produced the artifact.")
|
||||
parser.add_argument("--source-type", choices=["agent", "person", "system"], default="agent")
|
||||
parser.add_argument("--as-markdown", action="store_true", help="Wrap --file content in a Markdown fenced code block instead of uploading it.")
|
||||
parser.add_argument("--check", action="store_true", help="Fetch /api/capabilities and exit.")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Print request details without posting.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.check:
|
||||
return args
|
||||
|
||||
if args.path:
|
||||
if args.file or args.content_file:
|
||||
parser.error("provide a positional path OR --file/--content-file, not both")
|
||||
return args # kind and title are inferred in resolve_auto_path
|
||||
|
||||
if not args.title:
|
||||
parser.error("--title is required (or pass a positional path to infer it, or use --check)")
|
||||
if args.file and args.content_file:
|
||||
parser.error("use either --file or --content-file, not both")
|
||||
if args.file and args.kind and not args.as_markdown:
|
||||
parser.error("omit --kind when using --file unless --as-markdown is set")
|
||||
if not args.file and not args.kind:
|
||||
parser.error("--kind is required for text artifacts")
|
||||
return args
|
||||
|
||||
|
||||
def resolve_auto_path(args):
|
||||
"""Auto mode: turn a single positional path into the equivalent explicit args.
|
||||
|
||||
Text files (.md/.html/.txt) become a text artifact with a sniffed kind; every
|
||||
other file is uploaded as multipart with an inferred content type. Title is
|
||||
inferred from the content or the filename when not given. This is the path that
|
||||
keeps agents from ever building JSON or choosing a kind by hand.
|
||||
"""
|
||||
path = Path(args.path)
|
||||
if not path.is_file():
|
||||
raise ValueError(f"path does not exist: {path}")
|
||||
|
||||
suffix = path.suffix.lower()
|
||||
if suffix in TEXT_SUFFIXES and not args.as_markdown:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
kind = sniff_text_kind(suffix, text)
|
||||
args.content_file = str(path)
|
||||
args.kind = kind
|
||||
if not args.title:
|
||||
args.title = derive_title(path, kind, text)
|
||||
else:
|
||||
# image / pdf / binary -> multipart upload; code/data -> markdown wrap if asked
|
||||
args.file = str(path)
|
||||
args.kind = None
|
||||
if not args.title:
|
||||
args.title = humanize(path.stem)
|
||||
return args
|
||||
|
||||
|
||||
def sniff_text_kind(suffix, text):
|
||||
if suffix in (".md", ".markdown", ".txt"):
|
||||
return "markdown"
|
||||
lower = text.lower()
|
||||
if "<!doctype" in lower or "<html" in lower:
|
||||
return "html_full"
|
||||
if "<section" in lower:
|
||||
return "deck"
|
||||
return "html_fragment"
|
||||
|
||||
|
||||
def derive_title(path, kind, text):
|
||||
if kind == "markdown":
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("# "):
|
||||
return stripped[2:].strip()
|
||||
else:
|
||||
match = re.search(r"<title[^>]*>(.*?)</title>", text, re.IGNORECASE | re.DOTALL)
|
||||
if match and match.group(1).strip():
|
||||
return match.group(1).strip()
|
||||
match = re.search(r"<h1[^>]*>(.*?)</h1>", 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 "<section" not in content_lower:
|
||||
raise ValueError("deck needs top-level <section>...</section> blocks, not Markdown or unwrapped headings")
|
||||
if kind == "html_fragment" and ("<html" in content_lower or "<!doctype" in content_lower or "<body" in content_lower):
|
||||
raise ValueError("html_fragment must be body-only HTML; do not include doctype/html/head/body")
|
||||
if kind == "html_full" and "<html" not in content_lower:
|
||||
raise ValueError("html_full must be a complete HTML document with <html> (and ideally <!doctype html>)")
|
||||
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"<content length={len(payload['content'])}>"
|
||||
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())
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,13 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Daybook</title>
|
||||
<title>AgentFeed</title>
|
||||
<link rel="stylesheet" href="/static/daybook.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brandline">
|
||||
<h1>Daybook</h1>
|
||||
<p>Agent Artifacts</p>
|
||||
<h1>AgentFeed</h1>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<form method="get" class="filter">
|
||||
|
|
@ -44,12 +43,16 @@
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<main class="feed">
|
||||
<main class="feed"
|
||||
data-selected-day="{{ selected_day|date:'Y-m-d' }}"
|
||||
data-selected-persona="{{ selected_persona }}"
|
||||
data-selected-kind="{{ selected_kind }}"
|
||||
data-title-query="{{ title_query }}">
|
||||
<aside class="day-sidebar" aria-label="Days">
|
||||
<h2>Days</h2>
|
||||
<nav class="day-list">
|
||||
{% for day in days %}
|
||||
<a class="day-link {% if day == selected_day %}selected{% endif %}" href="?day={{ day|date:'Y-m-d' }}{% if selected_persona %}&persona={{ selected_persona|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&view={{ selected_view }}">
|
||||
<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>{{ day|date:"D" }}{% if day == today %}<em class="today-badge">Today</em>{% endif %}</span>
|
||||
<strong>{{ day|date:"Y-m-d" }}</strong>
|
||||
</a>
|
||||
|
|
@ -65,6 +68,10 @@
|
|||
<span>{{ artifacts|length }} artifact{{ artifacts|length|pluralize }}</span>
|
||||
</div>
|
||||
|
||||
<button class="new-artifacts-banner" type="button" hidden data-new-artifacts-banner>
|
||||
<strong data-new-artifacts-count>0</strong> new artifact<span data-new-artifacts-plural>s</span> available
|
||||
</button>
|
||||
|
||||
<div class="artifact-list">
|
||||
{% for artifact in artifacts %}
|
||||
<a class="artifact-row {% if selected_artifact.id == artifact.id %}selected{% endif %}"
|
||||
|
|
@ -74,18 +81,16 @@
|
|||
data-kind="{{ artifact.kind }}"
|
||||
data-rendered-url="{% url 'artifact' artifact.id %}"
|
||||
data-raw-url="{% url 'artifact' artifact.id %}?raw=1">
|
||||
<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>
|
||||
<time class="artifact-time" datetime="{{ artifact.created_at|date:'c' }}">{{ artifact.created_at|date:"H:i" }}</time>
|
||||
<span class="artifact-row-main">
|
||||
<strong>{{ artifact.created_at|date:"H:i" }}</strong>
|
||||
<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">
|
||||
<h3>No artifacts</h3>
|
||||
<p>This day is ready for new agent output.</p>
|
||||
<p>No artifacts exist for this day yet.</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
|
@ -124,9 +129,13 @@
|
|||
</footer>
|
||||
{% else %}
|
||||
<div class="empty preview-empty">
|
||||
<h2>Select an artifact</h2>
|
||||
<p>Choose an item from the middle column to preview it here.</p>
|
||||
<p>POST to <code>/api/artifacts</code> with <code>Authorization: Bearer dev-token</code>.</p>
|
||||
{% if artifacts %}
|
||||
<h2>No artifact loaded</h2>
|
||||
<p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p>
|
||||
{% else %}
|
||||
<h2>No artifacts for this day</h2>
|
||||
<p>This day is ready for new agent output.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
|
@ -179,6 +188,11 @@
|
|||
|
||||
const previewPane = document.querySelector(".preview-pane");
|
||||
const rows = Array.from(document.querySelectorAll(".artifact-row"));
|
||||
const dayLinks = Array.from(document.querySelectorAll(".day-link"));
|
||||
const initialArtifactIds = new Set(rows.map((row) => row.dataset.artifactId));
|
||||
const newArtifactsBanner = document.querySelector("[data-new-artifacts-banner]");
|
||||
const newArtifactsCount = document.querySelector("[data-new-artifacts-count]");
|
||||
const newArtifactsPlural = document.querySelector("[data-new-artifacts-plural]");
|
||||
|
||||
const escapeHtml = (value) => value.replace(/[&<>"]/g, (character) => ({
|
||||
"&": "&",
|
||||
|
|
@ -200,11 +214,21 @@
|
|||
}, 1200);
|
||||
};
|
||||
const selectedRow = () => document.querySelector(".artifact-row.selected");
|
||||
let navigationMode = selectedRow() ? "artifacts" : "days";
|
||||
const urlFor = (row, view) => view === "raw" ? row.dataset.rawUrl : row.dataset.renderedUrl;
|
||||
const pageUrlFor = (row, view) => {
|
||||
const url = new URL(row.href, window.location.origin);
|
||||
url.searchParams.set("artifact", row.dataset.artifactId);
|
||||
url.searchParams.set("view", view);
|
||||
url.searchParams.delete("preview");
|
||||
return url;
|
||||
};
|
||||
|
||||
const noPreviewUrl = () => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete("artifact");
|
||||
url.searchParams.set("preview", "none");
|
||||
url.searchParams.set("view", selectedView);
|
||||
return url;
|
||||
};
|
||||
|
||||
|
|
@ -224,6 +248,7 @@
|
|||
};
|
||||
|
||||
const renderPreview = (row, pushState = true) => {
|
||||
navigationMode = "artifacts";
|
||||
rows.forEach((candidate) => candidate.classList.toggle("selected", candidate === row));
|
||||
const source = urlFor(row, selectedView);
|
||||
const title = escapeHtml(row.dataset.title);
|
||||
|
|
@ -246,6 +271,18 @@
|
|||
if (pushState) window.history.pushState({}, "", pageUrlFor(row, selectedView));
|
||||
};
|
||||
|
||||
const clearPreview = () => {
|
||||
navigationMode = "days";
|
||||
if (document.activeElement && document.activeElement.classList.contains("artifact-row")) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
rows.forEach((candidate) => candidate.classList.remove("selected"));
|
||||
previewPane.innerHTML = `<div class="empty preview-empty"><h2>No artifact loaded</h2><p>Press <kbd>Right Arrow</kbd> or select an artifact from the middle column to preview it.</p></div>`;
|
||||
window.history.pushState({}, "", noPreviewUrl());
|
||||
const currentDay = document.querySelector(".day-link.selected");
|
||||
if (currentDay) currentDay.focus({preventScroll: true});
|
||||
};
|
||||
|
||||
rows.forEach((row) => {
|
||||
row.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
|
|
@ -253,6 +290,119 @@
|
|||
});
|
||||
});
|
||||
|
||||
const moveSelection = (direction) => {
|
||||
if (!rows.length) return;
|
||||
navigationMode = "artifacts";
|
||||
const current = selectedRow();
|
||||
const currentIndex = current ? rows.indexOf(current) : -1;
|
||||
const nextIndex = Math.max(0, Math.min(rows.length - 1, currentIndex + direction));
|
||||
const nextRow = rows[nextIndex === -1 ? 0 : nextIndex];
|
||||
renderPreview(nextRow);
|
||||
nextRow.focus({preventScroll: true});
|
||||
nextRow.scrollIntoView({block: "nearest"});
|
||||
};
|
||||
|
||||
const moveDay = (direction) => {
|
||||
if (!dayLinks.length) return;
|
||||
navigationMode = "days";
|
||||
const current = document.querySelector(".day-link.selected");
|
||||
const currentIndex = current ? dayLinks.indexOf(current) : 0;
|
||||
const nextIndex = Math.max(0, Math.min(dayLinks.length - 1, currentIndex + direction));
|
||||
const nextDay = dayLinks[nextIndex];
|
||||
if (nextDay && nextDay !== current) window.location.href = nextDay.href;
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.ctrlKey || event.metaKey || event.shiftKey) return;
|
||||
if (["INPUT", "SELECT", "TEXTAREA", "BUTTON"].includes(event.target.tagName)) return;
|
||||
|
||||
if (event.altKey && event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
moveDay(1);
|
||||
} else if (event.altKey && event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
moveDay(-1);
|
||||
} else if (event.altKey) {
|
||||
return;
|
||||
} else if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
if (navigationMode === "days") {
|
||||
moveDay(1);
|
||||
} else {
|
||||
moveSelection(1);
|
||||
}
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
if (navigationMode === "days") {
|
||||
moveDay(-1);
|
||||
} else {
|
||||
moveSelection(-1);
|
||||
}
|
||||
} else if (event.key === "PageDown") {
|
||||
if (navigationMode === "artifacts") {
|
||||
event.preventDefault();
|
||||
moveSelection(5);
|
||||
}
|
||||
} else if (event.key === "PageUp") {
|
||||
if (navigationMode === "artifacts") {
|
||||
event.preventDefault();
|
||||
moveSelection(-5);
|
||||
}
|
||||
} else if (event.key === "ArrowRight") {
|
||||
if (!selectedRow() && rows.length) {
|
||||
event.preventDefault();
|
||||
renderPreview(rows[0]);
|
||||
rows[0].focus({preventScroll: true});
|
||||
rows[0].scrollIntoView({block: "nearest"});
|
||||
} else if (selectedRow()) {
|
||||
navigationMode = "artifacts";
|
||||
}
|
||||
} else if (event.key === "ArrowLeft") {
|
||||
if (selectedRow()) {
|
||||
event.preventDefault();
|
||||
clearPreview();
|
||||
} else {
|
||||
navigationMode = "days";
|
||||
const currentDay = document.querySelector(".day-link.selected");
|
||||
if (currentDay) currentDay.focus({preventScroll: true});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const artifactListUrl = () => {
|
||||
const url = new URL("/api/artifacts", window.location.origin);
|
||||
const params = url.searchParams;
|
||||
if (feed.dataset.selectedDay) params.set("day", feed.dataset.selectedDay);
|
||||
if (feed.dataset.selectedPersona) params.set("persona", feed.dataset.selectedPersona);
|
||||
if (feed.dataset.selectedKind) params.set("kind", feed.dataset.selectedKind);
|
||||
if (feed.dataset.titleQuery) params.set("q", feed.dataset.titleQuery);
|
||||
return url;
|
||||
};
|
||||
|
||||
const showNewArtifactsBanner = (count) => {
|
||||
if (!newArtifactsBanner || count < 1) return;
|
||||
newArtifactsCount.textContent = String(count);
|
||||
newArtifactsPlural.hidden = count === 1;
|
||||
newArtifactsBanner.hidden = false;
|
||||
};
|
||||
|
||||
const checkForNewArtifacts = async () => {
|
||||
try {
|
||||
const response = await fetch(artifactListUrl(), {headers: {Accept: "application/json"}});
|
||||
if (!response.ok) return;
|
||||
const artifacts = await response.json();
|
||||
const unseenCount = artifacts.filter((artifact) => !initialArtifactIds.has(artifact.id)).length;
|
||||
showNewArtifactsBanner(unseenCount);
|
||||
} catch {
|
||||
// Polling is opportunistic; leave the current feed untouched on errors.
|
||||
}
|
||||
};
|
||||
|
||||
if (newArtifactsBanner) {
|
||||
newArtifactsBanner.addEventListener("click", () => window.location.reload());
|
||||
window.setInterval(checkForNewArtifacts, 5000);
|
||||
}
|
||||
|
||||
previewPane.addEventListener("click", (event) => {
|
||||
const copyButton = event.target.closest("[data-copy-url]");
|
||||
if (copyButton) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue