1305 lines
47 KiB
Markdown
1305 lines
47 KiB
Markdown
# v0.12.0 — File Handling + Vision (Design)
|
|
|
|
## Overview
|
|
|
|
File input into chat — table stakes for serious use. Image uploads for
|
|
vision-capable models, document uploads for text extraction into context,
|
|
and the storage backend that v0.14.0 (Knowledge Bases) and v0.15.0
|
|
(Compaction snapshots) will reuse.
|
|
|
|
Also picks up stragglers deferred from earlier releases.
|
|
|
|
**Depends on:** v0.11.0 (extension foundation — complete).
|
|
**Reused by:** v0.14.0 (KBs), v0.15.0 (compaction), v0.22.0 (exports).
|
|
|
|
---
|
|
|
|
## Stragglers (from v0.9.4 deferred)
|
|
|
|
Items deferred during vault work that were never scheduled:
|
|
|
|
### `switchboard vault rekey` CLI command
|
|
Re-encrypts all global/team API keys when `ENCRYPTION_KEY` is rotated.
|
|
Personal BYOK keys are unaffected (keyed to UEK, not env var).
|
|
|
|
Implementation: standalone CLI entrypoint that reads old + new env vars,
|
|
iterates `api_configs` and `team_providers` where `key_scope IN ('global',
|
|
'team')`, decrypts with old key, re-encrypts with new key, updates in a
|
|
transaction.
|
|
|
|
### Admin UI: encryption status indicator
|
|
Badge in admin Settings showing whether `ENCRYPTION_KEY` is set and how
|
|
many keys are encrypted vs. plaintext (migration stragglers). Single
|
|
`GET /admin/storage/status` endpoint.
|
|
|
|
### Per-chat model/preset persistence (server-side)
|
|
Currently localStorage bandaid from v0.10.2 — doesn't roam across devices.
|
|
|
|
**Fix:** Store `last_selector_id` in `channels.settings` JSONB on each
|
|
completion success. On chat selection, resolve:
|
|
`channel.settings.last_selector_id` → match against available
|
|
models/presets → fall back to `channel.model` → global default.
|
|
|
|
No migration needed (`channels.settings` JSONB column already exists).
|
|
Single `PATCH /channels/:id` with settings merge on completion success.
|
|
|
|
---
|
|
|
|
## Track 1: Storage Backend
|
|
|
|
### Environment Variables
|
|
|
|
| Variable | Default | Description |
|
|
|----------|---------|-------------|
|
|
| `STORAGE_BACKEND` | (auto) | `pvc` or `s3`. If not set: `pvc` when `STORAGE_PATH` is writable, disabled otherwise. |
|
|
| `STORAGE_PATH` | `/data/storage` | Mount point for PVC backend. Also scratch dir for extraction with S3. |
|
|
| `STORAGE_CLASS` | — | K8s only. Gitea CI variable → PVC manifest. Value: `cephfs` (RWX for multi-pod). |
|
|
| `S3_ENDPOINT` | — | S3-compatible endpoint (e.g. `http://minio:9000`). Required when `STORAGE_BACKEND=s3`. |
|
|
| `S3_BUCKET` | — | Bucket name. Must exist before first start. |
|
|
| `S3_ACCESS_KEY` | — | S3 access key ID. |
|
|
| `S3_SECRET_KEY` | — | S3 secret access key. |
|
|
| `S3_REGION` | `us-east-1` | AWS region. |
|
|
| `S3_PREFIX` | — | Optional key prefix for shared buckets (e.g. `switchboard/`). |
|
|
| `S3_FORCE_PATH_STYLE` | `true` | Path-style URLs. Required for MinIO, Ceph RGW, most self-hosted. |
|
|
| `EXTRACTION_MODE` | `inline` | `inline` (in-process, unified image) or `sidecar` (K8s, shared PVC). |
|
|
| `EXTRACTION_CONCURRENCY` | `3` | Max concurrent extraction jobs. Caps LibreOffice memory usage. |
|
|
|
|
Auto-detection when `STORAGE_BACKEND` is not set:
|
|
|
|
```
|
|
STORAGE_PATH writable → pvc (implicit)
|
|
STORAGE_PATH missing → file features disabled
|
|
admin panel shows "Storage not configured"
|
|
```
|
|
|
|
When `STORAGE_BACKEND=pvc` is explicit, fail startup if path is not
|
|
writable (fail-safe, same pattern as `ENCRYPTION_KEY` enforcement).
|
|
|
|
S3 backend: reads `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`,
|
|
`S3_REGION`, `S3_PREFIX`, `S3_FORCE_PATH_STYLE` from env vars. Uses minio-go v7.
|
|
Works with MinIO, Ceph RGW, AWS S3. Same interface as PVC — all handlers are
|
|
backend-agnostic. PVC still required as scratch dir for extraction queue.
|
|
|
|
### Config Addition
|
|
|
|
```go
|
|
// In config.go
|
|
StorageBackend string // "pvc", "s3", or "" (auto-detect)
|
|
StoragePath string // mount point for PVC backend
|
|
ExtractionMode string // "inline" or "sidecar"
|
|
ExtractionConcurrency int // max concurrent extraction jobs
|
|
```
|
|
|
|
### Interface
|
|
|
|
```go
|
|
// server/storage/storage.go
|
|
package storage
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
)
|
|
|
|
// ObjectStore is the abstraction for blob storage.
|
|
// Implementations: PVC (filesystem), S3 (minio-go v7).
|
|
type ObjectStore interface {
|
|
// Put writes data to the given key. Creates parent dirs as needed.
|
|
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
|
|
|
|
// Get returns a reader for the given key. Caller must close.
|
|
// Returns ErrNotFound if key does not exist.
|
|
Get(ctx context.Context, key string) (io.ReadCloser, int64, string, error)
|
|
|
|
// Delete removes the object at key. No error if already gone.
|
|
Delete(ctx context.Context, key string) error
|
|
|
|
// DeletePrefix removes all objects under the given prefix.
|
|
// Used for channel deletion (bulk cleanup).
|
|
DeletePrefix(ctx context.Context, prefix string) error
|
|
|
|
// Exists checks if an object exists at key without reading it.
|
|
Exists(ctx context.Context, key string) (bool, error)
|
|
|
|
// Healthy returns nil if the backend is operational.
|
|
Healthy(ctx context.Context) error
|
|
}
|
|
|
|
var ErrNotFound = errors.New("storage: object not found")
|
|
```
|
|
|
|
### PVC Implementation
|
|
|
|
`server/storage/pvc.go` — ~100 lines. Thin wrapper around `os.*`:
|
|
|
|
```go
|
|
type PVCStore struct {
|
|
basePath string // e.g. "/data/storage"
|
|
}
|
|
|
|
func NewPVC(basePath string) (*PVCStore, error) {
|
|
// Validate basePath is writable (create test file, remove)
|
|
// Create top-level subdirs: attachments/
|
|
}
|
|
```
|
|
|
|
Key → filesystem path: `filepath.Join(basePath, key)`. The key itself
|
|
provides all the directory structure.
|
|
|
|
### Filesystem Layout
|
|
|
|
```
|
|
/data/storage/ ← STORAGE_PATH mount point
|
|
└── attachments/
|
|
└── {channel_id}/
|
|
└── {attachment_id}_{filename}
|
|
```
|
|
|
|
**Channel-first** because:
|
|
- Channel deletion → `DeletePrefix("attachments/{channel_id}/")` — one call.
|
|
- User deletion cascades to owned channels → same cleanup path.
|
|
- Group chats / multi-participant channels: all files in one place
|
|
regardless of who uploaded them.
|
|
- Quota enforcement uses PG (`SUM(size_bytes) WHERE user_id = $1`),
|
|
not filesystem walks.
|
|
|
|
Future subdirs at the `STORAGE_PATH` level (not this release):
|
|
- `knowledge-bases/` — v0.14.0
|
|
- `compaction/` — v0.15.0
|
|
- `exports/` — TBD
|
|
|
|
### K8s Manifest (PVC)
|
|
|
|
```yaml
|
|
# k8s/storage-pvc.yaml
|
|
apiVersion: v1
|
|
kind: PersistentVolumeClaim
|
|
metadata:
|
|
name: switchboard-storage
|
|
namespace: switchboard
|
|
spec:
|
|
accessModes: [ReadWriteMany] # CephFS RWX — multi-pod safe
|
|
storageClassName: "${STORAGE_CLASS}"
|
|
resources:
|
|
requests:
|
|
storage: 10Gi # configurable per deployment
|
|
```
|
|
|
|
Backend deployment addition:
|
|
|
|
```yaml
|
|
# In k8s/backend.yaml
|
|
volumes:
|
|
- name: storage
|
|
persistentVolumeClaim:
|
|
claimName: switchboard-storage
|
|
containers:
|
|
- name: backend
|
|
volumeMounts:
|
|
- name: storage
|
|
mountPath: /data/storage
|
|
env:
|
|
- name: STORAGE_BACKEND
|
|
value: "pvc"
|
|
- name: STORAGE_PATH
|
|
value: "/data/storage"
|
|
```
|
|
|
|
### Docker Compose
|
|
|
|
```yaml
|
|
# In docker-compose.yml
|
|
services:
|
|
switchboard:
|
|
volumes:
|
|
- ./data/storage:/data/storage
|
|
environment:
|
|
- STORAGE_PATH=/data/storage
|
|
```
|
|
|
|
### Admin Status Endpoint
|
|
|
|
`GET /admin/storage/status`
|
|
|
|
```json
|
|
{
|
|
"backend": "pvc",
|
|
"path": "/data/storage",
|
|
"healthy": true,
|
|
"total_files": 142,
|
|
"total_bytes": 52428800,
|
|
"configured": true
|
|
}
|
|
```
|
|
|
|
When storage is not configured, file upload endpoints return 503 with
|
|
`{"error": "File storage not configured"}`. Frontend hides upload UI
|
|
based on a capabilities flag in the boot payload.
|
|
|
|
---
|
|
|
|
## Track 2: Attachments Schema + API
|
|
|
|
### Migration 007_attachments.sql
|
|
|
|
```sql
|
|
-- ── Attachments ────────────────────────────
|
|
CREATE TABLE IF NOT EXISTS attachments (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
user_id UUID NOT NULL REFERENCES users(id),
|
|
message_id UUID REFERENCES messages(id) ON DELETE SET NULL,
|
|
filename VARCHAR(255) NOT NULL,
|
|
content_type VARCHAR(127) NOT NULL,
|
|
size_bytes BIGINT NOT NULL,
|
|
storage_key TEXT NOT NULL,
|
|
extracted_text TEXT,
|
|
metadata JSONB DEFAULT '{}'::jsonb,
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
-- Access pattern: always join through channel for auth
|
|
CREATE INDEX idx_attachments_channel ON attachments(channel_id);
|
|
-- Quota pattern: sum by user
|
|
CREATE INDEX idx_attachments_user_size ON attachments(user_id);
|
|
-- Message association: find attachments for a message
|
|
CREATE INDEX idx_attachments_message ON attachments(message_id);
|
|
```
|
|
|
|
Notes on columns:
|
|
- `message_id` is nullable — attachment is uploaded before the message
|
|
is persisted. Updated after message creation. If message is deleted,
|
|
attachment persists (belongs to channel, not message).
|
|
- `storage_key` is the relative path within `STORAGE_PATH`, e.g.
|
|
`attachments/{channel_id}/{attachment_id}_{filename}`.
|
|
- `extracted_text` is populated asynchronously for PDFs/DOCX. NULL for
|
|
images. Used for context injection into completions.
|
|
- `metadata` JSONB holds dimensions (images), page count (PDFs),
|
|
extraction status, thumbnails, etc.
|
|
|
|
### Store Layer
|
|
|
|
```go
|
|
// server/store/attachments.go
|
|
type AttachmentStore interface {
|
|
Create(ctx context.Context, a *models.Attachment) error
|
|
GetByID(ctx context.Context, id string) (*models.Attachment, error)
|
|
GetByChannel(ctx context.Context, channelID string) ([]models.Attachment, error)
|
|
GetByMessage(ctx context.Context, messageID string) ([]models.Attachment, error)
|
|
SetMessageID(ctx context.Context, id, messageID string) error
|
|
Delete(ctx context.Context, id string) error
|
|
DeleteByChannel(ctx context.Context, channelID string) ([]string, error) // returns storage_keys for cleanup
|
|
UserUsageBytes(ctx context.Context, userID string) (int64, error)
|
|
}
|
|
```
|
|
|
|
### API Endpoints
|
|
|
|
```
|
|
POST /api/v1/channels/:id/attachments Upload file
|
|
GET /api/v1/attachments/:id Attachment metadata
|
|
GET /api/v1/attachments/:id/download Download file content
|
|
DELETE /api/v1/attachments/:id Delete attachment
|
|
GET /api/v1/channels/:id/attachments List channel attachments
|
|
```
|
|
|
|
### Upload Flow
|
|
|
|
```
|
|
Client Backend Storage
|
|
│ │ │
|
|
├── POST multipart ────────►│ │
|
|
│ (file + channel_id) │ │
|
|
│ ├── Auth check │
|
|
│ ├── Channel membership ─────│
|
|
│ │ verify │
|
|
│ ├── Size limit check │
|
|
│ ├── MIME type validation │
|
|
│ ├── Generate attachment_id │
|
|
│ ├── PUT ───────────────────►│
|
|
│ │ key: att/{ch}/{id}_{fn} │
|
|
│ ├── INSERT attachments row │
|
|
│ │ │
|
|
│◄── 201 { attachment } ────┤ │
|
|
```
|
|
|
|
The attachment is created *before* the message is sent. The frontend
|
|
holds the attachment ID(s), includes them in the completion request,
|
|
and the backend links them to the persisted message after creation.
|
|
|
|
### Download Flow (Access Control)
|
|
|
|
```
|
|
GET /api/v1/attachments/:id/download
|
|
|
|
→ SELECT a.*, c.id, c.user_id
|
|
FROM attachments a
|
|
JOIN channels c ON a.channel_id = c.id
|
|
WHERE a.id = $1
|
|
|
|
→ Verify: requester is channel owner
|
|
(future RBAC: OR requester is channel participant
|
|
OR requester is team member with channel access
|
|
OR requester is admin with audit log)
|
|
|
|
→ Stream file from ObjectStore with:
|
|
Content-Type: {content_type}
|
|
Content-Disposition: attachment; filename="{filename}"
|
|
Content-Length: {size_bytes}
|
|
```
|
|
|
|
**Critical:** Files are NEVER served directly from nginx or any static
|
|
file path. Every byte goes through the Go backend with auth checks.
|
|
No pre-signed URLs, no direct filesystem exposure.
|
|
|
|
### Size & Type Limits
|
|
|
|
Configured via global settings (see **Global Settings** section below).
|
|
Enforced server-side on upload — frontend mirrors limits for UX but the
|
|
backend is authoritative.
|
|
|
|
Validation order on upload:
|
|
1. `storage_allowed_types` — reject disallowed MIME types
|
|
2. `storage_max_file_size` — reject oversized individual files
|
|
3. `storage_max_upload_size` — reject if request total exceeds limit
|
|
4. `storage_max_attachments_per_message` — reject if too many files
|
|
5. `storage_user_quota_bytes` — reject if user quota exceeded (future)
|
|
|
|
MIME type is detected server-side (`http.DetectContentType` + extension
|
|
validation), not trusted from the client `Content-Type` header.
|
|
|
|
---
|
|
|
|
## Track 3: Multimodal Message Assembly
|
|
|
|
### Provider Message Polymorphism
|
|
|
|
Currently `providers.Message.Content` is a plain string. Vision requires
|
|
content arrays with image parts. The change is additive — no existing
|
|
callsites break.
|
|
|
|
```go
|
|
// In server/providers/provider.go
|
|
|
|
// ContentPart represents one part of a multimodal message.
|
|
type ContentPart struct {
|
|
Type string `json:"type"` // "text", "image_url"
|
|
Text string `json:"text,omitempty"` // for type="text"
|
|
ImageURL *ImageURL `json:"image_url,omitempty"` // for type="image_url"
|
|
}
|
|
|
|
type ImageURL struct {
|
|
URL string `json:"url"` // "data:image/jpeg;base64,..." or URL
|
|
Detail string `json:"detail,omitempty"` // "auto", "low", "high"
|
|
}
|
|
|
|
type Message struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
|
|
// Multimodal content. When non-nil, providers use this instead of Content.
|
|
ContentParts []ContentPart `json:"content_parts,omitempty"`
|
|
|
|
// ... existing tool fields unchanged
|
|
}
|
|
```
|
|
|
|
**Resolution rule:** if `ContentParts` is non-nil, providers build their
|
|
wire format from it. If nil, they use `Content` (string) as today. This
|
|
means every existing codepath works unchanged until an attachment is
|
|
involved.
|
|
|
|
### Provider Wire Format Mapping
|
|
|
|
**OpenAI / OpenRouter / Venice** (OpenAI-compatible):
|
|
|
|
```go
|
|
// In openai.go — message conversion
|
|
if len(m.ContentParts) > 0 {
|
|
// Content becomes array of objects
|
|
oaiMsg.Content = nil // clear string content
|
|
oaiMsg.ContentArray = toOpenAIContentParts(m.ContentParts)
|
|
}
|
|
```
|
|
|
|
OpenAI wire format:
|
|
```json
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "What's in this image?"},
|
|
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}}
|
|
]
|
|
}
|
|
```
|
|
|
|
The `openaiMessage` struct needs a `ContentArray` field with custom JSON
|
|
marshaling — when `ContentArray` is set, marshal content as the array;
|
|
otherwise marshal as string. This is the standard OpenAI multimodal
|
|
format.
|
|
|
|
**Anthropic:**
|
|
|
|
```go
|
|
// In anthropic.go — message conversion
|
|
if len(m.ContentParts) > 0 {
|
|
antMsg.Content = toAnthropicContentBlocks(m.ContentParts)
|
|
}
|
|
```
|
|
|
|
Anthropic wire format:
|
|
```json
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "What's in this image?"},
|
|
{"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "..."}}
|
|
]
|
|
}
|
|
```
|
|
|
|
The `anthropicContentBlock` struct gets new fields:
|
|
|
|
```go
|
|
type anthropicContentBlock struct {
|
|
// ... existing fields ...
|
|
// Image support
|
|
Source *anthropicImageSource `json:"source,omitempty"`
|
|
}
|
|
|
|
type anthropicImageSource struct {
|
|
Type string `json:"type"` // "base64"
|
|
MediaType string `json:"media_type"` // "image/jpeg", "image/png", etc.
|
|
Data string `json:"data"` // base64-encoded image
|
|
}
|
|
```
|
|
|
|
### Image Delivery: Base64 Inline
|
|
|
|
For v0.12.0, all images are sent as base64-encoded data inline in the
|
|
API request. No pre-signed URLs.
|
|
|
|
**Rationale:** The target audience is government/enterprise, often in
|
|
air-gapped or restricted networks. Pre-signed URLs require the provider's
|
|
API servers to reach the storage endpoint — impossible in disconnected
|
|
environments. Base64 works everywhere.
|
|
|
|
**Trade-off:** Larger request payloads. Acceptable for v0.12.0 — images
|
|
are typically <10MB each and context windows are large. URL-based
|
|
delivery can be an optimization in a future release for cloud deployments.
|
|
|
|
### Completion Handler Changes
|
|
|
|
```go
|
|
// In completion.go — after loading conversation, before building provReq
|
|
|
|
// If message has attachments, build multimodal content
|
|
if len(req.AttachmentIDs) > 0 {
|
|
parts := []providers.ContentPart{
|
|
{Type: "text", Text: req.Content},
|
|
}
|
|
for _, attID := range req.AttachmentIDs {
|
|
att, err := h.store.Attachments().GetByID(c, attID)
|
|
// ... verify att.channel_id matches ...
|
|
|
|
if isImageType(att.ContentType) {
|
|
// Verify vision capability
|
|
if !caps.Vision {
|
|
c.JSON(400, gin.H{"error": "Selected model does not support image input"})
|
|
return
|
|
}
|
|
|
|
// Read from storage, base64 encode
|
|
reader, _, _, _ := h.objectStore.Get(c, att.StorageKey)
|
|
data, _ := io.ReadAll(reader)
|
|
reader.Close()
|
|
b64 := base64.StdEncoding.EncodeToString(data)
|
|
dataURL := fmt.Sprintf("data:%s;base64,%s", att.ContentType, b64)
|
|
|
|
parts = append(parts, providers.ContentPart{
|
|
Type: "image_url",
|
|
ImageURL: &providers.ImageURL{URL: dataURL, Detail: "auto"},
|
|
})
|
|
} else if att.ExtractedText != nil && *att.ExtractedText != "" {
|
|
// Document: inject extracted text as context
|
|
parts = append(parts, providers.ContentPart{
|
|
Type: "text",
|
|
Text: fmt.Sprintf("[Document: %s]\n%s", att.Filename, *att.ExtractedText),
|
|
})
|
|
} else {
|
|
// Extraction not yet complete or failed — include filename only
|
|
status := att.Metadata["extraction_status"]
|
|
parts = append(parts, providers.ContentPart{
|
|
Type: "text",
|
|
Text: fmt.Sprintf("[Attached file: %s (extraction %s)]", att.Filename, status),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Use ContentParts instead of Content string
|
|
userMsg := providers.Message{
|
|
Role: "user",
|
|
ContentParts: parts,
|
|
}
|
|
messages = append(messages, userMsg)
|
|
} else {
|
|
// Existing text-only path — unchanged
|
|
messages = append(messages, providers.Message{
|
|
Role: "user",
|
|
Content: req.Content,
|
|
})
|
|
}
|
|
```
|
|
|
|
**Frontend blocks send only during active upload** — the send button
|
|
stays disabled while any staged attachment is in `uploading` state
|
|
(bytes still in flight). Once the file is on disk and the attachment
|
|
row exists in PG, send is enabled regardless of extraction status.
|
|
|
|
The completion handler checks `extraction_status` at request time:
|
|
- `complete` → inject extracted text as context
|
|
- anything else → inject filename-only placeholder
|
|
|
|
This avoids blocking users on slow extractions while still providing
|
|
extracted context when available. For images (no extraction), the
|
|
attachment is immediately usable.
|
|
|
|
### Capability Gating
|
|
|
|
Images are only assembled into content parts when the resolved model
|
|
has `capabilities.vision == true`. If a user attaches an image to a
|
|
non-vision model:
|
|
|
|
- Documents (PDF, DOCX, TXT): always allowed — extracted text is
|
|
injected as a text content part. No vision needed.
|
|
- Images to non-vision model: backend returns 400 with
|
|
`{"error": "Selected model does not support image input"}`.
|
|
- Frontend disables image upload button when selected model lacks vision.
|
|
|
|
### Text Extraction (Documents)
|
|
|
|
#### License Constraint
|
|
|
|
The project is Apache 2.0. Libraries with AGPL/GPL copyleft (e.g.
|
|
unipdf) are incompatible — their license would infect the codebase.
|
|
Pure Go PDF libraries under permissive licenses (`ledongthuc/pdf`,
|
|
`pdfcpu`) handle simple cases but choke on complex layouts, scanned
|
|
PDFs, and encrypted documents.
|
|
|
|
#### Approach: Tiered Extraction
|
|
|
|
**Tier 1 — Native Go (no external deps, handles ~80% of uploads):**
|
|
|
|
| Format | Library | License |
|
|
|--------|---------|---------|
|
|
| TXT, Markdown, CSV | `io.ReadAll` | — |
|
|
| PDF (simple) | `pdfcpu` | Apache 2.0 |
|
|
| DOCX | `fumiama/go-docx` | MIT |
|
|
|
|
**Tier 2 — LibreOffice Headless (handles everything else):**
|
|
|
|
For PDF with complex layouts, ODT, ODS, ODP, RTF, DOC (legacy), XLS,
|
|
and any format Tier 1 can't handle — shell out to LibreOffice headless:
|
|
|
|
```bash
|
|
libreoffice --headless --convert-to txt:Text /tmp/input.odt --outdir /tmp/
|
|
```
|
|
|
|
LibreOffice handles the full Office family:
|
|
- OpenDocument: `.odt`, `.ods`, `.odp`
|
|
- Microsoft: `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`
|
|
- PDF (including scanned with basic OCR via hunspell)
|
|
- RTF, HTML, and dozens more
|
|
|
|
#### Extraction Queue (Filesystem-Backed)
|
|
|
|
Extraction state lives on the PVC, not in memory. Pod restarts don't
|
|
lose queue state. The queue is a directory convention:
|
|
|
|
```
|
|
/data/storage/
|
|
├── attachments/ ← completed uploads (served to clients)
|
|
└── processing/ ← extraction queue (internal only)
|
|
└── {attachment_id}/
|
|
├── input ← original file (copy or hardlink)
|
|
├── status.json ← queue state + metadata
|
|
├── output.txt ← extracted text (when complete)
|
|
└── thumb.jpg ← thumbnail (when complete)
|
|
```
|
|
|
|
**status.json:**
|
|
|
|
```json
|
|
{
|
|
"state": "pending",
|
|
"queued_at": "2026-02-25T14:30:00Z",
|
|
"started_at": null,
|
|
"completed_at": null,
|
|
"content_type": "application/pdf",
|
|
"filename": "report.pdf",
|
|
"error": null
|
|
}
|
|
```
|
|
|
|
States: `pending` → `processing` → `complete` | `failed`
|
|
|
|
**Worker loop (runs in both inline and sidecar mode):**
|
|
|
|
```
|
|
1. Scan processing/ for dirs with status.state == "pending"
|
|
(ordered by queued_at — FIFO)
|
|
2. Respect concurrency cap (semaphore, default 3)
|
|
3. Pick job → flip state to "processing"
|
|
4. Tier 1: try native Go extraction
|
|
├── success → write output.txt, generate thumb, flip "complete"
|
|
└── fail or unsupported →
|
|
5. Tier 2: LibreOffice headless
|
|
├── success → write output.txt, generate thumb, flip "complete"
|
|
└── fail → flip "failed", write error
|
|
6. Copy results to PG:
|
|
├── UPDATE attachments SET extracted_text = ...,
|
|
│ metadata = jsonb_set(metadata, '{extraction_status}', '"complete"')
|
|
└── Thumbnail storage_key written to metadata
|
|
7. Clean up processing/{id}/ dir
|
|
```
|
|
|
|
**In inline mode** (Unified image): the worker is a goroutine pool in
|
|
the Go backend, scanning the queue directory on a 2-second tick.
|
|
|
|
**In sidecar mode** (K8s): the sidecar runs the same loop as a
|
|
standalone process. When extraction completes, it writes `output.txt`
|
|
and `thumb.jpg` to the processing dir and flips status to `complete`.
|
|
The Go backend polls the status file and copies results to PG.
|
|
|
|
**Crash recovery:** On startup, scan for `state == "processing"` (stale
|
|
from a crash). Reset to `pending` for re-processing. This is why the
|
|
queue is on the filesystem — nothing is lost.
|
|
|
|
**Concurrency:** Capped by semaphore. Default 3 concurrent extractions.
|
|
Configurable via `EXTRACTION_CONCURRENCY` env var. LibreOffice is
|
|
memory-hungry on complex docs — the cap prevents OOM on burst uploads.
|
|
|
|
**Frontend queue awareness:**
|
|
|
|
```
|
|
Attachment status:
|
|
uploading → progress bar
|
|
pending → "Queued (2 ahead)" ← position from queue scan
|
|
processing → "Extracting..." ← spinner
|
|
complete → ✓ (ready)
|
|
failed → ⚠ (send still enabled, model sees filename only)
|
|
```
|
|
|
|
The frontend polls `GET /api/v1/attachments/:id` every 2s for
|
|
non-terminal states. The response includes `extraction_status` and
|
|
`queue_position` (0 = currently processing, N = N jobs ahead).
|
|
|
|
Send is blocked while any attachment is `uploading`. Once all are at
|
|
least `pending`, send is enabled — the extraction results will be
|
|
available by the time the model processes the request (optimistic), or
|
|
the model sees the filename-only fallback (acceptable degradation).
|
|
|
|
**Correction to earlier design:** Send is blocked only during `uploading`
|
|
phase. Once the file is on disk and queued, the user can send. The
|
|
completion handler checks extraction status at request time and uses
|
|
whatever is available.
|
|
|
|
#### Extraction Metadata in PG
|
|
|
|
Attachment `metadata` JSONB tracks status (mirrored from queue):
|
|
|
|
```json
|
|
{
|
|
"extraction_status": "complete",
|
|
"extraction_error": null,
|
|
"queue_position": 0,
|
|
"page_count": 12,
|
|
"dimensions": {"width": 1920, "height": 1080},
|
|
"thumbnail_key": "attachments/{channel_id}/{id}_thumb.jpg",
|
|
"thumb_dimensions": {"width": 280, "height": 147}
|
|
}
|
|
```
|
|
|
|
States: `pending` → `processing` → `complete` | `failed` | `unavailable`
|
|
|
|
#### LibreOffice Deployment Strategy
|
|
|
|
**Two deployment modes, matching existing Docker architecture:**
|
|
|
|
| Deployment | LibreOffice location | Reason |
|
|
|-----------|---------------------|--------|
|
|
| **Unified** (docker-compose, dev) | Baked into image | Single container, simplicity wins |
|
|
| **K8s** (backend + frontend split) | Sidecar container | Separate scaling, lean backend image, independent version pinning |
|
|
|
|
**Unified Dockerfile addition:**
|
|
|
|
```dockerfile
|
|
# In Dockerfile (unified)
|
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
libreoffice-core libreoffice-writer libreoffice-calc \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
```
|
|
|
|
**K8s sidecar (backend.yaml addition):**
|
|
|
|
```yaml
|
|
# In k8s/backend.yaml
|
|
containers:
|
|
- name: backend
|
|
image: switchboard-backend:0.12.0
|
|
volumeMounts:
|
|
- name: storage
|
|
mountPath: /data/storage
|
|
env:
|
|
- name: STORAGE_BACKEND
|
|
value: "pvc"
|
|
- name: STORAGE_PATH
|
|
value: "/data/storage"
|
|
- name: EXTRACTION_MODE
|
|
value: "sidecar" # "inline" for unified, "sidecar" for k8s
|
|
|
|
- name: extractor
|
|
image: switchboard-extractor:0.12.0 # minimal image: alpine + libreoffice
|
|
volumeMounts:
|
|
- name: storage
|
|
mountPath: /data/storage
|
|
resources:
|
|
requests:
|
|
memory: "256Mi"
|
|
limits:
|
|
memory: "1Gi" # LibreOffice can spike on complex docs
|
|
|
|
volumes:
|
|
- name: storage
|
|
persistentVolumeClaim:
|
|
claimName: switchboard-storage
|
|
```
|
|
|
|
The sidecar image is small and purpose-built: Alpine + LibreOffice +
|
|
a watcher script. No Go code, no API surface. Upgrades to LibreOffice
|
|
are a tag bump on the sidecar image — backend image untouched.
|
|
|
|
**Communication:** Filesystem-only via the shared PVC. No gRPC, no
|
|
HTTP between containers. See Extraction Queue below.
|
|
|
|
#### Allowed Types (Updated)
|
|
|
|
```json
|
|
{
|
|
"storage_allowed_types": [
|
|
"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml",
|
|
"application/pdf",
|
|
"text/plain", "text/markdown", "text/csv",
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
"application/vnd.oasis.opendocument.text",
|
|
"application/vnd.oasis.opendocument.spreadsheet",
|
|
"application/vnd.oasis.opendocument.presentation",
|
|
"application/rtf",
|
|
"application/msword",
|
|
"application/vnd.ms-excel"
|
|
]
|
|
}
|
|
```
|
|
|
|
### Thumbnail Generation
|
|
|
|
Thumbnails are generated **asynchronously** alongside text extraction
|
|
as part of the same post-upload processing pipeline.
|
|
|
|
#### Image Thumbnails
|
|
|
|
Use `golang.org/x/image/draw` (BSD, stdlib-adjacent) for resizing:
|
|
|
|
```go
|
|
// Resize to max 280px on longest edge, preserve aspect ratio
|
|
// Output: JPEG at 80% quality
|
|
// Stored at: attachments/{channel_id}/{attachment_id}_thumb.jpg
|
|
```
|
|
|
|
No external dependency. Pure Go. Fast enough for on-upload generation.
|
|
|
|
#### Document Thumbnails
|
|
|
|
When LibreOffice is available, generate a first-page preview:
|
|
|
|
```bash
|
|
# Convert first page to PDF, then to image
|
|
libreoffice --headless --convert-to pdf /tmp/input.docx --outdir /tmp/
|
|
# Then use Go image libraries to render first page
|
|
# Or: convert to PNG directly via LibreOffice draw filter
|
|
libreoffice --headless --convert-to png /tmp/input.docx --outdir /tmp/
|
|
```
|
|
|
|
For PDFs specifically, `pdfcpu` can extract page images under Apache 2.0.
|
|
|
|
When LibreOffice is not available, documents get a generic file-type
|
|
icon (handled in CSS, no thumbnail row needed).
|
|
|
|
#### Storage
|
|
|
|
Thumbnails stored alongside originals with `_thumb` suffix:
|
|
|
|
```
|
|
/data/storage/attachments/{channel_id}/
|
|
├── {id}_report.pdf ← original
|
|
├── {id}_report.pdf_thumb.jpg ← thumbnail
|
|
├── {id}_photo.jpg ← original
|
|
└── {id}_photo.jpg_thumb.jpg ← thumbnail
|
|
```
|
|
|
|
Thumbnail URL returned in attachment metadata:
|
|
|
|
```json
|
|
{
|
|
"thumbnail_key": "attachments/{channel_id}/{id}_photo.jpg_thumb.jpg",
|
|
"dimensions": {"width": 1920, "height": 1080},
|
|
"thumb_dimensions": {"width": 280, "height": 147}
|
|
}
|
|
```
|
|
|
|
Download endpoint: `GET /api/v1/attachments/:id/thumbnail` — same
|
|
channel-scoped auth check as the full download.
|
|
|
|
---
|
|
|
|
## Track 4: Frontend
|
|
|
|
### Input Area Changes
|
|
|
|
The `<div class="input-wrap">` gets an attachment button and a preview
|
|
strip:
|
|
|
|
```
|
|
┌──────────────────────────────────────────────┐
|
|
│ ┌──────────┐ ┌──────────┐ │ ← attachment preview strip
|
|
│ │ image.png│ │ report.pdf│ │ (visible only when files
|
|
│ │ ✕ │ │ ✕ │ │ are staged)
|
|
│ └──────────┘ └──────────┘ │
|
|
├──────────────────────────────────────────────┤
|
|
│ 📎 │ Send a message... │ ■ │ ▶ │ ← existing input + new clip btn
|
|
└──────────────────────────────────────────────┘
|
|
```
|
|
|
|
### Upload Interactions
|
|
|
|
| Trigger | Behavior |
|
|
|---------|----------|
|
|
| 📎 button click | File picker dialog (filtered by allowed types) |
|
|
| Paste (Ctrl+V) binary | Auto-attach: screenshot/image → image attachment ({uuid}.{ext}) |
|
|
| Paste (Ctrl+V) large text | Auto-attach: text > threshold → text file ({uuid}.txt) |
|
|
| Paste (Ctrl+V) small text | Normal paste into textarea (below threshold) |
|
|
| Drag-and-drop on chat area | Auto-attach file(s) as attachments (max 5 per message) |
|
|
|
|
#### Smart Paste (Auto-Attach)
|
|
|
|
Paste events are intercepted and routed automatically — no prompts, no
|
|
user decisions. The clipboard content determines the behavior:
|
|
|
|
**Detection logic:**
|
|
|
|
```
|
|
Clipboard paste event
|
|
│
|
|
├── Contains files/images (binary)?
|
|
│ └── YES → auto-attach each item
|
|
│ ├── Image (screenshot, copied image) → upload as image attachment
|
|
│ ├── File (dragged from OS) → upload as file attachment
|
|
│ └── Filename: {uuid}.{ext} (ext from MIME type)
|
|
│
|
|
├── Contains text?
|
|
│ ├── Length ≤ threshold → normal paste into textarea
|
|
│ └── Length > threshold → auto-attach as text file
|
|
│ ├── Upload as attachment (content_type: text/plain)
|
|
│ ├── Filename: {uuid}.txt
|
|
│ └── Textarea stays empty (or keeps existing draft)
|
|
│
|
|
└── Empty → no-op
|
|
```
|
|
|
|
**Binary detection:** The `paste` event's `clipboardData.items` array
|
|
carries type information. Items with `type.startsWith('image/')` or
|
|
any non-text type are binary — always auto-attached. Text items
|
|
(`text/plain`, `text/html`) are checked against the threshold.
|
|
|
|
**UUID filenames:** All auto-attached pastes get UUID-based filenames.
|
|
No timestamps, no `pasted-text.txt` collision issues. The original
|
|
content type and paste source are captured in attachment metadata:
|
|
|
|
```json
|
|
{
|
|
"source": "clipboard",
|
|
"original_type": "image/png",
|
|
"paste_length": 4200
|
|
}
|
|
```
|
|
|
|
**Threshold:** `storage_paste_to_file_chars` global setting (default:
|
|
2000 characters, admin-configurable). Setting to 0 disables text
|
|
auto-attach (binary pastes still auto-attach regardless).
|
|
|
|
**UX flow for a screenshot paste:**
|
|
1. User hits Ctrl+V with a screenshot on clipboard
|
|
2. Image appears instantly in the attachment preview strip (uploading...)
|
|
3. Upload completes → thumbnail shown with checkmark
|
|
4. User types their question, hits send
|
|
5. Image goes to vision model as base64
|
|
|
|
**UX flow for a large log paste:**
|
|
1. User copies 5,000 chars of server logs, hits Ctrl+V
|
|
2. Text file chip appears in attachment preview strip: `📄 {uuid}.txt (4.9 KB)`
|
|
3. Extraction runs (trivial for plain text — near-instant)
|
|
4. User types "what's wrong with these logs?", hits send
|
|
5. Log content injected as extracted text context
|
|
|
|
No prompts, no dialogs, no decisions. Paste just works.
|
|
|
|
### State Management
|
|
|
|
Staged attachments live in `App.stagedAttachments[]` — an array of
|
|
`{id, filename, contentType, sizeBytes, previewUrl, extractionStatus}`
|
|
populated after the upload API returns. On send, attachment IDs are
|
|
included in the completion request body. On cancel/clear, staged
|
|
attachments are deleted via the API.
|
|
|
|
**Upload lifecycle per attachment:**
|
|
|
|
```
|
|
uploading → uploaded/queued → extracting → ready | failed
|
|
```
|
|
|
|
- `uploading`: progress bar visible, **send disabled**.
|
|
- `uploaded/queued`: file on disk, extraction queued. Queue position
|
|
shown: "Queued (2 ahead)". **Send enabled** — extraction will
|
|
complete before or during model processing, or the model gets
|
|
the filename-only fallback.
|
|
- `extracting`: spinner on thumbnail. Send remains enabled.
|
|
- `ready`: extraction complete (or image — no extraction needed).
|
|
Green checkmark.
|
|
- `failed`: extraction failed. Warning icon on thumbnail. Send still
|
|
enabled — model sees `[Attached file: X (extraction failed)]`.
|
|
|
|
**Key principle:** Send is blocked **only during active upload** (bytes
|
|
in flight). Once the file is on disk and the attachment row exists,
|
|
the user can send. This prevents the frustrating case where a user
|
|
types their message while a 50-page PDF extracts for 30 seconds.
|
|
|
|
**Polling:** Frontend polls `GET /api/v1/attachments/:id` every 2s for
|
|
any attachment with `extractionStatus == "pending" | "processing"`.
|
|
Stops when all settle. WebSocket notification is a future optimization.
|
|
Polling updates the queue position indicator and thumbnail status icon.
|
|
|
|
### Message Rendering
|
|
|
|
Attachments on sent/received messages render inline:
|
|
|
|
- **Images:** Thumbnail (from `_thumb.jpg`) with click-to-expand
|
|
(lightbox or side panel). Rendered in the message bubble below/above
|
|
the text.
|
|
- **Documents:** File chip with icon, filename, size, and download link.
|
|
`📄 report.pdf (2.3 MB) ⬇`
|
|
- **Pasted text files:** Same as documents — `📄 {uuid}.txt (4.9 KB) ⬇`.
|
|
Metadata `source: "clipboard"` available for future UX refinement.
|
|
|
|
Attachment data comes from `messages.metadata.attachment_ids` →
|
|
batch-fetched on conversation load via
|
|
`GET /channels/:id/attachments`.
|
|
|
|
### Capability-Aware UI
|
|
|
|
The frontend already knows model capabilities from the model selector.
|
|
|
|
**Images are always auto-attached regardless of current model** — the
|
|
user might switch models before sending. When the selected model has
|
|
`vision: false`:
|
|
|
|
- Image attachments show a subtle indicator: `👁️🗨️` icon dimmed with
|
|
tooltip "Current model doesn't support images"
|
|
- File picker filters to documents only, but paste/drop still accepts
|
|
images (user can switch models)
|
|
- The 📎 button tooltip says "Attach document" instead of "Attach file"
|
|
- On send with image + non-vision model: backend returns 400 with
|
|
`"Selected model does not support image input — switch to a vision
|
|
model or remove image attachments"`
|
|
- Document uploads (PDF, DOCX, ODT, etc.) always work — text
|
|
extraction doesn't require vision
|
|
|
|
When storage is not configured (boot payload flag), the 📎 button is
|
|
hidden entirely and all paste/drop auto-attach handlers are bypassed.
|
|
|
|
---
|
|
|
|
## Privacy & Access Control Model
|
|
|
|
### Principle
|
|
|
|
**Attachments have no access identity of their own. They inherit access
|
|
from their parent channel.** The channel membership check is the single
|
|
gate for all attachment operations.
|
|
|
|
### Access Matrix (v0.12.0 — Current Auth Model)
|
|
|
|
| Channel type | Who can upload | Who can download | Delete |
|
|
|---|---|---|---|
|
|
| Personal DM | Channel owner | Channel owner | Owner |
|
|
| (Future) Team channel | Team members with access | Team members with access | Uploader or team admin |
|
|
| (Future) Group chat | Channel participants | Channel participants | Uploader or channel owner |
|
|
| (Future) Workflow | Assigned participants | Assigned participants | Stage rules |
|
|
|
|
### Enforcement Points
|
|
|
|
Every attachment endpoint performs channel-scoped authorization:
|
|
|
|
```go
|
|
// Pseudocode — every handler follows this pattern
|
|
func (h *Handler) downloadAttachment(c *gin.Context) {
|
|
att := h.store.Attachments().GetByID(c, attID)
|
|
channel := h.store.Channels().GetByID(c, att.ChannelID)
|
|
|
|
// THE GATE: is the requester authorized for this channel?
|
|
if channel.UserID != requesterID {
|
|
c.JSON(403, gin.H{"error": "access denied"})
|
|
return
|
|
}
|
|
|
|
// Future RBAC (v0.20.0): replace the above with:
|
|
// if !h.rbac.CanAccess(requesterID, channel.ID, "attachment:read") {
|
|
// c.JSON(403, ...)
|
|
// }
|
|
|
|
h.objectStore.Get(c, att.StorageKey) // → stream to client
|
|
}
|
|
```
|
|
|
|
### Threat Mitigations
|
|
|
|
| Threat | Mitigation |
|
|
|--------|-----------|
|
|
| ID guessing / enumeration | Every download joins through channel + verifies requester membership. UUIDs are non-sequential. |
|
|
| Direct filesystem access | Files never served by nginx. All bytes go through Go backend with auth. No static file routes for storage path. |
|
|
| Cross-team data leak | Channel membership is the boundary. HR channel attachments are only accessible to HR channel participants. |
|
|
| Admin overreach | v0.12.0: admin can access (consistent with existing admin trust model). Audit-logged. v0.20.0: RBAC with team-scoped admin. |
|
|
| Metadata leak | Channel attachment list endpoint also requires channel membership. No global search across attachments. |
|
|
| Storage key prediction | Keys include UUID attachment_id — not guessable from channel_id + filename alone. |
|
|
| Upload as attack vector | MIME type validated against allowlist. File size enforced. Content-Type set from server-side detection, not client header. |
|
|
|
|
### Consistency with Existing Scoped Resources
|
|
|
|
The attachment access pattern is identical to the pattern used for all
|
|
scoped data in the system:
|
|
|
|
```
|
|
Resource Scope Access Gate
|
|
──────────────────────────────────────────────────
|
|
Notes (personal) user_id user_id = requester
|
|
Notes (team) team_id requester ∈ team members
|
|
Attachments channel_id requester ∈ channel (owner/participant)
|
|
Usage data user/team scoped queries
|
|
API keys user/org vault (UEK) / env-derived key
|
|
```
|
|
|
|
The row knows its scope. The handler enforces the boundary. The query
|
|
filters by it.
|
|
|
|
### Future RBAC Hooks (v0.20.0)
|
|
|
|
The v0.12.0 access checks are written as explicit `if` checks that
|
|
can be trivially replaced by a centralized RBAC call:
|
|
|
|
```go
|
|
// v0.12.0
|
|
if channel.UserID != requesterID { deny }
|
|
|
|
// v0.20.0
|
|
if !rbac.Can(requesterID, channel.ID, permission) { deny }
|
|
```
|
|
|
|
The schema, storage layer, and API surface don't change. Only the
|
|
authorization check function gets swapped.
|
|
|
|
---
|
|
|
|
## Implementation Order
|
|
|
|
```
|
|
Phase 1: Storage Backend
|
|
├── Config additions (STORAGE_BACKEND, STORAGE_PATH, EXTRACTION_MODE)
|
|
├── ObjectStore interface
|
|
├── PVC implementation (~100 lines)
|
|
├── Startup validation (writable check, health)
|
|
├── K8s manifest (PVC + volume mount + STORAGE_CLASS)
|
|
├── Docker-compose volume mount
|
|
├── Admin status endpoint
|
|
└── Boot payload: storage_configured flag
|
|
|
|
Phase 2: Attachments CRUD + Extraction Pipeline
|
|
├── Migration 007_attachments.sql
|
|
├── models.Attachment struct
|
|
├── AttachmentStore (PG CRUD)
|
|
├── handlers/attachments.go (upload, download, delete, list, thumbnail)
|
|
├── Channel-scoped access checks on all endpoints
|
|
├── MIME validation + size enforcement (server-side detection)
|
|
├── Filesystem cleanup on channel delete (CASCADE + DeletePrefix)
|
|
├── Extraction queue (filesystem-backed):
|
|
│ ├── processing/ directory convention + status.json
|
|
│ ├── Worker loop with semaphore (default 3 concurrent)
|
|
│ ├── Tier 1: native Go (pdfcpu, go-docx, io.ReadAll)
|
|
│ ├── Tier 2: LibreOffice headless (inline or sidecar)
|
|
│ ├── Crash recovery (reset stale "processing" → "pending")
|
|
│ └── PG metadata mirror (extraction_status, queue_position)
|
|
├── Thumbnail generation (images: Go resize, docs: LibreOffice)
|
|
├── Unified Dockerfile: add libreoffice-core
|
|
├── Sidecar Dockerfile: alpine + libreoffice + watcher
|
|
└── K8s backend.yaml: sidecar container definition
|
|
|
|
Phase 3: Multimodal Message Assembly
|
|
├── ContentPart + ImageURL types in providers/provider.go
|
|
├── OpenAI message conversion (content array marshaling)
|
|
├── Anthropic message conversion (image source blocks)
|
|
├── OpenRouter / Venice (inherit OpenAI path)
|
|
├── Completion handler: attachment → base64 → content parts
|
|
├── Completion handler: extraction_status check at request time
|
|
├── Capability gating (vision check before image assembly)
|
|
├── Document text injection (extracted_text → text content part)
|
|
└── Usage logging for multimodal requests
|
|
|
|
Phase 4: Frontend
|
|
├── Attachment button (📎) in input area
|
|
├── File picker with type filtering (capability-aware)
|
|
├── Smart paste handler:
|
|
│ ├── Binary detection (images, files → auto-attach)
|
|
│ ├── Large text detection (> threshold → auto-attach as {uuid}.txt)
|
|
│ ├── Small text → normal textarea paste
|
|
│ └── UUID filename generation
|
|
├── Drag-and-drop on chat area
|
|
├── Staged attachment preview strip with status indicators:
|
|
│ ├── Upload progress bar
|
|
│ ├── Queue position ("Queued (2 ahead)")
|
|
│ ├── Extraction spinner
|
|
│ ├── Ready checkmark / failed warning
|
|
│ └── Send blocked only during active upload
|
|
├── Extraction status polling (2s interval, stops at terminal state)
|
|
├── Attachment rendering in messages (thumbnails + doc chips)
|
|
├── Image lightbox / side panel expand
|
|
├── Capability-aware UI (hide/filter by vision support)
|
|
├── Storage-not-configured state (hide upload UI)
|
|
└── Admin Storage panel (status, orphan count, cleanup button)
|
|
|
|
Phase 5: Stragglers
|
|
├── vault rekey CLI command
|
|
├── Admin encryption status indicator
|
|
└── Per-chat model/preset persistence (server-side)
|
|
|
|
Phase 6: Admin Cleanup
|
|
├── POST /admin/storage/cleanup endpoint
|
|
├── Orphan detection query (message_id IS NULL, age > 24h)
|
|
├── Admin Storage panel: orphan count + reclaimable space card
|
|
└── "Run Cleanup Now" button
|
|
```
|
|
|
|
### Testing
|
|
|
|
- Integration tests: upload → send message with attachment → verify
|
|
multimodal completion request → verify download auth
|
|
- Access control tests: upload to channel A, attempt download from
|
|
user who owns channel B → 403
|
|
- Storage backend tests: put/get/delete/deletePrefix round-trip
|
|
- Extraction tests: PDF/DOCX/TXT → extracted_text populated
|
|
- Frontend tests: staged attachments state, capability gating
|
|
|
|
---
|
|
|
|
## Migration Summary
|
|
|
|
| Migration | Tables/Columns |
|
|
|-----------|---------------|
|
|
| 007_attachments.sql | `attachments` table with indexes |
|
|
|
|
No changes to existing tables. The `messages.metadata` JSONB carries
|
|
`attachment_ids` without schema changes. The `channels.settings` JSONB
|
|
carries `last_selector_id` without schema changes.
|
|
|
|
---
|
|
|
|
## Decisions Made
|
|
|
|
1. **Text extraction** — Tiered: native Go (pdfcpu Apache 2.0, go-docx
|
|
MIT) for common cases, LibreOffice headless for everything else
|
|
including ODF formats. No AGPL dependencies.
|
|
|
|
2. **Thumbnails** — Yes. Generated async post-upload. Images via Go
|
|
stdlib (`golang.org/x/image/draw`). Documents via LibreOffice
|
|
first-page render. Stored alongside originals with `_thumb` suffix.
|
|
|
|
3. **Max attachments per message** — 5 files (admin-configurable).
|
|
|
|
4. **Extraction timing** — Async with filesystem-backed queue. Upload
|
|
returns immediately, extraction runs in background via worker loop
|
|
with concurrency cap. Frontend polls for status. Send blocked only
|
|
during active upload (bytes in flight), not during extraction.
|
|
|
|
5. **Smart paste** — Automatic, no prompts. Binary clipboard items
|
|
(screenshots, images) always auto-attach. Text exceeding threshold
|
|
(default 2000 chars) auto-attaches as `{uuid}.txt`. Below threshold
|
|
pastes normally into textarea. UUID filenames, MIME detection from
|
|
clipboard data. Zero user decisions.
|
|
|
|
## Global Settings (New)
|
|
|
|
| Key | Default | Description |
|
|
|-----|---------|-------------|
|
|
| `storage_max_file_size` | `10485760` (10MB) | Per-file upload limit |
|
|
| `storage_max_upload_size` | `52428800` (50MB) | Per-request total limit |
|
|
| `storage_allowed_types` | *(see Allowed Types)* | MIME type allowlist |
|
|
| `storage_max_attachments_per_message` | `5` | Max files per message |
|
|
| `storage_paste_to_file_chars` | `2000` | Paste-to-file threshold (0=disabled) |
|
|
| `storage_user_quota_bytes` | `0` | Per-user quota (0=unlimited, future enforcement) |
|
|
|
|
## Decisions Made (Continued)
|
|
|
|
6. **Sidecar base image** — Debian slim. Rock-solid LibreOffice packages,
|
|
~80MB larger than Alpine but no flaky package issues.
|
|
|
|
7. **Queue persistence** — Dual: filesystem `status.json` is source of
|
|
truth (crash recovery, `ls` debuggability), mirrored to PG metadata
|
|
for API serving. Worth the bookkeeping for operational transparency.
|
|
|
|
## Open Questions
|
|
|
|
*(None remaining — all design decisions resolved.)*
|
|
|
|
## Orphan Cleanup (Admin Action)
|
|
|
|
Attachments uploaded but never associated with a message (user closes
|
|
tab, browser crash, abandoned upload) accumulate as orphans.
|
|
|
|
**v0.12.0: manual admin action with "Run Now" button.**
|
|
|
|
```
|
|
POST /admin/storage/cleanup
|
|
→ Scan attachments WHERE message_id IS NULL
|
|
AND created_at < NOW() - interval '24 hours'
|
|
→ Delete PG rows + storage files + processing dirs
|
|
→ Return { deleted: 3, freed_bytes: 15728640 }
|
|
```
|
|
|
|
**Admin Storage panel card:**
|
|
|
|
```
|
|
┌─ Orphan Files ──────────────────────────────┐
|
|
│ 3 orphaned attachments (15 MB reclaimable) │
|
|
│ │
|
|
│ Files not linked to any message for >24hrs │
|
|
│ │
|
|
│ [Run Cleanup Now] │
|
|
└─────────────────────────────────────────────┘
|
|
```
|
|
|
|
The 24-hour grace period prevents cleaning up files that are simply
|
|
in the middle of a long compose session.
|
|
|
|
**Future (v0.22.0 task runner):** This becomes a scheduled task running
|
|
daily. The admin button stays as an on-demand override. The endpoint
|
|
is identical — the scheduler just calls it on a cron.
|