@lotics/app-sdk 0.56.1 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -17,7 +17,7 @@ signature; open the file.**
17
17
  | [docs/queries.md](./docs/queries.md) | **The query engine authoring reference** — AST node kinds, per-field-type operator support, filters/params/pruning, free-text search, combining tables (join/union/link/`unnest`/`record_id`), shaping (aggregates, date buckets, windows), runtime refinement bounds, limits & the efficiency playbook. |
18
18
  | [docs/data_fetching.md](./docs/data_fetching.md) | The three read hooks (`useQuery`/`useInfiniteQuery`/`usePaginatedQuery`), cell readers (`row.*`, `readSelect`, `readMembers`, `readLinks`, `readFiles`, `readLocked`), `useFieldOptions`, data discipline, the search-as-you-type + record-picker patterns. |
19
19
  | [docs/mutations.md](./docs/mutations.md) | `useWorkflow` (the ONLY write path), the `WorkflowResult` resolve-never-throw contract, typed inputs, diff-before-update, locked records, `useOptimistic`. |
20
- | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments`, `readFiles`/presigned URLs, workflow-generated files, preview pairing, filter operators, the server-side delivery bounds. |
20
+ | [docs/files.md](./docs/files.md) | Files end to end — `useFileUpload`, `useAttachments`, `readFiles`/presigned URLs, workflow-generated files, preview pairing, filter operators, the server-side delivery bounds. **Uploads declare a `fidelity`** (`standard` / `high` / `original`) — the app picks how much of the image survives storage; use `high` whenever text must stay legible. |
21
21
  | [docs/members_and_options.md](./docs/members_and_options.md) | People + select options + comments — `useMembers`, `useFieldOptions`, `useViewer`, `useComments`, and the `@lotics/ui` components they feed. |
22
22
  | [docs/navigation_and_state.md](./docs/navigation_and_state.md) | `AppRouter` (embedded/standalone URL model), `useUrlState` + `urlParam` codecs, `useRecents`. |
23
23
  | [docs/ai.md](./docs/ai.md) | `useAgentRun` (structured vs free-text, streaming ai-sdk `parts` → `AgentRun`, the agent's ask-back — `pendingChoice`/`answerChoice` over the parked `awaiting_input` state), `askAi` — plus the fields-vs-file razor for choosing between them — and `useAiContext` (push the current screen's view state to the member's ambient chat agent; caps, push-only semantics, auto query-refetch on chat mutation). **A `file` input carries its own content** — images/PDFs are perceived natively, Word/Excel/CSV/text are materialized into the run; no reader tool to declare. |
@@ -1,3 +1,4 @@
1
+ import { type ImageFidelity } from "./upload/optimize.js";
1
2
  import { type AiContextValue } from "./rpc.js";
2
3
  import { type AgentUIPart, type PendingChoice } from "./agent_stream.js";
3
4
  import type { AppWorkflows, AppWorkflowResults, AppQueries, AppAgents, AppAgentResults } from "./types.js";
@@ -302,8 +303,17 @@ interface FileUploadState {
302
303
  * Upload one file. Resolves to the stored file; pass `UploadedFile.id` into
303
304
  * a `useWorkflow` call to attach it to a record. Rejects on failure — the
304
305
  * file is never partially stored.
306
+ *
307
+ * `fidelity` says how much of the image must survive storage; it defaults to
308
+ * `"high"`, which keeps the text of a photographed document legible. Only
309
+ * photographs are affected — a PDF, Word or Excel file is stored untouched at
310
+ * every step. Pass `"standard"` for bulk visual capture (a forty-photo survey,
311
+ * where the volume is what costs you), or `"original"` when the pixels
312
+ * themselves are the evidence.
305
313
  */
306
- upload: (file: File) => Promise<UploadedFile>;
314
+ upload: (file: File, options?: {
315
+ fidelity?: ImageFidelity;
316
+ }) => Promise<UploadedFile>;
307
317
  /** True while any upload from this hook is in flight. */
308
318
  uploading: boolean;
309
319
  /** Message of the most recent failed upload, cleared when a new one starts. */
@@ -341,7 +351,9 @@ interface AttachmentsState {
341
351
  /** Add picked/pasted/dropped files — each shows its local preview at once and
342
352
  * uploads in the background. Picking is the app's choice: wire a button to
343
353
  * `@lotics/ui` `pickFiles`, or a paste/drop handler, then call this. */
344
- add: (files: File[]) => void;
354
+ add: (files: File[], options?: {
355
+ fidelity?: ImageFidelity;
356
+ }) => void;
345
357
  /** Remove one attachment and revoke its preview URL. */
346
358
  remove: (id: string) => void;
347
359
  /** Remove all attachments and revoke their preview URLs. */
package/dist/src/hooks.js CHANGED
@@ -16,6 +16,7 @@
16
16
  * `useState` — they have nothing to share.
17
17
  */
18
18
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
19
+ import { DEFAULT_IMAGE_FIDELITY } from "./upload/optimize.js";
19
20
  import useSWR from "swr";
20
21
  import useSWRInfinite from "swr/infinite";
21
22
  import { rpc, rpcAgentRun, rpcAgentRunContinue, postHostNotification, subscribeHostRefetch, } from "./rpc.js";
@@ -242,12 +243,13 @@ export function usePaginatedQuery(alias, params, opts) {
242
243
  export function useFileUpload() {
243
244
  const [inFlight, setInFlight] = useState(0);
244
245
  const [error, setError] = useState(null);
245
- const upload = useCallback(async (file) => {
246
+ const upload = useCallback(async (file, options) => {
247
+ const fidelity = options?.fidelity ?? DEFAULT_IMAGE_FIDELITY;
246
248
  setInFlight((n) => n + 1);
247
249
  setError(null);
248
250
  try {
249
- const uploaded = await rpc("upload", { file });
250
- captureAppEvent("app_file_uploaded", { mime_type: file.type });
251
+ const uploaded = await rpc("upload", { file, fidelity });
252
+ captureAppEvent("app_file_uploaded", { mime_type: file.type, fidelity });
251
253
  return uploaded;
252
254
  }
253
255
  catch (err) {
@@ -287,7 +289,7 @@ let attachSeq = 0;
287
289
  export function useAttachments() {
288
290
  const { upload } = useFileUpload();
289
291
  const [files, setFiles] = useState([]);
290
- const add = useCallback((incoming) => {
292
+ const add = useCallback((incoming, options) => {
291
293
  for (const file of incoming) {
292
294
  const id = `att_${(attachSeq += 1)}`;
293
295
  const previewUrl = URL.createObjectURL(file);
@@ -295,7 +297,7 @@ export function useAttachments() {
295
297
  ...prev,
296
298
  { id, filename: file.name, mime_type: file.type, preview_url: previewUrl, status: "uploading" },
297
299
  ]);
298
- upload(file)
300
+ upload(file, options)
299
301
  .then((uploaded) => setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, status: "ready", file_id: uploaded.id } : f))))
300
302
  .catch(() => setFiles((prev) => prev.map((f) => (f.id === id ? { ...f, status: "error" } : f))));
301
303
  }
package/dist/src/rpc.js CHANGED
@@ -525,7 +525,7 @@ function rpcStandalone(op, payload) {
525
525
  case "agentRun.cancel":
526
526
  return standaloneAgentRunCancel(payload);
527
527
  case "upload":
528
- return standaloneUpload(payload.file);
528
+ return standaloneUpload(payload.file, payload.fidelity);
529
529
  case "members":
530
530
  return standaloneMembers(payload);
531
531
  case "context":
@@ -705,7 +705,7 @@ async function standaloneAgentRunCancel(p) {
705
705
  });
706
706
  return { ok: true };
707
707
  }
708
- async function standaloneUpload(file) {
708
+ async function standaloneUpload(file, fidelity) {
709
709
  if (!(file instanceof File)) {
710
710
  throw new Error("upload payload must include a File");
711
711
  }
@@ -713,6 +713,6 @@ async function standaloneUpload(file) {
713
713
  const uploaded = await runUploadPipeline(file, {
714
714
  initUpload: (input) => apiCall("POST", `/v1/apps/${app_id}/files/upload-url`, input, { appId: app_id }),
715
715
  completeUpload: (input) => apiCall("POST", `/v1/apps/${app_id}/files/complete`, input, { appId: app_id }),
716
- });
716
+ }, { fidelity });
717
717
  return uploaded;
718
718
  }
@@ -2,8 +2,9 @@
2
2
  * Browser-side image compression for app uploads.
3
3
  *
4
4
  * Phone-camera photos are huge (4–10 MB HEIC/JPEG) and no document needs
5
- * megapixels. Resize to ≤1568px on the long edge — the vision model's own
6
- * working resolution, above which the provider downsamples anyway — and
5
+ * megapixels. Resize to ≤2000px on the long edge — above the standard vision
6
+ * tier's 1568px so a high-resolution reader keeps real detail, but under the
7
+ * limit that rejects requests carrying more than 20 images — and
7
8
  * re-encode as JPEG at q=0.9, which keeps small glyphs (container numbers,
8
9
  * invoice lines) legible to a reader and to an extraction agent.
9
10
  *
@@ -13,10 +14,32 @@
13
14
  *
14
15
  * Ported from `frontend/lib/upload_file_optimization.ts` so public-app forms
15
16
  * get the same mobile-friendly upload behavior as the in-Lotics UI. Kept
16
- * dependency-free (no logger, no UploadIntent abstraction) so the SDK ships
17
+ * dependency-free (no logger, no ImageFidelity abstraction) so the SDK ships
17
18
  * as a single drop-in.
18
19
  */
19
- export type OptimizationReason = "optimized" | "skipped_unsupported_format" | "skipped_environment_unsupported" | "skipped_decode_unavailable" | "skipped_invalid_dimensions" | "skipped_small_dimensions" | "skipped_canvas_unavailable" | "skipped_canvas_type_mismatch";
20
+ /**
21
+ * How faithful the STORED image must be — a closed ordinal scale, not a taxonomy
22
+ * of subjects, so a caller can read the value and know what it costs.
23
+ * `@lotics/shared/image_policy` carries the same scale and the reasoning.
24
+ */
25
+ export type ImageFidelity = "original" | "high" | "standard";
26
+ /**
27
+ * What an upload defaults to when the caller says nothing. MIRRORS
28
+ * `@lotics/shared/image_policy` — this package ships dependency-free and cannot
29
+ * import it — and a test fails when the two drift.
30
+ *
31
+ * `high` because the surfaces that do not think about this mostly carry text; a
32
+ * caller collecting in volume declares `"standard"` and pays a fraction as much.
33
+ */
34
+ export declare const DEFAULT_IMAGE_FIDELITY: ImageFidelity;
35
+ export interface ImagePolicy {
36
+ maxDimensionPx: number;
37
+ jpegQuality: number;
38
+ }
39
+ /** The mirror, exposed so the drift guard can compare it with the shared
40
+ * source of truth. Not part of the package's public surface. */
41
+ export declare const IMAGE_POLICIES_FOR_TEST: Record<"high" | "standard", ImagePolicy>;
42
+ export type OptimizationReason = "optimized" | "skipped_original" | "skipped_unsupported_format" | "skipped_environment_unsupported" | "skipped_decode_unavailable" | "skipped_invalid_dimensions" | "skipped_small_dimensions" | "skipped_canvas_unavailable" | "skipped_canvas_type_mismatch";
20
43
  export interface OptimizationResult {
21
44
  file: File;
22
45
  optimized: boolean;
@@ -28,4 +51,4 @@ export interface OptimizationResult {
28
51
  targetWidth: number;
29
52
  targetHeight: number;
30
53
  }
31
- export declare function optimizeImageForUpload(file: File): Promise<OptimizationResult>;
54
+ export declare function optimizeImageForUpload(file: File, fidelity?: ImageFidelity): Promise<OptimizationResult>;
@@ -2,8 +2,9 @@
2
2
  * Browser-side image compression for app uploads.
3
3
  *
4
4
  * Phone-camera photos are huge (4–10 MB HEIC/JPEG) and no document needs
5
- * megapixels. Resize to ≤1568px on the long edge — the vision model's own
6
- * working resolution, above which the provider downsamples anyway — and
5
+ * megapixels. Resize to ≤2000px on the long edge — above the standard vision
6
+ * tier's 1568px so a high-resolution reader keeps real detail, but under the
7
+ * limit that rejects requests carrying more than 20 images — and
7
8
  * re-encode as JPEG at q=0.9, which keeps small glyphs (container numbers,
8
9
  * invoice lines) legible to a reader and to an extraction agent.
9
10
  *
@@ -13,15 +14,36 @@
13
14
  *
14
15
  * Ported from `frontend/lib/upload_file_optimization.ts` so public-app forms
15
16
  * get the same mobile-friendly upload behavior as the in-Lotics UI. Kept
16
- * dependency-free (no logger, no UploadIntent abstraction) so the SDK ships
17
+ * dependency-free (no logger, no ImageFidelity abstraction) so the SDK ships
17
18
  * as a single drop-in.
18
19
  */
20
+ /**
21
+ * What an upload defaults to when the caller says nothing. MIRRORS
22
+ * `@lotics/shared/image_policy` — this package ships dependency-free and cannot
23
+ * import it — and a test fails when the two drift.
24
+ *
25
+ * `high` because the surfaces that do not think about this mostly carry text; a
26
+ * caller collecting in volume declares `"standard"` and pays a fraction as much.
27
+ */
28
+ export const DEFAULT_IMAGE_FIDELITY = "high";
19
29
  // MIRRORS `@lotics/shared/image_policy` — the SDK ships dependency-free so it
20
30
  // cannot import it. Change both together.
21
- const MAX_IMAGE_DIMENSION_PX = 1568;
22
- const JPEG_QUALITY = 0.9;
31
+ // · standard bulk visual evidence, NEVER read by a model, so tokens do not
32
+ // enter it: sized purely for upload time and storage across dozens of files.
33
+ // · high — read by a model or a person. Token cost depends on DIMENSION
34
+ // alone, so this buys quality (free in tokens) and keeps the dimension modest.
35
+ const IMAGE_POLICIES = {
36
+ standard: { maxDimensionPx: 1280, jpegQuality: 0.8 },
37
+ high: { maxDimensionPx: 1568, jpegQuality: 0.95 },
38
+ };
39
+ /** The mirror, exposed so the drift guard can compare it with the shared
40
+ * source of truth. Not part of the package's public surface. */
41
+ export const IMAGE_POLICIES_FOR_TEST = IMAGE_POLICIES;
23
42
  const CONVERTIBLE_EXTENSION_PATTERN = /\.(heic|heif|png|webp)$/i;
24
- export async function optimizeImageForUpload(file) {
43
+ export async function optimizeImageForUpload(file, fidelity = DEFAULT_IMAGE_FIDELITY) {
44
+ if (fidelity === "original")
45
+ return unchanged(file, "skipped_original");
46
+ const policy = IMAGE_POLICIES[fidelity];
25
47
  const format = getOptimizableFormat(file);
26
48
  const mimeType = format?.outputMimeType;
27
49
  const unsupportedReason = getUnsupportedReason(file, mimeType);
@@ -37,11 +59,11 @@ export async function optimizeImageForUpload(file) {
37
59
  closeSource(source);
38
60
  return unchanged(file, "skipped_invalid_dimensions");
39
61
  }
40
- if (Math.max(width, height) <= MAX_IMAGE_DIMENSION_PX) {
62
+ if (Math.max(width, height) <= policy.maxDimensionPx) {
41
63
  closeSource(source);
42
64
  return unchanged(file, "skipped_small_dimensions", width, height);
43
65
  }
44
- const { width: targetWidth, height: targetHeight } = scale(width, height);
66
+ const { width: targetWidth, height: targetHeight } = scale(width, height, policy.maxDimensionPx);
45
67
  const canvas = document.createElement("canvas");
46
68
  canvas.width = targetWidth;
47
69
  canvas.height = targetHeight;
@@ -54,7 +76,7 @@ export async function optimizeImageForUpload(file) {
54
76
  ctx.imageSmoothingQuality = "high";
55
77
  ctx.drawImage(source, 0, 0, targetWidth, targetHeight);
56
78
  closeSource(source);
57
- const blob = await canvasToBlob(canvas, mimeType);
79
+ const blob = await canvasToBlob(canvas, mimeType, policy.jpegQuality);
58
80
  if (blob.type !== mimeType) {
59
81
  return unchanged(file, "skipped_canvas_type_mismatch", width, height, targetWidth, targetHeight);
60
82
  }
@@ -131,11 +153,11 @@ function closeSource(source) {
131
153
  if ("close" in source && typeof source.close === "function")
132
154
  source.close();
133
155
  }
134
- function scale(width, height) {
156
+ function scale(width, height, maxDimensionPx) {
135
157
  const largest = Math.max(width, height);
136
- if (largest <= MAX_IMAGE_DIMENSION_PX)
158
+ if (largest <= maxDimensionPx)
137
159
  return { width, height };
138
- const factor = MAX_IMAGE_DIMENSION_PX / largest;
160
+ const factor = maxDimensionPx / largest;
139
161
  return {
140
162
  width: Math.max(1, Math.round(width * factor)),
141
163
  height: Math.max(1, Math.round(height * factor)),
@@ -172,7 +194,7 @@ async function loadImage(file) {
172
194
  image.src = objectUrl;
173
195
  });
174
196
  }
175
- async function canvasToBlob(canvas, mimeType) {
197
+ async function canvasToBlob(canvas, mimeType, jpegQuality) {
176
198
  return new Promise((resolve, reject) => {
177
199
  canvas.toBlob((blob) => {
178
200
  if (!blob) {
@@ -180,6 +202,6 @@ async function canvasToBlob(canvas, mimeType) {
180
202
  return;
181
203
  }
182
204
  resolve(blob);
183
- }, mimeType, JPEG_QUALITY);
205
+ }, mimeType, jpegQuality);
184
206
  });
185
207
  }
@@ -13,6 +13,7 @@
13
13
  * timeouts use platform-internal defaults — same conventions as the
14
14
  * in-Lotics direct-upload pipeline.
15
15
  */
16
+ import { type ImageFidelity } from "./optimize.js";
16
17
  interface UploadInitResponse {
17
18
  upload_url: string;
18
19
  file_id: string;
@@ -47,6 +48,8 @@ export interface UploadRpc {
47
48
  }
48
49
  export interface RunUploadPipelineOptions {
49
50
  signal?: AbortSignal;
51
+ /** What the surface is collecting — selects the stored image resolution. */
52
+ fidelity?: ImageFidelity;
50
53
  }
51
54
  export declare function runUploadPipeline(file: File, rpc: UploadRpc, options?: RunUploadPipelineOptions): Promise<CompleteResponseFile>;
52
55
  export {};
@@ -13,16 +13,16 @@
13
13
  * timeouts use platform-internal defaults — same conventions as the
14
14
  * in-Lotics direct-upload pipeline.
15
15
  */
16
- import { optimizeImageForUpload } from "./optimize.js";
16
+ import { optimizeImageForUpload, DEFAULT_IMAGE_FIDELITY } from "./optimize.js";
17
17
  import { putToStorageWithRetry } from "./transport.js";
18
18
  export async function runUploadPipeline(file, rpc, options = {}) {
19
- const { signal } = options;
19
+ const { signal, fidelity = DEFAULT_IMAGE_FIDELITY } = options;
20
20
  // 1. Compress images. Non-image files return unchanged. Failures here are
21
21
  // intentionally swallowed — the user shouldn't see an upload error
22
22
  // because canvas threw; we just upload the original bytes.
23
23
  let candidate = file;
24
24
  try {
25
- const optimized = await optimizeImageForUpload(file);
25
+ const optimized = await optimizeImageForUpload(file, fidelity);
26
26
  candidate = optimized.file;
27
27
  }
28
28
  catch {
package/docs/files.md CHANGED
@@ -14,6 +14,8 @@ discipline in [data fetching](./data_fetching.md).
14
14
  |---|---|---|
15
15
  | Collect bytes from the visitor | `useFileUpload().upload(file)` | `UploadedFile` — a stored, **unattached** file id + presigned serving URLs |
16
16
  | Collect several with live previews | `useAttachments()` | `AttachedFile[]` with instant local previews; `fileIds` when done |
17
+
18
+ `useAttachments().add(files, { fidelity })` takes the same `fidelity` as `upload`.
17
19
  | Attach to a record | a declared workflow with a `{ type: "file" }` input | the workflow writes the id(s) into a `files` field — the **only** write path |
18
20
  | Read back from records | `useQuery` + `readFiles(cell)` | `AppFile[]` — presigned `url`/`thumbnail_url` (24 h) + `size`/`created_at` |
19
21
  | Receive a generated document | `useWorkflow` → `WorkflowResult.files` | presigned files auto-extracted from the run |
@@ -30,17 +32,40 @@ declared workflow (see [mutations](./mutations.md)).
30
32
  const { upload, uploading, error } = useFileUpload();
31
33
  const submit = useWorkflow("submitOrder");
32
34
 
33
- const photo = await upload(file); // File → UploadedFile
34
- await submit({ ...fields, photo_file_id: photo.id });
35
+ const invoiceShot = await upload(file); // high (default)
36
+ const surveyPhoto = await upload(file, { fidelity: "standard" }); // bulk capture
37
+ await submit({ ...fields, invoice_file_id: invoiceShot.id });
35
38
  ```
