@gmickel/gno 1.39.2 → 1.41.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/README.md +1 -0
- package/assets/skill/SKILL.md +17 -0
- package/assets/skill/cli-reference.md +48 -0
- package/assets/skill/mcp-reference.md +24 -0
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v1.39.2.zip → gno-browser-clipper-v1.41.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.41.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +1 -1
- package/spec/cli.md +146 -7
- package/spec/db/schema.sql +17 -0
- package/spec/mcp.md +194 -0
- package/spec/output-schemas/memory-recall.schema.json +159 -0
- package/spec/output-schemas/memory-remember.schema.json +164 -0
- package/spec/output-schemas/status.schema.json +269 -54
- package/src/cli/commands/memory.ts +491 -0
- package/src/cli/commands/status.ts +23 -4
- package/src/cli/options.ts +4 -0
- package/src/cli/program.ts +127 -0
- package/src/config/types.ts +7 -0
- package/src/core/audit-provenance.ts +91 -0
- package/src/core/audit-workspace.ts +17 -0
- package/src/core/memory-diagnostics.ts +144 -0
- package/src/core/memory-fence.ts +239 -0
- package/src/core/memory-recall.ts +269 -0
- package/src/core/memory-record.ts +435 -0
- package/src/core/memory-remember.ts +425 -0
- package/src/core/memory-types.ts +211 -0
- package/src/core/memory.ts +87 -0
- package/src/core/network-boundary-inventory.ts +10 -0
- package/src/ingestion/sync.ts +17 -0
- package/src/mcp/http-egress.ts +2 -0
- package/src/mcp/tools/index.ts +43 -0
- package/src/mcp/tools/memory-recall.ts +122 -0
- package/src/mcp/tools/memory-remember.ts +177 -0
- package/src/mcp/tools/memory-shared.ts +80 -0
- package/src/pipeline/search.ts +2 -0
- package/src/pipeline/types.ts +8 -0
- package/src/sdk/client.ts +94 -1
- package/src/sdk/index.ts +13 -0
- package/src/sdk/types.ts +28 -0
- package/src/serve/CLAUDE.md +22 -2
- package/src/serve/clipper-security.ts +3 -17
- package/src/serve/public/components/pdf/PdfViewer.tsx +97 -19
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/globals.css +4 -0
- package/src/serve/public/hooks/use-pdf-document.ts +101 -37
- package/src/serve/public/hooks/use-pdf-pages.ts +324 -92
- package/src/serve/public/lib/pdf-transport.ts +13 -0
- package/src/serve/public/lib/pdf.ts +77 -10
- package/src/serve/public/lib/server-capabilities.ts +19 -0
- package/src/serve/public/pages/Ask.tsx +8 -9
- package/src/serve/public/pages/DocView.tsx +80 -18
- package/src/serve/public/pages/Search.tsx +8 -9
- package/src/serve/request-locality.ts +84 -0
- package/src/serve/routes/api.ts +255 -3
- package/src/serve/server.ts +189 -29
- package/src/store/migrations/027-memory-scopes.ts +37 -0
- package/src/store/migrations/index.ts +2 -0
- package/src/store/sqlite/adapter.ts +127 -3
- package/src/store/types.ts +54 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.39.2.zip.sha256 +0 -1
package/src/sdk/client.ts
CHANGED
|
@@ -38,7 +38,11 @@ import type {
|
|
|
38
38
|
GnoMoveNoteOptions,
|
|
39
39
|
GnoMultiGetOptions,
|
|
40
40
|
GnoQueryOptions,
|
|
41
|
+
GnoRecallInput,
|
|
42
|
+
GnoRecallResult,
|
|
41
43
|
GnoRefactorNoteResult,
|
|
44
|
+
GnoRememberInput,
|
|
45
|
+
GnoRememberResult,
|
|
42
46
|
GnoRenameNoteApplyOptions,
|
|
43
47
|
GnoRenameNoteOptions,
|
|
44
48
|
GnoSearchOptions,
|
|
@@ -113,6 +117,11 @@ import {
|
|
|
113
117
|
listKnowledgeChanges,
|
|
114
118
|
type KnowledgeDeltaServiceResult,
|
|
115
119
|
} from "../core/knowledge-delta";
|
|
120
|
+
import {
|
|
121
|
+
MemoryError,
|
|
122
|
+
type MemoryErrorCode,
|
|
123
|
+
MemoryService,
|
|
124
|
+
} from "../core/memory";
|
|
116
125
|
import { resolveNoteCreatePlan } from "../core/note-creation";
|
|
117
126
|
import { resolveNotePreset } from "../core/note-presets";
|
|
118
127
|
import {
|
|
@@ -144,6 +153,7 @@ import {
|
|
|
144
153
|
} from "../core/sections";
|
|
145
154
|
import { normalizeStructuredQueryInput } from "../core/structured-query";
|
|
146
155
|
import { parseAndValidateTagFilter } from "../core/tags";
|
|
156
|
+
import { writeLeasePath } from "../core/write-lease";
|
|
147
157
|
import {
|
|
148
158
|
defaultSyncService,
|
|
149
159
|
type SyncResult,
|
|
@@ -171,7 +181,7 @@ import {
|
|
|
171
181
|
multiGetDocuments,
|
|
172
182
|
} from "./documents";
|
|
173
183
|
import { runEmbed } from "./embed";
|
|
174
|
-
import { sdkError } from "./errors";
|
|
184
|
+
import { type GnoSdkErrorCode, sdkError } from "./errors";
|
|
175
185
|
|
|
176
186
|
interface OpenedClientState {
|
|
177
187
|
config: Config;
|
|
@@ -295,6 +305,41 @@ async function resolveClientState(
|
|
|
295
305
|
};
|
|
296
306
|
}
|
|
297
307
|
|
|
308
|
+
/** SDK error family per memory code; exhaustive so a new code fails to compile. */
|
|
309
|
+
const MEMORY_ERROR_TO_SDK: Readonly<Record<MemoryErrorCode, GnoSdkErrorCode>> =
|
|
310
|
+
{
|
|
311
|
+
MEMORY_TEXT_REQUIRED: "VALIDATION",
|
|
312
|
+
MEMORY_TEXT_TOO_LARGE: "VALIDATION",
|
|
313
|
+
MEMORY_QUERY_REQUIRED: "VALIDATION",
|
|
314
|
+
MEMORY_BUDGET_INVALID: "VALIDATION",
|
|
315
|
+
MEMORY_COLLECTION_REQUIRED: "VALIDATION",
|
|
316
|
+
MEMORY_COLLECTION_NOT_FOUND: "NOT_FOUND",
|
|
317
|
+
MEMORY_COLLECTION_UNMANAGED: "VALIDATION",
|
|
318
|
+
MEMORY_SCOPES_REQUIRED: "VALIDATION",
|
|
319
|
+
MEMORY_SCOPES_INVALID: "VALIDATION",
|
|
320
|
+
MEMORY_IDENTITY_REQUIRED: "VALIDATION",
|
|
321
|
+
MEMORY_DECISION_INVALID: "VALIDATION",
|
|
322
|
+
MEMORY_PREDECESSOR_REQUIRED: "VALIDATION",
|
|
323
|
+
MEMORY_PREDECESSOR_NOT_FOUND: "NOT_FOUND",
|
|
324
|
+
MEMORY_PREDECESSOR_HASH_MISMATCH: "RUNTIME",
|
|
325
|
+
MEMORY_SUPERSEDE_CONFLICT: "RUNTIME",
|
|
326
|
+
MEMORY_SUPERSEDE_PROJECTION_FAILED: "RUNTIME",
|
|
327
|
+
MEMORY_FENCED_REPLAY: "VALIDATION",
|
|
328
|
+
MEMORY_FENCED_DERIVED: "VALIDATION",
|
|
329
|
+
MEMORY_WRITE_LEASE_BUSY: "RUNTIME",
|
|
330
|
+
MEMORY_SYNC_FAILED: "RUNTIME",
|
|
331
|
+
MEMORY_QUERY_FAILED: "RUNTIME",
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/** Map a core MemoryError onto the SDK error family; the memory code survives in `details.code`. */
|
|
335
|
+
function toMemorySdkError(cause: unknown): unknown {
|
|
336
|
+
if (!(cause instanceof MemoryError)) return cause;
|
|
337
|
+
return sdkError(MEMORY_ERROR_TO_SDK[cause.code], cause.message, {
|
|
338
|
+
cause,
|
|
339
|
+
details: { code: cause.code },
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
298
343
|
class GnoClientImpl implements GnoClient {
|
|
299
344
|
config: Config;
|
|
300
345
|
readonly dbPath: string;
|
|
@@ -1581,6 +1626,54 @@ class GnoClientImpl implements GnoClient {
|
|
|
1581
1626
|
};
|
|
1582
1627
|
}
|
|
1583
1628
|
|
|
1629
|
+
/**
|
|
1630
|
+
* The memory service owns the shared write lease; the SDK never takes it.
|
|
1631
|
+
* Embedding is best-effort: without a local model the service reports
|
|
1632
|
+
* lexical-only matching/retrieval instead of failing.
|
|
1633
|
+
*/
|
|
1634
|
+
private async withMemoryService<T>(
|
|
1635
|
+
collection: string | undefined,
|
|
1636
|
+
run: (service: MemoryService) => Promise<T>
|
|
1637
|
+
): Promise<T> {
|
|
1638
|
+
this.assertOpen();
|
|
1639
|
+
const ports = await this.createRuntimePorts({
|
|
1640
|
+
embed: true,
|
|
1641
|
+
collection: this.config.collections.some(
|
|
1642
|
+
(candidate) => candidate.name === collection
|
|
1643
|
+
)
|
|
1644
|
+
? collection
|
|
1645
|
+
: undefined,
|
|
1646
|
+
});
|
|
1647
|
+
try {
|
|
1648
|
+
return await run(
|
|
1649
|
+
new MemoryService({
|
|
1650
|
+
store: this.store,
|
|
1651
|
+
config: this.config,
|
|
1652
|
+
collections: this.config.collections,
|
|
1653
|
+
lockPath: writeLeasePath(this.dbPath),
|
|
1654
|
+
embedPort: ports.embedPort,
|
|
1655
|
+
vectorIndex: ports.vectorIndex,
|
|
1656
|
+
})
|
|
1657
|
+
);
|
|
1658
|
+
} catch (cause) {
|
|
1659
|
+
throw toMemorySdkError(cause);
|
|
1660
|
+
} finally {
|
|
1661
|
+
await this.disposeRuntimePorts(ports);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
async remember(input: GnoRememberInput): Promise<GnoRememberResult> {
|
|
1666
|
+
return this.withMemoryService(input?.collection, (service) =>
|
|
1667
|
+
service.remember(input)
|
|
1668
|
+
);
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
async recall(input: GnoRecallInput): Promise<GnoRecallResult> {
|
|
1672
|
+
return this.withMemoryService(input?.collection, (service) =>
|
|
1673
|
+
service.recall(input)
|
|
1674
|
+
);
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1584
1677
|
async capture(options: GnoCaptureOptions): Promise<GnoCaptureResult> {
|
|
1585
1678
|
this.assertOpen();
|
|
1586
1679
|
const collection = this.getCollections(options.collection)[0];
|
package/src/sdk/index.ts
CHANGED
|
@@ -58,6 +58,10 @@ export type {
|
|
|
58
58
|
GnoMultiGetOptions,
|
|
59
59
|
GnoMultiGetResult,
|
|
60
60
|
GnoQueryOptions,
|
|
61
|
+
GnoRecallInput,
|
|
62
|
+
GnoRecallResult,
|
|
63
|
+
GnoRememberInput,
|
|
64
|
+
GnoRememberResult,
|
|
61
65
|
GnoRenameNoteApplyOptions,
|
|
62
66
|
GnoRenameNoteOptions,
|
|
63
67
|
GnoProjectHintOptions,
|
|
@@ -76,7 +80,16 @@ export type {
|
|
|
76
80
|
SectionTargetCreateSelector,
|
|
77
81
|
SectionTargetResolveResult,
|
|
78
82
|
SectionTargetV1,
|
|
83
|
+
MemoryCandidate,
|
|
84
|
+
MemoryFact,
|
|
85
|
+
MemoryRecallReceipt,
|
|
86
|
+
RecalledFact,
|
|
79
87
|
} from "./types";
|
|
88
|
+
export {
|
|
89
|
+
MemoryError,
|
|
90
|
+
type MemoryDecision,
|
|
91
|
+
type MemoryErrorCode,
|
|
92
|
+
} from "../core/memory";
|
|
80
93
|
export {
|
|
81
94
|
ContextCapsuleContractError,
|
|
82
95
|
type ContextCapsuleErrorCode,
|
package/src/sdk/types.ts
CHANGED
|
@@ -43,6 +43,16 @@ import type {
|
|
|
43
43
|
KnowledgeImpactResult,
|
|
44
44
|
ListKnowledgeChangesInput,
|
|
45
45
|
} from "../core/knowledge-delta";
|
|
46
|
+
import type {
|
|
47
|
+
MemoryCandidate,
|
|
48
|
+
MemoryFact,
|
|
49
|
+
MemoryRecallReceipt,
|
|
50
|
+
RecalledFact,
|
|
51
|
+
RecallInput,
|
|
52
|
+
RecallResult,
|
|
53
|
+
RememberInput,
|
|
54
|
+
RememberResult,
|
|
55
|
+
} from "../core/memory";
|
|
46
56
|
import type { NoteCollisionPolicy } from "../core/note-creation";
|
|
47
57
|
import type { NotePresetId } from "../core/note-presets";
|
|
48
58
|
import type {
|
|
@@ -245,6 +255,13 @@ export interface GnoCaptureOptions extends Omit<CaptureInput, "overwrite"> {}
|
|
|
245
255
|
|
|
246
256
|
export type GnoCaptureResult = CaptureReceipt;
|
|
247
257
|
|
|
258
|
+
/** Shared memory contract (identical on CLI, MCP, REST, and SDK). */
|
|
259
|
+
export type GnoRememberInput = RememberInput;
|
|
260
|
+
export type GnoRememberResult = RememberResult;
|
|
261
|
+
export type GnoRecallInput = RecallInput;
|
|
262
|
+
export type GnoRecallResult = RecallResult;
|
|
263
|
+
export type { MemoryCandidate, MemoryFact, MemoryRecallReceipt, RecalledFact };
|
|
264
|
+
|
|
248
265
|
export interface GnoCreateFolderOptions {
|
|
249
266
|
collection: string;
|
|
250
267
|
name: string;
|
|
@@ -370,6 +387,17 @@ export interface GnoClient {
|
|
|
370
387
|
embed(options?: GnoEmbedOptions): Promise<GnoEmbedResult>;
|
|
371
388
|
index(options?: GnoIndexOptions): Promise<GnoIndexResult>;
|
|
372
389
|
capture(options: GnoCaptureOptions): Promise<GnoCaptureResult>;
|
|
390
|
+
/**
|
|
391
|
+
* Store one fact in a memory-managed collection, or propose candidates when
|
|
392
|
+
* `decision` is omitted. Requires caller + session identity and explicit
|
|
393
|
+
* scopes. Errors carry the stable memory code in `details.code`.
|
|
394
|
+
*/
|
|
395
|
+
remember(input: GnoRememberInput): Promise<GnoRememberResult>;
|
|
396
|
+
/**
|
|
397
|
+
* Budgeted, cited recall of current facts in the caller's explicit scopes.
|
|
398
|
+
* The result carries a content-free fencing receipt.
|
|
399
|
+
*/
|
|
400
|
+
recall(input: GnoRecallInput): Promise<GnoRecallResult>;
|
|
373
401
|
createNote(options: GnoCreateNoteOptions): Promise<GnoCreateNoteResult>;
|
|
374
402
|
createFolder(options: GnoCreateFolderOptions): Promise<GnoCreateFolderResult>;
|
|
375
403
|
previewRenameNote(
|
package/src/serve/CLAUDE.md
CHANGED
|
@@ -71,12 +71,13 @@ Answer generation uses shared module to stay in sync with CLI:
|
|
|
71
71
|
| --------------------- | ------ | ------------------------------------------ |
|
|
72
72
|
| `/api/health` | GET | Health check |
|
|
73
73
|
| `/api/status` | GET | Index stats, onboarding, health, bootstrap |
|
|
74
|
-
| `/api/capabilities` | GET | Available features
|
|
74
|
+
| `/api/capabilities` | GET | Available features + `localClient` |
|
|
75
75
|
| `/api/collections` | GET | List collections |
|
|
76
76
|
| `/api/publish/export` | POST | Export gno.sh publish artifact JSON |
|
|
77
77
|
| `/api/docs` | GET | List documents |
|
|
78
78
|
| `/api/doc` | GET | Get document content |
|
|
79
|
-
| `/api/doc-asset` | GET |
|
|
79
|
+
| `/api/doc-asset` | GET | Source file bytes (Range, HEAD, ETag/304) |
|
|
80
|
+
| `/api/docs/:id/reveal`| POST | Reveal source on host (local client only) |
|
|
80
81
|
| `/vendor/pdfjs/*` | GET | Same-origin pdfjs worker/cmaps/fonts |
|
|
81
82
|
| `/api/search` | POST | BM25 search |
|
|
82
83
|
| `/api/query` | POST | Hybrid search |
|
|
@@ -94,6 +95,17 @@ Answer generation uses shared module to stay in sync with CLI:
|
|
|
94
95
|
- **AI Elements**: Conversation, Message, Sources, CodeBlock, Loader
|
|
95
96
|
- **Tag Components**: TagInput, TagFacets (filter sidebar)
|
|
96
97
|
- **Routing**: Simple hash-free SPA routing in App.tsx
|
|
98
|
+
- **Server capabilities**: `public/lib/server-capabilities.ts` is the one
|
|
99
|
+
shared `ServerCapabilities` type (`localClient`) plus
|
|
100
|
+
`fetchServerCapabilities()`; a failed read counts as remote
|
|
101
|
+
- **PDF viewer transport**: `public/lib/pdf-transport.ts` holds the tier
|
|
102
|
+
constants (`PDF_WHOLE_FILE_MAX_BYTES` = 8 MB, `PDF_RANGE_CHUNK_BYTES` =
|
|
103
|
+
1 MB) with no pdfjs import so Bun-side tooling can read them;
|
|
104
|
+
`hooks/use-pdf-document.ts` sends one same-origin `HEAD` per load to pick
|
|
105
|
+
the tier and falls back to ranged on any probe failure;
|
|
106
|
+
`hooks/use-pdf-pages.ts` publishes page slots from page 1 geometry and
|
|
107
|
+
corrects sizes and fit scales in one commit when the full pass lands
|
|
108
|
+
(fatal error = page 1 or document; later pages carry `slot.error`)
|
|
97
109
|
|
|
98
110
|
## Development
|
|
99
111
|
|
|
@@ -110,6 +122,14 @@ gno serve --port 3000
|
|
|
110
122
|
- Binds to `127.0.0.1` only (no LAN exposure)
|
|
111
123
|
- CSP headers on all responses
|
|
112
124
|
- CORS protection on POST endpoints
|
|
125
|
+
- Request locality (`request-locality.ts`): `localClient` is true only for a
|
|
126
|
+
loopback peer, a loopback `Host`, and no forwarding headers; the reveal
|
|
127
|
+
route refuses non-local clients with 403
|
|
128
|
+
- HTTP caching: `/api/doc-asset` sends a strong `ETag` (size + mtime) with
|
|
129
|
+
`private, max-age=0, must-revalidate` and answers `If-None-Match` with 304
|
|
130
|
+
before Range handling; hashed SPA chunks (`/chunk-*.js|css`) get a one-year
|
|
131
|
+
`immutable` policy and a gzip body via `createPublicFetchFallback` in
|
|
132
|
+
`server.ts` (production only; the entry HTML and dev/HMR are untouched)
|
|
113
133
|
- No external font/script loading — the PDF.js worker, cMaps, and standard fonts
|
|
114
134
|
are served same-origin from the installed `pdfjs-dist` package via
|
|
115
135
|
`/vendor/pdfjs/*`, so the CSP keeps `worker-src 'self'` and `font-src 'self'`
|
|
@@ -4,6 +4,7 @@ import type { HttpMcpPeerServer } from "../mcp/http-security";
|
|
|
4
4
|
|
|
5
5
|
import { readClipperBoundedJson } from "./clipper-body";
|
|
6
6
|
import { clipperSecurityErrorResponse } from "./clipper-security-errors";
|
|
7
|
+
import { isLoopbackAddress } from "./request-locality";
|
|
7
8
|
import { ReaderGate } from "./resident-admission";
|
|
8
9
|
|
|
9
10
|
export { readClipperBoundedJson } from "./clipper-body";
|
|
@@ -126,7 +127,7 @@ function validateSameOriginEntry(origin: string): boolean {
|
|
|
126
127
|
|
|
127
128
|
function isLoopbackHostname(hostname: string): boolean {
|
|
128
129
|
if (hostname === "localhost") return true;
|
|
129
|
-
return
|
|
130
|
+
return isLoopbackAddress(hostname.replace(/^\[|\]$/g, ""));
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
function normalizedHeaderNames(headers: readonly string[]): string[] {
|
|
@@ -144,21 +145,6 @@ function normalizedHeaderNames(headers: readonly string[]): string[] {
|
|
|
144
145
|
return [...new Set(normalized)];
|
|
145
146
|
}
|
|
146
147
|
|
|
147
|
-
export function isClipperLoopbackAddress(address: string): boolean {
|
|
148
|
-
const normalized = address.toLowerCase();
|
|
149
|
-
if (normalized === "::1") return true;
|
|
150
|
-
const mapped = normalized.startsWith("::ffff:")
|
|
151
|
-
? normalized.slice("::ffff:".length)
|
|
152
|
-
: normalized;
|
|
153
|
-
const octets = mapped.split(".");
|
|
154
|
-
return (
|
|
155
|
-
octets.length === 4 &&
|
|
156
|
-
octets.every((octet) => /^\d{1,3}$/.test(octet)) &&
|
|
157
|
-
Number(octets[0]) === 127 &&
|
|
158
|
-
octets.every((octet) => Number(octet) <= 255)
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
148
|
export function parseClipperExtensionOrigin(
|
|
163
149
|
origin: string
|
|
164
150
|
): { extensionId: string; origin: string } | null {
|
|
@@ -370,7 +356,7 @@ export class ClipperSecurityBoundary {
|
|
|
370
356
|
}
|
|
371
357
|
| ClipperSecurityFailure {
|
|
372
358
|
const peer = server.requestIP(request);
|
|
373
|
-
if (!peer || !
|
|
359
|
+
if (!peer || !isLoopbackAddress(peer.address)) {
|
|
374
360
|
return {
|
|
375
361
|
ok: false,
|
|
376
362
|
response: clipperSecurityErrorResponse("CLIPPER_FORBIDDEN"),
|
|
@@ -99,6 +99,69 @@ function StatePanel({
|
|
|
99
99
|
);
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
const ERROR_EYEBROW: Record<PdfFallbackReason, string> = {
|
|
103
|
+
corrupt: "CANNOT RENDER",
|
|
104
|
+
password: "PASSWORD PROTECTED",
|
|
105
|
+
network: "COULD NOT LOAD",
|
|
106
|
+
bootstrap: "VIEWER UNAVAILABLE",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const PAGE_ERROR_BODY: Record<PdfFallbackReason, string> = {
|
|
110
|
+
corrupt: "This page could not be rendered.",
|
|
111
|
+
password: "This page is password protected.",
|
|
112
|
+
network: "This page could not be loaded from this session.",
|
|
113
|
+
bootstrap: "The PDF viewer could not render this page.",
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
type PdfPageErrorSlotProps = {
|
|
117
|
+
pageNumber: number;
|
|
118
|
+
width: number;
|
|
119
|
+
height: number;
|
|
120
|
+
error: PdfFallbackReason;
|
|
121
|
+
onMount: (pageNumber: number, el: HTMLElement | null) => void;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A later page whose geometry failed (R2): the page error state rendered in
|
|
126
|
+
* place of the page, at the slot's placeholder size so the scroll model and
|
|
127
|
+
* the visibility window keep their shape. Never fires the text fallback.
|
|
128
|
+
*/
|
|
129
|
+
function PdfPageErrorSlot({
|
|
130
|
+
pageNumber,
|
|
131
|
+
width,
|
|
132
|
+
height,
|
|
133
|
+
error,
|
|
134
|
+
onMount,
|
|
135
|
+
}: PdfPageErrorSlotProps) {
|
|
136
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
137
|
+
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
onMount(pageNumber, rootRef.current);
|
|
140
|
+
return () => {
|
|
141
|
+
onMount(pageNumber, null);
|
|
142
|
+
};
|
|
143
|
+
}, [pageNumber, onMount]);
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div
|
|
147
|
+
className="gno-pdf-page flex flex-col items-center justify-center gap-2 px-4 text-center"
|
|
148
|
+
data-page-error={error}
|
|
149
|
+
data-page-number={pageNumber}
|
|
150
|
+
data-testid={`pdf-page-${pageNumber}`}
|
|
151
|
+
ref={rootRef}
|
|
152
|
+
role="status"
|
|
153
|
+
style={{ width: width || undefined, height: height || undefined }}
|
|
154
|
+
>
|
|
155
|
+
<p className="font-mono text-[10px] text-muted-foreground/60 uppercase tracking-[0.15em]">
|
|
156
|
+
{ERROR_EYEBROW[error]}
|
|
157
|
+
</p>
|
|
158
|
+
<p className="text-[13px] text-foreground/90 leading-relaxed">
|
|
159
|
+
Page {pageNumber}: {PAGE_ERROR_BODY[error]}
|
|
160
|
+
</p>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
102
165
|
function prefersReducedMotion(): boolean {
|
|
103
166
|
if (
|
|
104
167
|
typeof window === "undefined" ||
|
|
@@ -184,7 +247,11 @@ export function PdfViewer({
|
|
|
184
247
|
containerWidth,
|
|
185
248
|
containerHeight,
|
|
186
249
|
genId,
|
|
250
|
+
scrollContainerRef: columnRef,
|
|
187
251
|
});
|
|
252
|
+
// Fatal only: a document load failure or the hook's fatal error (page 1
|
|
253
|
+
// geometry, render-path acquisition). A later page's geometry failure rides
|
|
254
|
+
// on its slot and never reaches this path.
|
|
188
255
|
const viewerError = status === "error" ? error : pages.error;
|
|
189
256
|
|
|
190
257
|
// Fallback: exactly once per failed load/page attempt when extracted text exists.
|
|
@@ -400,7 +467,7 @@ export function PdfViewer({
|
|
|
400
467
|
<StatePanel
|
|
401
468
|
body="This PDF could not be rendered. Download the original to read it."
|
|
402
469
|
downloadUrl={downloadUrl}
|
|
403
|
-
eyebrow=
|
|
470
|
+
eyebrow={ERROR_EYEBROW.corrupt}
|
|
404
471
|
onRetry={handleRetry}
|
|
405
472
|
role="alert"
|
|
406
473
|
testId="pdf-state-corrupt"
|
|
@@ -411,7 +478,7 @@ export function PdfViewer({
|
|
|
411
478
|
<StatePanel
|
|
412
479
|
body="This PDF is password protected. Download the original to open it in a PDF reader."
|
|
413
480
|
downloadUrl={downloadUrl}
|
|
414
|
-
eyebrow=
|
|
481
|
+
eyebrow={ERROR_EYEBROW.password}
|
|
415
482
|
role="alert"
|
|
416
483
|
testId="pdf-state-password"
|
|
417
484
|
/>
|
|
@@ -421,7 +488,7 @@ export function PdfViewer({
|
|
|
421
488
|
<StatePanel
|
|
422
489
|
body="The document could not be loaded from this session. Try again, or download the original."
|
|
423
490
|
downloadUrl={downloadUrl}
|
|
424
|
-
eyebrow=
|
|
491
|
+
eyebrow={ERROR_EYEBROW.network}
|
|
425
492
|
onRetry={handleRetry}
|
|
426
493
|
role="alert"
|
|
427
494
|
testId="pdf-state-network"
|
|
@@ -432,7 +499,7 @@ export function PdfViewer({
|
|
|
432
499
|
<StatePanel
|
|
433
500
|
body="The PDF viewer could not start in this window. Download the original to read it."
|
|
434
501
|
downloadUrl={downloadUrl}
|
|
435
|
-
eyebrow=
|
|
502
|
+
eyebrow={ERROR_EYEBROW.bootstrap}
|
|
436
503
|
onRetry={handleRetry}
|
|
437
504
|
role="alert"
|
|
438
505
|
testId="pdf-state-bootstrap"
|
|
@@ -512,21 +579,32 @@ export function PdfViewer({
|
|
|
512
579
|
|
|
513
580
|
{showProgressive ? (
|
|
514
581
|
<div className="mx-auto flex w-fit flex-col items-center gap-6">
|
|
515
|
-
{pages.slots.map((slot) =>
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
582
|
+
{pages.slots.map((slot) =>
|
|
583
|
+
slot.error ? (
|
|
584
|
+
<PdfPageErrorSlot
|
|
585
|
+
key={slot.pageNumber}
|
|
586
|
+
error={slot.error}
|
|
587
|
+
height={slot.height}
|
|
588
|
+
onMount={pages.observePage}
|
|
589
|
+
pageNumber={slot.pageNumber}
|
|
590
|
+
width={slot.width}
|
|
591
|
+
/>
|
|
592
|
+
) : (
|
|
593
|
+
<PdfPageView
|
|
594
|
+
key={slot.pageNumber}
|
|
595
|
+
active={slot.active}
|
|
596
|
+
doc={doc}
|
|
597
|
+
height={slot.height}
|
|
598
|
+
onMount={pages.observePage}
|
|
599
|
+
onInternalNavigate={goToPage}
|
|
600
|
+
onRender={pages.ensureRendered}
|
|
601
|
+
pageNumber={slot.pageNumber}
|
|
602
|
+
rendered={slot.rendered}
|
|
603
|
+
scale={pages.scale}
|
|
604
|
+
width={slot.width}
|
|
605
|
+
/>
|
|
606
|
+
)
|
|
607
|
+
)}
|
|
530
608
|
</div>
|
|
531
609
|
) : null}
|
|
532
610
|
</div>
|