feat(attachments): inline chip previews (image/pdf thumbnail, audio player, text snippet)

- core/thumbnails.py + GET .../attachments/{id}/thumbnail: server-rendered PNG
  thumbnails (image downscale; pdf first page via pypdfium2). Extracted a shared
  ownership-gated blob resolver used by both get_content and the thumbnail route
- buildAttachmentPreview (composer_attachments.js): image/pdf -> thumbnail,
  audio -> <audio> player, text -> lazy snippet; reused by the composer chips and
  the sent-message pills (interactive.js). Cookie auth, so direct media src works
- chip kind icons now cover pdf/audio; the upload swap adopts the server's
  authoritative kind for styling + icon + preview
- chat.css preview styling; tests for make_thumbnail
This commit is contained in:
Patrick Buckley
2026-06-15 16:41:53 -07:00
parent 8ad6d3d2f3
commit c09ba6041f
7 changed files with 352 additions and 36 deletions
+3 -1
View File
@@ -36,7 +36,9 @@ async def _stub(_request: Request) -> JSONResponse:
def _attach() -> AttachmentHandlers:
return AttachmentHandlers(upload=_stub, list=_stub, get_content=_stub, delete=_stub)
return AttachmentHandlers(
upload=_stub, list=_stub, get_content=_stub, thumbnail=_stub, delete=_stub
)
def _route_paths(routes: list[Any]) -> list[tuple[str, frozenset[str]]]:
+51
View File
@@ -0,0 +1,51 @@
"""Tests for attachment thumbnail generation (image downscale + pdf first page)."""
from __future__ import annotations
from turnstone.core.thumbnails import make_thumbnail
PNG_1x1 = (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
b"\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\rIDATx\x9cc\xfc\xcf"
b"\xc0\xc0\xc0\x00\x00\x00\x05\x00\x01\xa5\xf6E@\x00\x00\x00\x00IEND\xaeB`\x82"
)
_PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
def _minimal_pdf(text: str = "Hi") -> bytes:
stream = b"BT /F1 24 Tf 20 60 Td (" + text.encode("latin-1") + b") Tj ET"
objs = [
b"<</Type/Catalog/Pages 2 0 R>>",
b"<</Type/Pages/Kids[3 0 R]/Count 1>>",
b"<</Type/Page/Parent 2 0 R/MediaBox[0 0 300 144]"
b"/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>",
b"<</Length %d>>\nstream\n%s\nendstream" % (len(stream), stream),
b"<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>",
]
pdf = b"%PDF-1.4\n"
offsets = []
for i, obj in enumerate(objs, 1):
offsets.append(len(pdf))
pdf += b"%d 0 obj\n%s\nendobj\n" % (i, obj)
xref = len(pdf)
pdf += b"xref\n0 %d\n0000000000 65535 f \n" % (len(objs) + 1)
for off in offsets:
pdf += b"%010d 00000 n \n" % off
pdf += b"trailer\n<</Size %d/Root 1 0 R>>\nstartxref\n%d\n%%%%EOF" % (len(objs) + 1, xref)
return pdf
class TestMakeThumbnail:
def test_image_thumbnail_is_png(self) -> None:
out = make_thumbnail(PNG_1x1, "image")
assert out is not None and out[:8] == _PNG_MAGIC
def test_pdf_thumbnail_is_png(self) -> None:
out = make_thumbnail(_minimal_pdf(), "pdf")
assert out is not None and out[:8] == _PNG_MAGIC
def test_audio_has_no_thumbnail(self) -> None:
assert make_thumbnail(b"RIFFfake", "audio") is None
def test_garbage_image_returns_none(self) -> None:
assert make_thumbnail(b"not an image", "image") is None
+75 -32
View File
@@ -483,6 +483,7 @@ class AttachmentHandlers:
upload: Handler # POST {prefix}/{ws_id}/attachments
list: Handler # GET {prefix}/{ws_id}/attachments
get_content: Handler # GET {prefix}/{ws_id}/attachments/{attachment_id}/content
thumbnail: Handler # GET {prefix}/{ws_id}/attachments/{attachment_id}/thumbnail
delete: Handler # DELETE {prefix}/{ws_id}/attachments/{attachment_id}
@@ -630,6 +631,13 @@ def register_session_routes(
methods=["GET"],
)
)
routes.append(
Route(
f"{p}/{{ws_id}}/attachments/{{attachment_id}}/thumbnail",
a.thumbnail,
methods=["GET"],
)
)
routes.append(
Route(
f"{p}/{{ws_id}}/attachments/{{attachment_id}}",
@@ -3816,10 +3824,18 @@ def make_attachment_handlers(cfg: SessionEndpointConfig) -> AttachmentHandlers:
]
return JSONResponse({"attachments": rows})
async def get_content(request: Request) -> Response:
import asyncio
async def _resolve_served_blob(
request: Request,
) -> tuple[bytes, str, str, str] | Response:
"""Gate + resolve an attachment blob for serving (content or thumbnail).
from starlette.responses import Response as _Response
Returns ``(body, kind, mime, filename)`` or an error ``Response``.
Pending (staged) blobs serve from the buffer scoped to the uploader;
committed blobs serve from the store gated by ownership — the requester
(already gated to own ``ws_id``) must have a turn whose ref-list names the
id. Cross-user / cross-ws / unreferenced → 404 so existence doesn't leak.
"""
import asyncio
from turnstone.core.attachment_buffer import get_attachment_buffer
from turnstone.core.memory import attachment_referenced_in_ws, get_attachment
@@ -3834,38 +3850,38 @@ def make_attachment_handlers(cfg: SessionEndpointConfig) -> AttachmentHandlers:
user_id, err = await _resolve_owner(request, ws_id)
if err:
return err
# Pending (staged) blobs serve straight from the buffer, scoped to the
# uploader. Committed blobs serve from the store, gated by ownership:
# the requester (already gated to own ``ws_id``) must have a turn whose
# ref-list names the id. Cross-user / cross-ws / unreferenced → 404 so
# existence doesn't leak.
kind: str
stored_mime: str
filename: str
staged = get_attachment_buffer().get(attachment_id, ws_id=ws_id, user_id=user_id)
if staged is not None:
body: bytes = staged.content
kind = staged.kind
stored_mime = staged.mime_type or "application/octet-stream"
filename = staged.filename or "attachment"
else:
# Both committed-blob gates are sync DB I/O — the ref check is an
# unbounded ws-scoped LIKE scan (O(turns-in-ws)) run on every
# committed-image request, so keep it off the event loop. Matches
# the asyncio.to_thread convention used throughout this module.
row = await asyncio.to_thread(get_attachment, attachment_id)
if not row or not await asyncio.to_thread(
attachment_referenced_in_ws, attachment_id, ws_id
):
return JSONResponse({"error": "Not found"}, status_code=404)
body = row.get("content") or b""
kind = row.get("kind") or ""
stored_mime = row.get("mime_type") or "application/octet-stream"
filename = str(row.get("filename") or "attachment")
return (
staged.content,
staged.kind,
staged.mime_type or "application/octet-stream",
staged.filename or "attachment",
)
# Committed-blob gates are sync DB I/O — the ref check is an unbounded
# ws-scoped LIKE scan (O(turns-in-ws)), so keep it off the event loop.
row = await asyncio.to_thread(get_attachment, attachment_id)
if not row or not await asyncio.to_thread(
attachment_referenced_in_ws, attachment_id, ws_id
):
return JSONResponse({"error": "Not found"}, status_code=404)
return (
row.get("content") or b"",
row.get("kind") or "",
row.get("mime_type") or "application/octet-stream",
str(row.get("filename") or "attachment"),
)
async def get_content(request: Request) -> Response:
from starlette.responses import Response as _Response
resolved = await _resolve_served_blob(request)
if not isinstance(resolved, tuple):
return resolved
body, kind, stored_mime, filename = resolved
# Force text/plain for text kinds — avoids same-origin HTML/SVG
# rendering if a user uploaded an HTML-ish text file. Images
# keep their sniffed MIME (allowlist is strict: png/jpeg/gif/webp).
# rendering if a user uploaded an HTML-ish text file. Images keep their
# sniffed MIME (allowlist is strict: png/jpeg/gif/webp).
response_mime = "text/plain; charset=utf-8" if kind == "text" else stored_mime
safe_name = filename.replace('"', "").replace("\r", "").replace("\n", "")
headers = {
@@ -3876,6 +3892,32 @@ def make_attachment_handlers(cfg: SessionEndpointConfig) -> AttachmentHandlers:
}
return _Response(body, media_type=response_mime, headers=headers)
async def get_thumbnail(request: Request) -> Response:
import asyncio
from starlette.responses import Response as _Response
from turnstone.core.thumbnails import make_thumbnail
resolved = await _resolve_served_blob(request)
if not isinstance(resolved, tuple):
return resolved
body, kind, _mime, _filename = resolved
if kind not in ("image", "pdf"):
return JSONResponse({"error": "no thumbnail for this attachment kind"}, status_code=415)
png = await asyncio.to_thread(make_thumbnail, body, kind)
if png is None:
return JSONResponse({"error": "thumbnail unavailable"}, status_code=415)
return _Response(
png,
media_type="image/png",
headers={
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": "default-src 'none'; sandbox",
"Cache-Control": "private, max-age=300",
},
)
async def delete_(request: Request) -> Response:
from turnstone.core.attachment_buffer import get_attachment_buffer
@@ -3900,6 +3942,7 @@ def make_attachment_handlers(cfg: SessionEndpointConfig) -> AttachmentHandlers:
upload=upload,
list=list_pending,
get_content=get_content,
thumbnail=get_thumbnail,
delete=delete_,
)
+46
View File
@@ -0,0 +1,46 @@
"""Small PNG thumbnails of visual attachments, for the UI chip/preview.
``image`` → downscaled PNG; ``pdf`` → first page rendered (pypdfium2) then
downscaled. Audio and text have no thumbnail. Never raises — returns ``None`` on
any failure, and the UI falls back to a plain icon.
"""
from __future__ import annotations
from io import BytesIO
from turnstone.core.log import get_logger
log = get_logger(__name__)
_THUMB_MAX_PX = 160
def make_thumbnail(data: bytes, kind: str, *, max_px: int = _THUMB_MAX_PX) -> bytes | None:
"""Return a small PNG thumbnail for an ``image``/``pdf`` blob, else ``None``."""
try:
from PIL import Image
except ImportError: # pragma: no cover - declared dependency; defensive
log.warning("Pillow not installed; thumbnails unavailable")
return None
try:
if kind == "pdf":
from turnstone.core.pdf import rasterize_pdf
pages = rasterize_pdf(data, max_pages=1)
if not pages:
return None
img = Image.open(BytesIO(pages[0]))
elif kind == "image":
img = Image.open(BytesIO(data))
else:
return None
rgb = img.convert("RGB")
rgb.thumbnail((max_px, max_px))
buf = BytesIO()
rgb.save(buf, format="PNG")
return buf.getvalue()
except Exception as exc:
log.warning("thumbnail generation failed (kind=%s): %s", kind, exc)
return None
+41
View File
@@ -66,6 +66,47 @@
color: var(--red);
}
/* --- attachment previews (image/pdf thumbnail · audio player · text snippet) --- */
.composer-chip {
flex-wrap: wrap;
max-width: 340px;
}
.attach-preview-thumb {
width: 28px;
height: 28px;
object-fit: cover;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-base);
vertical-align: middle;
flex: 0 0 auto;
}
.attach-preview-audio {
height: 30px;
max-width: 240px;
vertical-align: middle;
flex: 1 1 auto;
}
.attach-preview-snippet {
display: inline-block;
color: var(--fg-dim);
font-size: 10px;
max-width: 240px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
flex: 1 1 auto;
}
.msg-user-attach-pill .attach-preview-thumb {
width: 44px;
height: 44px;
}
.msg-user-attach-pill .attach-preview-audio {
height: 32px;
max-width: 280px;
}
.composer-row {
display: flex;
align-items: flex-end;
+111 -1
View File
@@ -47,6 +47,82 @@ function _inferKind(file) {
return "text";
}
function _attachUrl(wsId, id, suffix) {
return (
"/v1/api/workstreams/" +
encodeURIComponent(wsId) +
"/attachments/" +
encodeURIComponent(id) +
suffix
);
}
function _kindIcon(kind) {
if (kind === "image") return "🖼";
if (kind === "audio") return "🎵";
return "📄"; // pdf + text
}
// Build an inline preview node for a committed attachment (real id), or null.
// image/pdf → server-rendered thumbnail; audio → <audio> player; text → a lazy
// snippet. Auth is cookie-based, so a plain media `src` works same-origin.
export function buildAttachmentPreview(opts) {
var kind = opts.kind,
wsId = opts.wsId,
id = opts.attachmentId;
if (!wsId || !id) return null;
if (kind === "image" || kind === "pdf") {
var img = document.createElement("img");
img.className = "attach-preview attach-preview-thumb";
img.loading = "lazy";
img.decoding = "async";
img.alt = "";
img.src = _attachUrl(wsId, id, "/thumbnail");
// Drop the node if the thumbnail can't render, so the icon shows instead.
img.addEventListener("error", function () {
img.remove();
});
return img;
}
if (kind === "audio") {
var audio = document.createElement("audio");
audio.className = "attach-preview attach-preview-audio";
audio.controls = true;
audio.preload = "none";
audio.src = _attachUrl(wsId, id, "/content");
return audio;
}
if (kind === "text") {
var snip = document.createElement("span");
snip.className = "attach-preview attach-preview-snippet";
var fetchFn =
(opts && opts.authFetch) ||
(typeof window !== "undefined" ? window.authFetch : null);
if (typeof fetchFn !== "function") return null;
fetchFn(_attachUrl(wsId, id, "/content"), {
method: "GET",
credentials: "include",
})
.then(function (r) {
return r && r.ok ? r.text() : "";
})
.then(function (t) {
if (!t) {
snip.remove();
return;
}
snip.textContent =
t.slice(0, 240).replace(/\s+/g, " ").trim() +
(t.length > 240 ? "…" : "");
})
.catch(function () {
snip.remove();
});
return snip;
}
return null;
}
function _toastError(msg) {
if (typeof window.toast !== "undefined" && window.toast.error) {
window.toast.error(msg);
@@ -89,7 +165,7 @@ export function createAttachmentController(opts) {
var icon = document.createElement("span");
icon.className = "composer-chip-icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = info.kind === "image" ? "🖼" : "📄";
icon.textContent = _kindIcon(info.kind);
chip.appendChild(icon);
var name = document.createElement("span");
@@ -117,10 +193,37 @@ export function createAttachmentController(opts) {
});
chip.appendChild(btn);
_applyPreview(chip, info);
chipsEl.appendChild(chip);
return chip;
}
// Insert a committed-attachment preview into a chip (image/pdf thumbnail,
// audio player, or a lazy text snippet). No-op for the in-flight placeholder
// (no real id yet); the swap re-applies once the real id lands.
function _applyPreview(chip, info) {
var idStr = "" + (info.attachment_id || "");
if (info.uploading || !idStr || idStr.indexOf("__uploading_") === 0) return;
var prev = buildAttachmentPreview({
kind: info.kind,
wsId: getWsId(),
attachmentId: info.attachment_id,
authFetch: _authFetch,
});
var old = chip.querySelector(".attach-preview");
if (old) old.remove();
if (!prev) return;
if (info.kind === "image" || info.kind === "pdf") {
var icon = chip.querySelector(".composer-chip-icon");
if (icon) icon.replaceWith(prev);
else chip.insertBefore(prev, chip.firstChild);
} else {
var btn = chip.querySelector(".composer-chip-remove");
if (btn) chip.insertBefore(prev, btn);
else chip.appendChild(prev);
}
}
function _findChip(id) {
return chipsEl.querySelector('[data-attachment-id="' + id + '"]');
}
@@ -159,6 +262,11 @@ export function createAttachmentController(opts) {
var chip = _findChip(placeholderId);
if (chip) {
chip.dataset.attachmentId = info.attachment_id;
// classify_upload on the server is authoritative — adopt its kind for the
// chip styling + icon, then render the preview now there's a real id.
chip.className = "composer-chip composer-chip-" + (info.kind || "other");
var swapIcon = chip.querySelector(".composer-chip-icon");
if (swapIcon) swapIcon.textContent = _kindIcon(info.kind);
var name = chip.querySelector(".composer-chip-name");
if (name) {
name.textContent = info.filename || "(unnamed)";
@@ -166,6 +274,7 @@ export function createAttachmentController(opts) {
}
var size = chip.querySelector(".composer-chip-size");
if (size) size.textContent = formatSize(info.size_bytes || 0);
_applyPreview(chip, info);
} else {
renderChip(info);
}
@@ -315,3 +424,4 @@ export function createAttachmentController(opts) {
// Still-classic consumers reach this as a global at event/boot time (after
// this deferred module evaluated). New module code imports instead.
window.createAttachmentController = createAttachmentController;
window.buildAttachmentPreview = buildAttachmentPreview;
+25 -2
View File
@@ -366,6 +366,7 @@ class Pane {
if (Array.isArray(attachments) && attachments.length > 0) {
const pills = document.createElement("div");
pills.className = "msg-user-attach";
const attachWsId = this.wsId;
attachments.forEach(function (a) {
const pill = document.createElement("span");
pill.className =
@@ -373,13 +374,35 @@ class Pane {
const icon = document.createElement("span");
icon.className = "msg-user-attach-icon";
icon.setAttribute("aria-hidden", "true");
icon.textContent = a.kind === "image" ? "\ud83d\uddbc" : "\ud83d\udcc4";
icon.textContent =
a.kind === "image"
? "\ud83d\uddbc"
: a.kind === "audio"
? "\ud83c\udfb5"
: "\ud83d\udcc4";
pill.appendChild(icon);
const nameEl = document.createElement("span");
nameEl.className = "msg-user-attach-name";
nameEl.textContent =
a.filename || (a.kind === "image" ? "image" : "document");
a.filename ||
(a.kind === "image"
? "image"
: a.kind === "audio"
? "audio"
: "document");
pill.appendChild(nameEl);
const prev =
typeof window.buildAttachmentPreview === "function"
? window.buildAttachmentPreview({
kind: a.kind,
wsId: attachWsId,
attachmentId: a.attachment_id,
})
: null;
if (prev) {
if (a.kind === "image" || a.kind === "pdf") icon.replaceWith(prev);
else pill.appendChild(prev);
}
pills.appendChild(pill);
});
el.appendChild(pills);