36
39
 
37
40
  Signature: `dist/src/hooks.d.ts`. Returns `{ upload, uploading, error }`:
38
41
 
39
- - `upload(file: File): Promise<UploadedFile>` — resolves to the stored file; rejects on failure
42
+ - `upload(file: File, options?: { fidelity }): Promise<UploadedFile>` — resolves to the stored file; rejects on failure
40
43
  (the file is never partially stored). `UploadedFile` is
41
44
  `{ id, filename, mime_type, url?, thumbnail_url? }` — `url`/`thumbnail_url` are presigned
42
45
  (24 h) and load directly in the sandboxed iframe, so a just-uploaded image previews without a
43
46
  round-trip.
47
+ - `fidelity` — **how faithful the stored image must be.** An ordinal scale, so the value tells
48
+ you what it costs without looking up a mapping. The platform owns the pixels behind each step
49
+ and re-tunes them centrally as model tiers change.
50
+
51
+ Defaults to `"high"` — enough to resolve the text of a photographed document. Drop to
52
+ `"standard"` when collecting in volume.
53
+
54
+ **This only affects photographs.** A PDF, Word, Excel or CSV file is stored untouched at every
55
+ step, so `"original"` is not what keeps a document intact — it already is. Reach for it when
56
+ the PIXELS are the evidence. Note too that HEIC (what an iPhone camera produces) is transcoded
57
+ to JPEG server-side whatever you choose, because no browser can render it.
58
+
59
+ | `fidelity` | Use it when | Stored at |
60
+ |---|---|---|
61
+ | `"standard"` | The image is only looked AT and archived — site and container surveys, the forty-image drop. Never read by a model, so it is sized purely for upload speed and storage. | 1280 px, q 0.80 |
62
+ | `"high"` *(default)* | The image is READ — invoices, release orders, screenshots of dense tables — by a model or a person. Near-lossless, because a model's token cost depends on dimension alone, so quality is free accuracy. | 1568 px, q 0.95 |
63
+ | `"original"` | The bytes themselves matter: evidence of record, or anything that may be re-read at a higher fidelity later. | unchanged |
64
+
65
+ Getting it wrong is never fatal — too low stores fewer pixels than the reader wanted, it never
66
+ produces a wrong answer. Rule of thumb: **if text on the image has to be legible, or a person
67
+ may zoom, use `"high"`.** Reach for `"original"` only when the file IS the record rather than
68
+ a picture of it — it is the one step whose meaning survives a change to these numbers.
44
69
  - `uploading` — true while **any** upload from this hook is in flight.
45
70
  - `error` — message of the most recent failed upload; cleared when a new one starts.
46
71
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.56.1",
3
+ "version": "0.57.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {