Files
opensquilla--opensquilla/docs/features/attachment-drag-upload-spec.md
T
2026-07-13 13:12:33 +08:00

396 lines
15 KiB
Markdown

# Attachment Drag-and-Drop Upload Spec
Date: 2026-06-29
Status: Draft
## Problem
OpenSquilla should let users attach files by dragging them into the chat surface
in both the browser Web UI and the Electron desktop client. The behavior must be
consistent across surfaces and must use the existing gateway attachment
ingestion path rather than adding a parallel desktop-only file pipeline.
The current code already contains most of the upload path:
- `opensquilla-webui/src/views/ChatView.vue` listens for drag/drop on the chat
thread and passes dropped files to `addAttachment`.
- `opensquilla-webui/src/composables/chat/useChatAttachments.ts` validates file
type and size, inlines small files, and stages large files through
`/api/v1/files/upload`.
- `opensquilla-webui/src/composables/chat/useChatSend.ts` sends staged files as
`{ type, file_uuid, mime, name }`.
- `src/opensquilla/gateway/uploads.py` exposes the multipart upload route and
stores staged bytes behind an opaque `file_uuid`.
- `src/opensquilla/gateway/attachment_ingest.py` resolves `file_uuid` entries
into transcript material references before a turn runs.
- `desktop/electron/src/main.ts` creates a sandboxed, context-isolated browser
window; `desktop/electron/src/preload.cts` exposes only explicit desktop APIs.
The missing product-quality layer is a complete, reviewed drag-upload UX and
test contract that covers both browser and desktop surfaces, including auth,
error states, retry behavior, and cross-platform desktop validation.
## Goals
- Support dragging files from the OS/browser file picker into the Web UI chat
surface.
- Support the same drag behavior in the Electron desktop client without a
separate upload implementation.
- Preserve the existing attachment policy: max count, per-category size caps,
total size cap, MIME sniffing, and staged upload TTL. Any file type is
admitted; the MIME set only routes representation (rendered families are
extracted or inlined, everything else stages as an opaque workspace file).
- Make upload progress and failure states visible in the composer.
- Prevent sending while any attachment is still reading or uploading.
- Ensure token-authenticated gateways can upload files by using the same bearer
token source as the WebSocket/RPC client.
- Keep raw local file paths out of renderer code and out of chat payloads.
- Persist only stable transcript material references after `chat.send` accepts
the turn; do not persist `file_uuid` in transcripts.
## Non-Goals
- Do not implement folder drag-and-drop.
- Do not upload directory trees or recursively enumerate local paths.
- Do not expose arbitrary local filesystem reads through Electron preload.
- Do not make Electron bypass the gateway upload endpoint.
- Do not add resumable/chunked uploads in this iteration.
- Do not add per-type client-side gating: admission is any-type (opaque
staging), and rejections happen only on size/count policy, mirroring the
gateway's category router in `contracts/attachments.py`.
- Do not support remote URL drag/drop as file upload unless the browser supplies
a real `File` object.
## Existing Surfaces
### Web UI
- `ChatView.vue` owns high-level chat surface events, including drag/drop and
paste.
- `ChatComposer.vue` renders pending attachment chips and the file input.
- `useChatAttachments.ts` owns attachment state, MIME resolution, inline reads,
staged uploads, and local validation.
- `useChatSend.ts` serializes pending attachments into the `chat.send` RPC
payload.
### Gateway
- `contracts/attachments.py` defines allowed media types and size limits.
- `uploads.py` handles multipart staging and returns `file_uuid`.
- `attachment_ingest.py` validates inline and staged attachments, writes
transcript material, and returns consumed staged UUIDs.
- `rpc_sessions.py` evicts consumed `file_uuid` values only after the turn has
been accepted into the runtime.
### Desktop
- The Electron renderer runs the same Vue Web UI as the browser.
- The Electron window is sandboxed and context-isolated.
- The preload API currently exposes gateway status, settings, onboarding, and
artifact open helpers; it does not expose raw file reads.
## Recommended Design
Use one shared Web UI upload path for browser and Electron:
```text
User drops files
-> ChatView drop handler
-> useChatAttachments.addAttachments(files)
-> inline small files OR POST large files to /api/v1/files/upload
-> ChatComposer renders pending chips
-> useChatSend serializes attachments
-> chat.send RPC
-> gateway attachment_ingest resolves file_uuid
-> turn runtime receives stable attachment refs
```
Electron should not get a special upload bridge in the first iteration. When a
file is dragged from the OS into the Electron browser window, Chromium exposes
it to renderer code as a `File`. That is the same object shape the browser path
uses, so the renderer can call the same composable.
Add a desktop-specific bridge only if manual validation proves a platform cannot
produce usable `DataTransfer.files`, or if a later feature explicitly needs
native behavior such as folder selection, revealing local file paths, or
watching local files for changes.
## User Experience
### Drop Zone
The primary drop zone is the chat thread/composer area.
Expected behavior:
- `dragenter` / `dragover` with at least one file item shows a visible drop
affordance.
- `dragleave` uses a drag-depth counter or equivalent containment check so the
affordance does not flicker when moving across child elements.
- `drop` extracts only `File` objects and ignores non-file drag data.
- Dropping files focuses the composer and appends attachments to the pending
list.
- Unsupported items produce a toast and do not block valid files in the same
drop.
### Attachment Chips
Each pending attachment chip should show:
- name;
- MIME or concise type label;
- size;
- state: reading, uploading, ready, failed;
- thumbnail for image inline attachments when available;
- remove button;
- retry button for staged upload failures.
Sending is disabled while any attachment is `reading` or `uploading`.
### Empty Message Behavior
If the user sends with attachments and no text, the existing fallback message
`Describe these attachments` remains acceptable.
The visible bubble should still show the user's display text when present and
attachment chips/previews when attachments exist.
## Attachment State Model
Use an explicit state machine in `useChatAttachments.ts`.
Recommended internal states:
| State | Meaning | Sendable |
| --- | --- | --- |
| `inline_pending` | FileReader is reading a small file. | No |
| `inline` | Base64 data is ready in the pending payload. | Yes |
| `uploading` | Multipart upload is in progress. | No |
| `staged` | Gateway returned a `file_uuid`. | Yes |
| `failed` | Read or upload failed and can be removed or retried. | No |
The public type may keep the existing names, but code paths should treat them as
states rather than ad-hoc variants.
`addAttachment(file)` should be implemented in terms of
`addAttachments(files: File[])` so file picker, paste, and drag/drop share batch
validation behavior.
## Data Contracts
### Inline Attachment Payload
Small files are sent through `chat.send` as:
```json
{
"type": "image/png",
"mime": "image/png",
"name": "screenshot.png",
"data": "<base64>"
}
```
### Staged Upload Request
Large files are uploaded first:
```http
POST /api/v1/files/upload
Content-Type: multipart/form-data
Authorization: Bearer <token>
```
Multipart fields:
- `file`: the file bytes and filename;
- `mime`: the client-resolved MIME, used only as a claim; gateway revalidates.
Expected success response:
```json
{
"file_uuid": "u-...",
"filename": "report.pdf",
"mime": "application/pdf",
"size": 12345
}
```
### Staged `chat.send` Payload
After staging, `chat.send` sends:
```json
{
"type": "application/pdf",
"mime": "application/pdf",
"name": "report.pdf",
"file_uuid": "u-..."
}
```
The gateway resolves this into a stable attachment ref and must not persist
`file_uuid` into transcript envelopes.
## Auth Requirements
The multipart upload route intentionally requires header-based auth in token
mode. Query-string token auth must remain rejected for uploads.
Vue upload requests must therefore include the same token source used by the
WebSocket/RPC connection:
- read `sessionStorage.getItem("opensquilla.wsToken")`;
- when present, set `Authorization: Bearer <token>`;
- keep `credentials: "same-origin"` for same-origin cookies and browser policy;
- never place the token in the upload URL.
This is important because the current Vue staged upload path uses
`credentials: "same-origin"` but does not include an authorization header. The
legacy static chat upload path already includes the bearer token and should be
used as the behavior reference.
## Security and Safety Rules
- Frontend validation is advisory UX only; gateway validation remains
authoritative.
- The renderer must never send local filesystem paths in chat payloads.
- Electron preload must not expose an arbitrary `readFile(path)` API for this
feature.
- `file_uuid` is a short-lived upload-store identifier, not a durable reference.
- Staged upload bytes are evicted only after the turn is accepted.
- Failed `chat.send` after successful staging must keep the staged file retryable
until TTL expiry.
- MIME sniffing mismatches are handled by gateway policy, not by trusting the
browser-provided `File.type`.
- Directory entries and zero-byte files should be rejected with clear UX.
## Implementation Plan
### Frontend
1. Add `addAttachments(files: File[])` in `useChatAttachments.ts`.
2. Route file picker, paste, and drop through `addAttachments`.
3. Add drag-depth or containment-safe drop-zone state in `ChatView.vue`.
4. Show a drop affordance only when the drag payload contains files.
5. Add upload auth headers to staged upload requests.
6. Add retry handling for staged upload failures.
7. Keep `ChatComposer.vue` presentational: props down, events up.
8. Ensure `useChatSend.ts` does not clear failed attachments as if they were
successfully queued.
### Gateway
1. Keep `/api/v1/files/upload` as the only staged upload endpoint.
2. Keep the upload route header-token requirement unchanged.
3. Confirm `UploadStore` and `attachment_ingest` reject unsupported MIME, over
size, unknown UUID, restart-lost UUID, and total size overflow.
4. Add targeted tests only where the new frontend contract exposes gaps.
### Desktop
1. Validate drag/drop in packaged or dev Electron on macOS, Windows, and Linux
where available.
2. Keep Electron preload unchanged unless validation proves native bridging is
required.
3. If a bridge becomes required, expose only a narrow capability such as
`stageDroppedFile(handle)` and keep all byte validation in the gateway.
## Test Plan
### Frontend Unit Tests
- `resolveAttachmentMime()` prefers allowed browser MIME and falls back to
extension.
- Unknown UTF-8 text degrades to `text/plain`.
- Unknown binary files are rejected.
- Oversize files are rejected by per-MIME cap.
- `addAttachments()` handles mixed valid and invalid files without dropping the
valid ones.
- Inline files transition from `inline_pending` to `inline`.
- Large stageable files transition from `uploading` to `staged`.
- Failed staged upload leaves a failed/removable state.
- Upload requests include `Authorization: Bearer <token>` when the token exists.
- Upload requests do not put the token in the URL.
### Vue Component Tests
- Dragging files over the chat surface shows the drop affordance.
- Dragging non-file data does not show the drop affordance.
- Dropping files calls the attachment composable once with all dropped files.
- Sending is disabled while any attachment is reading or uploading.
- Removing an attachment updates the pending list without mutating child props.
### Gateway Tests
- Multipart upload rejects missing auth header in token mode.
- Multipart upload accepts bearer token in token mode.
- Multipart upload rejects query-token-only auth.
- Staged `file_uuid` resolution returns an attachment ref with no `file_uuid` or
inline `data`.
- Restart-lost UUID produces the re-upload error path.
- Successful turn acceptance evicts consumed UUIDs.
- Failed turn acceptance does not evict consumed UUIDs.
### Browser E2E
- Drop one small image into Web UI, send, and assert `chat.send` carries inline
base64 attachment data.
- Drop one large PDF into Web UI, wait for staged chip, send, and assert
`chat.send` carries `file_uuid`.
- Drop a valid file and an invalid file together; valid file remains pending and
invalid file produces a toast.
- Verify mobile and desktop layouts do not overflow with long filenames or wide
image thumbnails.
### Desktop Manual Smoke
Run on Electron dev and packaged builds:
1. Start the desktop client.
2. Drag a small PNG from the OS file manager into the chat thread.
3. Confirm the composer shows a thumbnail chip.
4. Send and confirm the user bubble renders the attachment.
5. Drag a PDF larger than the inline threshold.
6. Confirm upload progress changes to staged/ready.
7. Send and confirm the gateway accepts the turn.
8. Repeat with token auth enabled.
9. Confirm no local file path appears in the chat payload, transcript, or logs.
## Acceptance Criteria
- Browser Web UI supports drag-and-drop upload for every MIME currently allowed
by `contracts/attachments.py`.
- Electron desktop supports the same drag-and-drop behavior without a separate
upload code path.
- Token-authenticated uploads succeed because the frontend sends bearer auth
headers.
- Query-token-only multipart upload remains rejected.
- Users cannot send while attachments are still reading or uploading.
- Failed uploads are visible and removable or retryable.
- Staged attachments are sent as `file_uuid` and are materialized into stable
transcript attachment refs before runtime execution.
- `file_uuid` never appears in persisted transcript envelopes.
- Successful turn acceptance evicts consumed staged uploads.
- Frontend and backend tests cover valid, invalid, oversize, auth, retry, and
layout cases.
## Rollout
- Ship behind the normal chat composer behavior with no user-facing setting.
- Keep existing file input and paste upload behavior working during rollout.
- Validate Web UI first, then Electron dev, then packaged desktop.
- Add troubleshooting guidance only if desktop platform differences require
user-visible explanation.
## Open Questions
- Should failed staged upload chips offer retry, or should failure remove the
chip and rely on toast only?
- Should duplicate files in the same drop be deduplicated by
`name + size + lastModified`, or should duplicates be allowed?
- Should the drop affordance cover only the chat thread or the full chat view?
- Should future folder drag support be handled through a separate desktop-only
import flow rather than this attachment pipeline?