Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
src/admin/ai/ModelPicker/ModelPicker.tsx diff
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,8 +184,9 @@ Detailed patterns: [`docs/reference/typebox-patterns.md`](docs/reference/typebox

Every untyped boundary uses TypeBox. Inside the boundary, code trusts the parsed value.

- **HTTP responses (client):** `@core/http` is a single three-layer stack — there is exactly ONE way to validate a response, expressed at the altitude you need:
- **HTTP responses (client):** `@core/http` is the single client stack — use the entry matching the response kind and altitude:
- **`apiRequest(path, { schema, … })`** — the canonical entry. Does the `fetch` itself: sets `credentials`, serializes a JSON body (FormData passes through untouched), validates the success body against `schema`, and throws a single `ApiError` (carrying the HTTP status) on failure. Detect cancellation with `isAbortError(err)`. **Default to this** — do NOT hand-roll `fetch` + `res.ok` + `res.json()` in admin code.
- **`apiBlobRequest(path, …)`** — the binary-response counterpart for authenticated image/file reads. It shares `apiRequest`'s credential, cancellation, and `ApiError` behavior, then returns a `Blob`; the caller validates the MIME type before use.
- **`readEnvelope(res, Schema, fallbackMessage)`** — for the persistence layer, which performs its own injectable `fetch` (test seam) and then hands the `Response` here. Checks `res.ok` (throws `ApiError` with status + the `{ error }` envelope message), then validates the body. Its no-body sibling is `assertOk(res, fallback)` for `void`/Blob/streaming/text responses.
- **`parseJsonResponse(res, Schema)`** — the low-level body-validation primitive that `apiRequest` and `readEnvelope` are *built on*. It validates a body with NO HTTP-status semantics. Reserved for genuine primitives only: the `@core/http` internals, the XHR upload path (`useUploadQueue`), and server-side fetches of external APIs. Do NOT reach for it in admin/persistence code — `assertOk(res, m); parseJsonResponse(res, S)` is exactly `readEnvelope(res, S, m)`; always write the latter.
- **`JSON.parse` of persisted data:** `safeParseJson(raw, Schema)` for hard, `parseJsonWithFallback(raw, Schema, default)` for soft.
Expand Down
3 changes: 2 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,8 @@ The codebase enforces "validate, then trust": every untyped input goes through a

| Boundary | Helper | Lives in |
|--------------------------------------|-------------------------------------------------------|---------------------------------------|
| HTTP request (client, canonical) | `apiRequest(path, { schema, … })` → throws `ApiError` | `src/core/http/apiClient.ts` |
| HTTP request (client, canonical JSON) | `apiRequest(path, { schema, … })` → throws `ApiError` | `src/core/http/apiClient.ts` |
| HTTP request (client, binary body) | `apiBlobRequest(path, …)` → `Blob` / throws `ApiError` | `src/core/http/apiClient.ts` |
| HTTP response from a held `Response` | `readEnvelope(res, Schema, fallbackMessage)` | `src/core/http/apiClient.ts` |
| Raw JSON response validation | `parseJsonResponse(res, Schema)` | `src/core/utils/jsonValidate.ts` |
| `JSON.parse` of persisted strings | `safeParseJson(raw, Schema)` / `parseJsonWithFallback`| `src/core/utils/jsonValidate.ts` |
Expand Down
115 changes: 103 additions & 12 deletions docs/features/agent.md

Large diffs are not rendered by default.

18 changes: 11 additions & 7 deletions docs/features/media.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The workspace is canvas-style: it uses `AdminWorkspaceCanvasLayout`, the lighter
- **State:** one hook — `useMediaWorkspace()` — orchestrates folders, assets, selection, filters, upload queue, and folder moves. The editor store doesn't grow new slices; the Media page is self-contained.
- **Folders:** folders render as first-class grid/list items in the canvas. Opening a folder filters the canvas to its contents, and nested folders show a parent-folder entry to navigate back.
- **Drag/drop:** assets can be dragged into folders from the canvas or folder tree. A drop replaces the asset's folder memberships with the target folder, matching desktop file-manager move semantics.
- **Floating windows:** Asset viewer, upload queue, bulk edit. Each is `useDraggablePanel('mediaViewer' | 'mediaUploadQueue' | 'mediaBulkEdit')`. Position survives reload via `workspaceLayoutStorage`.
- **Floating windows:** Asset viewer, upload queue, bulk edit. Each uses the shared `@admin/shared/FloatingWindow` shell or its `useDraggablePanel` hook with a unique id (`mediaDetachedInspector`, `mediaUploadQueue`, `mediaBulkEdit`). Position survives reload via `workspaceLayoutStorage`.
- **Auto-open behavior:** upload queue opens when uploads start; bulk-edit opens at 2+ selected; viewer opens on primary selection.
- **Server side:** `media_assets`, `media_folders`, `media_asset_folders` tables. Handlers under `/admin/api/cms/media`, `/admin/api/cms/media/folders`, `/admin/api/cms/media/storage`. Repositories at `server/repositories/media*.ts`.
- **Storage adapters:** built-in local-disk plus plugin-registered adapters. Non-public-url adapters route through `/_instatic/media/<adapterId>/<storagePath>` for signed redirects.
Expand All @@ -25,6 +25,7 @@ The workspace is canvas-style: it uses `AdminWorkspaceCanvasLayout`, the lighter
```text
src/admin/pages/media/
├── MediaPage.tsx — top-level component
├── mediaAssetEvents.ts — typed cross-workspace asset-created notifications
├── components/
│ ├── MediaSidebar/ — folder tree + storage + smart folders
│ ├── MediaCanvas/ — file grid / list with FilterBar
Expand All @@ -37,7 +38,6 @@ src/admin/pages/media/
│ ├── MediaPickerField/ — embedded picker control
│ ├── MediaFolderPanel/ — folder list panel
│ ├── MediaStoragePanel/ — storage adapter management
│ ├── FloatingWindow/ — shared floating-window shell
│ └── viewers/ — per-media-type viewers (image, video, pdf, etc.)
├── hooks/
│ ├── useMediaWorkspace.ts — orchestrates server state (folders, assets, filters)
Expand All @@ -54,6 +54,8 @@ src/admin/pages/media/
├── mediaDragDrop.ts — TypeBox-validated drag/drop payload helpers
├── smartFolders.ts — smart folder IDs, type guard, per-ID predicates
└── variants.ts — image variant URL helpers

src/admin/shared/FloatingWindow/ — shared portal shell + persisted drag hook used across admin workspaces
```

---
Expand Down Expand Up @@ -144,7 +146,7 @@ The count badge shown next to each smart folder in the sidebar is computed clien

### Floating windows

Each floating window has a unique `FloatingPanelId` (`'mediaViewer' | 'mediaUploadQueue' | 'mediaBulkEdit'`), uses `useDraggablePanel(id)` for position, and gets its position persisted via `workspaceLayoutStorage.ts`. Visibility differs by window:
Each floating window has a unique `FloatingPanelId` (`'mediaDetachedInspector' | 'mediaUploadQueue' | 'mediaBulkEdit'`), uses `useDraggablePanel(id)` for position, and gets its position persisted via `workspaceLayoutStorage.ts`. Visibility differs by window:

| Window | How visibility is determined |
|-----------------|-------------------------------------------------------------------------------------------------|
Expand All @@ -154,7 +156,7 @@ Each floating window has a unique `FloatingPanelId` (`'mediaViewer' | 'mediaUplo

The viewer and bulk-edit are derived rather than stored because "closed" is identical to "no selection" — every close path calls `workspace.clearSelection()`. Deriving avoids an extra render commit and the one-frame open lag that appeared with the old `setState`-in-effect approach.

The shared `FloatingWindow` shell at `components/FloatingWindow/` provides the chrome: drag handle, close button, position bounding.
The shared `FloatingWindow` shell at `src/admin/shared/FloatingWindow/` provides the portal, chrome, drag handle, close button, and position bounding without pulling media-specific editor code into other workspaces. Its dimension-aware clamp measures each panel and keeps at least a 50px header strip reachable on every viewport edge; stored positions are re-clamped when a window mounts, changes size, or the viewport resizes.

---

Expand Down Expand Up @@ -250,8 +252,10 @@ Folder routes (`/admin/api/cms/media/folders/...`) are matched **before** asset

### Upload pipeline

Uploads initiated outside the Media page use the same pipeline. In particular, the Agent Panel's explicit **Save to Media** image action resolves the private chat image, wraps it in a MIME-correct `File`, and calls `uploadCmsMediaAsset`; it does not create an AI-specific storage route. On success, `mediaAssetEvents.ts` upserts the new row into an already-mounted Site → Media explorer while the normal media cache is primed for canvas consumers.

```text
POST /admin/api/cms/media/upload
POST /admin/api/cms/media
mediaUpload.ts ← validates upload (size + magic-byte MIME sniff)
Expand Down Expand Up @@ -325,8 +329,8 @@ The redirect handler is `tryServeMediaRedirect` in `server/router.ts`. The redir
### Add a new floating window

1. Add a unique id to `FloatingPanelId` in the layout storage module.
2. Create `components/<WindowName>/` with the window React component using the shared `FloatingWindow` shell.
3. Use `useDraggablePanel('newWindowId')` for position; track `open` in `MediaPage` local state.
2. Create `components/<WindowName>/` with the window React component using `FloatingWindow` from `@admin/shared/FloatingWindow`.
3. Pass the id to `FloatingWindow`; track `open` in `MediaPage` local state. Use the exported `useDraggablePanel` directly only for a custom shell such as `MediaViewerWindow`.
4. Add the auto-open rule (selection threshold, upload event, etc.) inside `MediaPage`.

### Add a new sidebar panel
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/design-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ The visual editor uses additional raw z-index values that are **not** tokenised.
| 30 | Main toolbar |
| 50 | Floating panels: PropertiesPanel, AgentPanel, DomPanel |
| 55 | LeftSidebar, RightSidebar, PanelRail |
| 70 | Media floating windows (FloatingWindow, MediaViewerWindow) |
| 90 | Shared admin floating windows (`FloatingWindow`, `MediaViewerWindow`, agent image preview) |
| 80 | CodeEditorPanel |
| 201 | Toolbar popovers / dropdowns |
| 400–401 | PreviewOverlay |
Expand Down
10 changes: 6 additions & 4 deletions docs/reference/typebox-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Schemas are the **source of truth**. Domain types come from `Static<typeof Schem
- **Helpers live in `src/core/utils/typeboxHelpers.ts`** — `Type`, `Value`, `Static`, `withFallback`, `parseValue`, `safeParseValue`, `filterArray`, `formatValueErrors`.
- **Compiled validators live in `src/core/utils/typeboxCompiler.ts`** — `compiled`, `compiledCheck`, `compiledDecode`, `compiledSafeParseValue`, `compiledFormatValueErrors`. Hot repeated `Check` / `Decode` / `Errors` paths should use these or helpers that already call them.
- **JSON boundary helpers live in `src/core/utils/jsonValidate.ts`** — `safeParseJson`, `parseJsonWithFallback`, `parseJsonResponse`.
- **Canonical client HTTP layer:** `@core/http` — `apiRequest(path, { schema, … })` (the default for browser→server calls), plus `ApiError`, `isAbortError`, `readEnvelope`, `responseErrorMessage`, `ErrorEnvelopeSchema`. All defined in `src/core/http/apiClient.ts`.
- **Canonical client HTTP layer:** `@core/http` — `apiRequest(path, { schema, … })` (the default for JSON browser→server calls), `apiBlobRequest(path, …)` for binary responses, plus `ApiError`, `isAbortError`, `readEnvelope`, `responseErrorMessage`, `ErrorEnvelopeSchema`. All defined in `src/core/http/apiClient.ts`.
- **Schemas are source of truth.** `type Foo = Static<typeof FooSchema>` — never a hand-rolled interface beside the schema.
- **Soft fallbacks** for corrupted local storage / optional config use `withFallback(schema, default)` + `parseJsonWithFallback`.
- **Hard fallbacks** for required documents throw and bubble to an error boundary.
Expand Down Expand Up @@ -94,10 +94,11 @@ const prefs = parseJsonWithFallback(
| Helper | Purpose |
|-------------------------------------------------|------------------------------------------------------------------|
| `apiRequest(path, { method, body, schema, query, signal, … })` | **The default for browser→server calls.** Sets `credentials`, JSON-serializes `body`, validates the success body against `schema` (returns `Static<schema>`; `void` without one), and throws `ApiError` on a non-OK status. |
| `apiBlobRequest(path, { signal, … })` | Binary-response counterpart for authenticated image/file reads. Uses the same transport and `ApiError` envelope handling, then returns a `Blob`; the caller validates the MIME type before use. |
| `readEnvelope(res, schema, fallbackMessage)` | For code that already holds a `Response` (the persistence layer, which injects its own `fetch`): check `res.ok` (throw `ApiError` with `responseErrorMessage(res, fallback)` if not), then validate body against `schema` |
| `assertOk(res, fallbackMessage)` | No-body counterpart to `readEnvelope`: throw `ApiError` if the `Response` is not OK, otherwise return (for void mutations / bodies parsed separately) |
| `responseErrorMessage(res, fallback)` | Extract a useful error message from a failed `Response` (reads `{ error: string }` envelope if present, then raw text, otherwise the fallback) |
| `ApiError` | The single error type thrown by `apiRequest`/`readEnvelope`; carries `.status` so UI can branch (403, 404, …) |
| `ApiError` | The single error type thrown by `apiRequest`/`apiBlobRequest`/`readEnvelope`; carries `.status` so UI can branch (403, 404, …) |
| `isAbortError(err)` | True for an aborted fetch (user cancellation / superseded request) — the uniform replacement for `(err as Error).name === 'AbortError'` |

---
Expand Down Expand Up @@ -286,7 +287,8 @@ Common boundaries already wrapped — extend the same pattern when you add a new

| Boundary | Helper | Lives in |
|--------------------------------------------|-----------------------------------------------------|-----------------------------------------|
| HTTP request (client, canonical) | `apiRequest(path, { schema, … })` | `src/core/http/apiClient.ts` |
| HTTP request (client, canonical JSON) | `apiRequest(path, { schema, … })` | `src/core/http/apiClient.ts` |
| HTTP request (client, binary body) | `apiBlobRequest(path, { signal, … })` | `src/core/http/apiClient.ts` |
| HTTP response from a held `Response` | `readEnvelope(res, Schema, fallback)` | `src/core/http/apiClient.ts` |
| HTTP body-validation primitive (no status semantics; `@core/http` internals, XHR, server-side external APIs) | `parseJsonResponse(res, Schema)` | `src/core/utils/jsonValidate.ts` |
| Request body (server handler) | `readValidatedBody(req, Schema)` → typed value or `null` (return `badRequest` on null) | `server/http.ts` + per-handler |
Expand Down Expand Up @@ -333,7 +335,7 @@ Common boundaries already wrapped — extend the same pattern when you add a new
- `src/core/utils/typeboxHelpers.ts` — helper layer (`parseValue`, `withFallback`, `filterArray`, etc.)
- `src/core/utils/typeboxCompiler.ts` — cached TypeCompiler layer for hot validation execution
- `src/core/utils/jsonValidate.ts` — JSON boundary helpers
- `src/core/http/apiClient.ts` — `apiRequest`, `ApiError`, `isAbortError`, `readEnvelope`, `assertOk`, `responseErrorMessage`, `ErrorEnvelopeSchema`
- `src/core/http/apiClient.ts` — `apiRequest`, `apiBlobRequest`, `ApiError`, `isAbortError`, `readEnvelope`, `assertOk`, `responseErrorMessage`, `ErrorEnvelopeSchema`
- `src/core/persistence/responseSchemas.ts` — shared CMS HTTP response schemas
- `src/core/persistence/validate.ts` — `validateSite`, `validatePages`, `ValidatePagesOptions`, `SiteValidationError`
- `src/core/plugins/manifest.ts` — `parsePluginManifest`
Expand Down
38 changes: 37 additions & 1 deletion server/ai/conversations/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
* preceding tool call in the replayed history.
*/

import type { AiMessage, AiToolOutput } from '../runtime/types'
import type { AiContentBlock, AiMessage, AiToolOutput } from '../runtime/types'
import type { MessageRecord } from './types'

/**
Expand All @@ -33,6 +33,9 @@ import type { MessageRecord } from './types'
export const INTERRUPTED_TOOL_RESULT_ERROR =
'Tool call did not complete — the previous turn was interrupted before a result was produced.'

export const NON_VISION_USER_IMAGE_OMITTED =
'[Attached image omitted because the selected model does not support image input.]'

/**
* Reconstruct `AiMessage` history from persisted `MessageRecord` rows.
*
Expand Down Expand Up @@ -100,3 +103,36 @@ export function buildMessageHistory(records: MessageRecord[]): AiMessage[] {

return out
}

/**
* Adapt persisted user images to the selected model without mutating history.
*
* Vision models retain every image-bearing turn. Text-only models receive one
* breadcrumb per image-bearing turn, which keeps a conversation usable after
* switching away from a vision model.
*/
export function projectUserImagesForModel(
messages: readonly AiMessage[],
visionInput: boolean,
): AiMessage[] {
if (visionInput) return [...messages]

return messages.map((message) => {
if (message.role !== 'user' || !message.content.some((block) => block.kind === 'image')) {
return message
}
let breadcrumbAdded = false
const content: AiContentBlock[] = []
for (const block of message.content) {
if (block.kind !== 'image') {
content.push(block)
continue
}
if (!breadcrumbAdded) {
content.push({ kind: 'text', text: NON_VISION_USER_IMAGE_OMITTED })
breadcrumbAdded = true
}
}
return { role: 'user', content }
})
}
Loading
Loading