@crosery/dsh-viewer 0.0.0-stage → 0.3.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.
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The asset route: bytes for one signed reference, with range support.
3
+ *
4
+ * Range handling is not an optimization here — it is what makes `<video>` and
5
+ * `<audio>` seekable at all. Chrome and Safari will not expose a scrub bar for
6
+ * a response that does not advertise `accept-ranges`, and Safari refuses to
7
+ * start playback of a media response that answers a range request with 200.
8
+ *
9
+ * Bytes are streamed from the process path rather than pulled through
10
+ * `ctx.fs.readBytes`, which materializes a whole file in memory: a two-hour
11
+ * video is exactly the case this plugin exists for. The tradeoff is that the
12
+ * route is only available on a backend that exposes a local path — `index.ts`
13
+ * degrades that case to the attachment path for admissible rasters, and to an
14
+ * unavailable card otherwise.
15
+ * @module @crosery/dsh-viewer/asset-route
16
+ */
17
+ import type { IncomingMessage, ServerResponse } from 'node:http';
18
+ import { type ViewerKind } from './contract.ts';
19
+ /** One parsed, satisfiable byte range. */
20
+ interface ByteRange {
21
+ start: number;
22
+ end: number;
23
+ }
24
+ /**
25
+ * Parse a single-range `Range` header against a known size.
26
+ *
27
+ * Only the single-range form is honored. A multipart range response is a
28
+ * different content type with its own framing, and no browser media element
29
+ * needs one; answering the whole entity is the specified fallback.
30
+ * @param header - the raw header value, or undefined.
31
+ * @param size - the entity's total byte length.
32
+ * @returns the resolved range, `undefined` for no usable range, or `'invalid'`
33
+ * when the client named a range that cannot be satisfied.
34
+ */
35
+ export declare function parseRange(header: string | undefined, size: number): ByteRange | undefined | 'invalid';
36
+ /**
37
+ * Response headers that keep an inline-served asset from becoming an XSS
38
+ * vector.
39
+ *
40
+ * The route answers on the app's own origin, so a user who opens an asset URL
41
+ * in a tab navigates to it as a document. For `text/html` and `image/svg+xml`
42
+ * that document would otherwise run its own script with the app's origin. The
43
+ * CSP header removes that: `sandbox` puts a navigated HTML asset in an opaque
44
+ * origin, and `default-src 'none'` leaves an SVG document with no script at
45
+ * all. Both are inert when the asset is loaded as a subresource, which is the
46
+ * normal path.
47
+ * @param kind - the classified viewer kind.
48
+ * @returns extra headers for this kind, possibly empty.
49
+ */
50
+ export declare function guardHeaders(kind: ViewerKind): Record<string, string>;
51
+ /** Optional behavior of {@link assetHandler}. */
52
+ export interface AssetHandlerOptions {
53
+ /**
54
+ * The converted-document cache. Serving one of its artifacts marks it used,
55
+ * so the artifact behind a card someone is looking at is not evicted.
56
+ */
57
+ cacheDir?: string;
58
+ }
59
+ /**
60
+ * Build the asset route's handler.
61
+ * @param secret - a thunk returning the harness MAC key, or `undefined` while
62
+ * key material is still loading (the route answers 503 until it resolves).
63
+ * @param options - optional behavior; see {@link AssetHandlerOptions}.
64
+ * @returns the `webServer` route handler.
65
+ */
66
+ export declare function assetHandler(secret: () => Buffer | undefined, options?: AssetHandlerOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
67
+ export {};
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Signed asset references: how a card names a file to the asset route without
3
+ * the route ever trusting a path the browser supplied.
4
+ *
5
+ * The design constraint is that a card must survive a harness restart. A card
6
+ * rebuilt from a replayed session log holds only the URL that was minted months
7
+ * ago, so an in-process token table would leave every reopened video dead. The
8
+ * reference is therefore self-contained — the path travels in the URL and a
9
+ * keyed MAC travels beside it — and the route accepts a path only when the MAC
10
+ * proves this harness minted it.
11
+ *
12
+ * The MAC key is the whole security boundary: without it the route would be an
13
+ * arbitrary-file-read endpoint on a port the user's browser can reach. It is
14
+ * generated once per harness home, stored 0600, and never leaves the Host.
15
+ * @module @crosery/dsh-viewer/asset-token
16
+ */
17
+ /** Query parameter carrying the base64url-encoded absolute path. */
18
+ export declare const PATH_PARAM = "p";
19
+ /** Query parameter carrying the hex MAC over that path. */
20
+ export declare const SIGNATURE_PARAM = "s";
21
+ /**
22
+ * Load the harness-local MAC key, generating it on first use.
23
+ *
24
+ * Written with the exclusive flag so two harness processes racing on a fresh
25
+ * home cannot end up with different keys — the loser reads the winner's file
26
+ * instead of overwriting it, which would have invalidated every URL the winner
27
+ * had already minted.
28
+ * @param keyPath - absolute path of the key file inside the harness home.
29
+ * @returns the raw key bytes.
30
+ */
31
+ export declare function loadAssetSecret(keyPath: string): Promise<Buffer>;
32
+ /**
33
+ * Mint the same-origin URL a card loads for one file.
34
+ * @param secret - the harness MAC key.
35
+ * @param processPath - the canonical absolute path in the backend's execution
36
+ * world, as `ctx.fs.processPath` reports it.
37
+ * @returns the signed, root-relative asset URL.
38
+ */
39
+ export declare function assetUrlFor(secret: Buffer, processPath: string): string;
40
+ /**
41
+ * Recover the path one asset request names, after proving this harness minted
42
+ * the reference.
43
+ *
44
+ * Comparison is constant-time over equal-length buffers; a length mismatch is
45
+ * rejected before the compare, because `timingSafeEqual` throws on unequal
46
+ * lengths and a length difference leaks nothing an attacker did not already
47
+ * choose.
48
+ * @param secret - the harness MAC key.
49
+ * @param search - the request URL's query string, leading `?` optional.
50
+ * @returns the verified absolute path, or `undefined` for a missing, malformed,
51
+ * or unsigned reference.
52
+ */
53
+ export declare function verifyAssetRequest(secret: Buffer, search: string): string | undefined;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Bounds on the converted-document cache.
3
+ *
4
+ * Every conversion leaves one PDF in a directory beside the asset key, shared
5
+ * by every profile on the machine. Without a bound that directory only grows:
6
+ * a user who previews a few documents a day accumulates gigabytes nobody asked
7
+ * to keep. So the cache is least-recently-used with three caps — age, entry
8
+ * count and total bytes — and "used" means either converted-or-hit by
9
+ * `display_file` or served to a card by the asset route, which is what keeps
10
+ * the artifact behind a card someone is looking at from aging out.
11
+ *
12
+ * Pruning is lazy: once when the plugin activates and after each write, never
13
+ * on a timer. It only ever considers regular files in the cache directory
14
+ * itself whose names this plugin writes, so a stray file, a symlink or a
15
+ * subdirectory placed there is never touched, and nothing outside the
16
+ * directory is reachable at all.
17
+ * @module @crosery/dsh-viewer/cache
18
+ */
19
+ /** The three caps one prune enforces. */
20
+ export interface CacheLimits {
21
+ /** An artifact unused for longer than this is removed. */
22
+ maxAgeMs: number;
23
+ /** At most this many artifacts are kept, most recently used first. */
24
+ maxEntries: number;
25
+ /** Kept artifacts total at most this many bytes. */
26
+ maxBytes: number;
27
+ }
28
+ /**
29
+ * The shipped caps: a month unused, 256 documents, 512 MiB. Generous enough
30
+ * that a card in an active conversation never loses its artifact; small enough
31
+ * that the directory stays an unremarkable size on a laptop.
32
+ */
33
+ export declare const CACHE_LIMITS: CacheLimits;
34
+ /**
35
+ * Whether a file name is one of this cache's artifacts.
36
+ * @param name - a bare file name.
37
+ * @returns true for a name a converter writes.
38
+ */
39
+ export declare function isArtifactName(name: string): boolean;
40
+ /**
41
+ * Write an artifact into place.
42
+ *
43
+ * The bytes land under a unique partial name beside the artifact — in the cache
44
+ * directory itself, never in the system temp directory — and are renamed into
45
+ * place last. Beside it because `rename` only works within one filesystem: a
46
+ * temp directory on another mount (a Linux `tmpfs` `/tmp`) fails it with
47
+ * `EXDEV`. Renamed last so a reader either finds no artifact or a complete
48
+ * one, never a half-written file served to a PDF viewer. The partial name is
49
+ * one {@link pruneCache} recognizes, so even a process killed mid-write leaves
50
+ * nothing behind for long; any failure here removes it at once.
51
+ * @param artifact - the artifact's absolute path.
52
+ * @param write - writes the complete bytes to the path it is given.
53
+ */
54
+ export declare function placeArtifact(artifact: string, write: (partial: string) => Promise<void>): Promise<void>;
55
+ /**
56
+ * Stamp an artifact as used now, if it exists.
57
+ *
58
+ * Doubles as the cache-hit test: a successful stamp is a hit, and the stamp is
59
+ * what moves the artifact to the front of the eviction order.
60
+ * @param path - the artifact's absolute path.
61
+ * @returns true when the artifact exists.
62
+ */
63
+ export declare function useArtifact(path: string): Promise<boolean>;
64
+ /**
65
+ * Refresh a served file's last-use stamp when it is one of this cache's
66
+ * artifacts. Any other file the asset route serves — the user's own images and
67
+ * videos — is left exactly as it is.
68
+ * @param cacheDir - the cache directory.
69
+ * @param path - the absolute path the route is serving.
70
+ * @param mtimeMs - its modification time, as the route's `stat` saw it.
71
+ * @param now - the current time.
72
+ */
73
+ export declare function useServedArtifact(cacheDir: string, path: string, mtimeMs: number, now?: number): Promise<void>;
74
+ /**
75
+ * Enforce the caps once.
76
+ *
77
+ * The most recently used artifact always survives, whatever the caps say: it
78
+ * is the one a card was most likely just handed.
79
+ * @param cacheDir - the cache directory; a missing directory is an empty cache.
80
+ * @param limits - the caps to enforce.
81
+ * @param now - the current time.
82
+ * @returns the absolute paths removed.
83
+ */
84
+ export declare function pruneCache(cacheDir: string, limits?: CacheLimits, now?: number): Promise<string[]>;
85
+ /**
86
+ * Prune in the background.
87
+ *
88
+ * Calls that arrive while a prune of the same directory runs fold into one
89
+ * more pass after it, so a burst of conversions costs two directory scans, not
90
+ * one per conversion. A failure is logged, never thrown: a cache that could not
91
+ * be trimmed still serves every card.
92
+ * @param cacheDir - the cache directory.
93
+ * @param limits - the caps to enforce.
94
+ * @returns settles when the directory has been pruned; never rejects.
95
+ */
96
+ export declare function schedulePrune(cacheDir: string, limits?: CacheLimits): Promise<void>;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The tool card: one row plus whatever element actually plays the file.
3
+ *
4
+ * Registered for both `display_file` and the shipped `read_image`, so an image
5
+ * the model pulled in through the shipped tool is shown as a picture rather
6
+ * than as a bare text row on the trains whose `read_image` has no view of its
7
+ * own (see `client/index.ts` for how the two coexist where it does).
8
+ *
9
+ * Two byte sources, in priority order. A signed asset URL streams straight from
10
+ * the Host and is the only one that can carry video, audio, PDF or HTML — and
11
+ * the only one that supports range requests, which is what makes a `<video>`
12
+ * seekable. A durable attachment is the fallback: it is images-only, but it
13
+ * works when the filesystem backend exposes no local path, and it is the only
14
+ * source a shipped `read_image` result has at all. From 0.1.7 the chat hands
15
+ * every tool view its own session-authorized image loader, and the card uses
16
+ * it; on older trains the plugin's own attachment reader stands in.
17
+ */
18
+ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots';
19
+ import { type DisplayValue } from '../contract.ts';
20
+ import { cardModel } from './card-model.ts';
21
+ import { type OwnerImageLoader, type ViewerSources } from './sources.ts';
22
+ /** Business face the plugin injects into every card occurrence. */
23
+ export interface ViewerCardInjected {
24
+ /**
25
+ * Resolve one durable attachment into a browser URL scoped to this session.
26
+ * The fallback for trains whose tool-view owner carries no `loadImage`.
27
+ * @param attachmentId - the opaque durable id.
28
+ * @returns a URL valid until the plugin unloads.
29
+ */
30
+ loadAttachment: (attachmentId: string) => Promise<string>;
31
+ }
32
+ /** The runtime share this card actually reads off the toolview slot. */
33
+ export interface ViewerCardOwner {
34
+ /** Wire tool name this entry was dispatched for. */
35
+ toolName: string;
36
+ /** Frozen running call or settled result node. */
37
+ block: Parameters<typeof cardModel>[0];
38
+ /**
39
+ * Open a path through the Host: the operating system's default application
40
+ * up to 0.1.5, the right-Sidebar preview from 0.1.7. Every supported train
41
+ * supplies it; optional only so a card rendered outside a tool row (the turn
42
+ * tail) or by a test can omit it.
43
+ */
44
+ openFile?: ((path: string) => unknown) | undefined;
45
+ /** 0.1.7+: the chat's session-authorized loader for durable images. */
46
+ loadImage?: OwnerImageLoader | undefined;
47
+ }
48
+ /** Full card props: owner share, injected face, and the locale seat. */
49
+ export type ViewerCardProps = ViewerCardOwner & ViewerCardInjected & {
50
+ t: TranslateNS<'tool.viewer'>;
51
+ };
52
+ /** The element that plays one file, or the reason there is none. */
53
+ export declare function Viewer({ value, sources, t }: {
54
+ value: DisplayValue;
55
+ sources: ViewerSources;
56
+ t: TranslateNS<'tool.viewer'>;
57
+ }): import("react").JSX.Element;
58
+ /**
59
+ * One settled, displayable file: the header and its player.
60
+ *
61
+ * Shared by the tool row and by the turn tail, so a file shown after a turn
62
+ * completes looks exactly like the one shown while it ran.
63
+ * @param props - the display, how to reach its bytes, the tool it came from, and `t`.
64
+ * @returns the card.
65
+ */
66
+ export declare function DisplayedFile({ value, toolName, sources, t }: {
67
+ value: DisplayValue;
68
+ toolName: string;
69
+ sources: ViewerSources;
70
+ t: TranslateNS<'tool.viewer'>;
71
+ }): import("react").JSX.Element;
72
+ /**
73
+ * One `display_file` (or `read_image`) call, as a row plus its player.
74
+ * @param props - the toolview owner share, the injected loader, and `t`.
75
+ * @returns the card.
76
+ */
77
+ export declare function ViewerCard({ toolName, block, t, openFile, loadImage, loadAttachment }: ViewerCardProps): import("react").JSX.Element;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The turn-tail entry: a completed turn's displayed files, outside the fold.
3
+ *
4
+ * Rendered by the chat between a completed turn's closing reply and its action
5
+ * row, which is the one place a folded turn keeps visible — the harness's own
6
+ * delivery cards live there for the same reason. See `turn-tail.ts` for where
7
+ * the data comes from and when it is empty.
8
+ * @module @crosery/dsh-viewer/client/ViewerTail
9
+ */
10
+ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots';
11
+ import { type ViewerCardInjected } from './ViewerCard.tsx';
12
+ import { type FoldSource, type TurnLike } from './turn-tail.ts';
13
+ /** What the plugin injects into the tail entry. */
14
+ export interface ViewerTailInjected extends ViewerCardInjected {
15
+ /** Whether the chat currently folds completed turns. */
16
+ folds: FoldSource;
17
+ }
18
+ /** The tail owner's share, as far as this entry reads it. */
19
+ export interface ViewerTailOwner {
20
+ /** The completed turn. */
21
+ turn: TurnLike;
22
+ /** The closing reply's log sequence. */
23
+ seq: number;
24
+ /** Open a path through the Host. */
25
+ openFile?: ((path: string) => unknown) | undefined;
26
+ }
27
+ /** Full tail props: owner share, injected face, and the locale seat. */
28
+ export type ViewerTailProps = ViewerTailOwner & ViewerTailInjected & {
29
+ t: TranslateNS<'tool.viewer'>;
30
+ };
31
+ /**
32
+ * The completed turn's displays, or nothing.
33
+ *
34
+ * Nothing whenever the rows are on screen anyway — the chat does not fold, or
35
+ * this turn is still open, aborted, failed, or (0.1.7) was steered — so the
36
+ * tail never repeats a card the reader can already see.
37
+ * @param props - owner share, injected face, and `t`.
38
+ * @returns the tail section, or `null`.
39
+ */
40
+ export declare function ViewerTail({ turn, seq, openFile, folds, loadAttachment, t }: ViewerTailProps): import("react").JSX.Element | null;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Pure derivation of a card's state from one tool block.
3
+ *
4
+ * Everything here runs during replay of a session logged by an older build, so
5
+ * every read is defensive and every failure mode is a narrower card rather than
6
+ * a throw: a crashing entry is removed from its slot for the rest of the
7
+ * session, which would take the whole conversation's viewer cards down with it.
8
+ *
9
+ * Content blocks are narrowed structurally instead of through the Host's
10
+ * `ContentBlock` union. The union is merge-extensible, so a build that knows
11
+ * fewer arms than the log contains must still find the image arm it does know.
12
+ * @module @crosery/dsh-viewer/client/card-model
13
+ */
14
+ import { type DisplayValue } from '../contract.ts';
15
+ /**
16
+ * The slice of one tool-call block this card reads, declared structurally.
17
+ *
18
+ * NOT imported from the client shell's package: that block type has lived in
19
+ * two different packages across harness trains (`dsh-client-runtime` up to
20
+ * 0.1.1, `dsh-client-ui-chat` from 0.1.2) and the older one stopped publishing
21
+ * entirely. A type-only import would pin this plugin's toolchain to one train
22
+ * and make the other fail to typecheck — the same class of breakage as an
23
+ * import of a removed value export, one compilation later.
24
+ *
25
+ * Structure is also what this module actually uses. Every read below is
26
+ * defensive and every field is optional: the block may be a live call, a
27
+ * replayed log entry from an older build, or a shape a newer build wrote.
28
+ */
29
+ export interface ToolCallBlockLike {
30
+ /** Discriminates the running arm from the settled one. */
31
+ kind?: string;
32
+ /** Raw arguments, on the running arm. */
33
+ argsRaw?: string;
34
+ /** The settled call this result belongs to. */
35
+ call?: {
36
+ argsRaw?: string;
37
+ } | null;
38
+ /** Whether the tool reported a failure. */
39
+ isError?: boolean;
40
+ /** Model-facing content blocks. */
41
+ content?: readonly unknown[];
42
+ /** Structured failure, when there is one. */
43
+ error?: {
44
+ code?: string;
45
+ } | null;
46
+ /** Presentation metadata this plugin wrote, on the settled arm. */
47
+ meta?: unknown;
48
+ }
49
+ /** What the card renders right now. */
50
+ export type CardState =
51
+ /** The call is dispatched and no result has landed. */
52
+ {
53
+ phase: 'running';
54
+ path: string | undefined;
55
+ }
56
+ /** The call failed; `message` is the tool's own text. */
57
+ | {
58
+ phase: 'failed';
59
+ path: string | undefined;
60
+ message: string;
61
+ }
62
+ /** A displayable file, with everything the viewer needs. */
63
+ | {
64
+ phase: 'ready';
65
+ value: DisplayValue;
66
+ }
67
+ /**
68
+ * The call settled, but this build could not recover a viewer payload from
69
+ * it — an older log, a truncated window, a shape a newer build wrote. The row
70
+ * still renders; it just has nothing to play.
71
+ */
72
+ | {
73
+ phase: 'bare';
74
+ path: string | undefined;
75
+ message: string;
76
+ };
77
+ /**
78
+ * Recover the `file_path` argument from either arm of the block.
79
+ *
80
+ * The raw arguments are whatever the model emitted, so a parse failure is an
81
+ * ordinary outcome — a card with no path in its header, not an error.
82
+ * @param block - the running or settled call.
83
+ * @returns the requested path, or `undefined` when it cannot be read.
84
+ */
85
+ export declare function argumentPathOf(block: ToolCallBlockLike): string | undefined;
86
+ /**
87
+ * Find the first durable image carried by a settled result's content.
88
+ *
89
+ * This is what lets the plugin render the SHIPPED `read_image` tool, which
90
+ * writes no presentation metadata at all: its image rides the model-facing
91
+ * content blocks, and those are persisted with the result.
92
+ * @param content - the settled result's content blocks.
93
+ * @returns the validated image facts, or `undefined` when there is no image.
94
+ */
95
+ export declare function contentImageOf(content: readonly unknown[]): DisplayValue['image'] | undefined;
96
+ /**
97
+ * Rebuild a viewer payload from this plugin's own result envelope.
98
+ *
99
+ * The recovery path for a NESTED dispatch: the registry projects
100
+ * `presentationMeta` only for top-level calls, so a `display_file` invoked from
101
+ * inside `run_code` persists content blocks and nothing else. The envelope is
102
+ * this plugin's own structured output, and the result is validated through the
103
+ * same narrowing every replayed payload goes through — including the guard that
104
+ * refuses an asset URL which is not this plugin's route.
105
+ * @param content - the settled result's content blocks.
106
+ * @param inContext - whether the result also carried a durable image block.
107
+ * @returns the validated payload, or `undefined` when the envelope is not ours.
108
+ */
109
+ export declare function envelopeValueOf(content: readonly unknown[], inContext: boolean): DisplayValue | undefined;
110
+ /**
111
+ * Derive the card state for one call.
112
+ * @param block - the running or settled call.
113
+ * @param toolName - the wire tool name this entry was dispatched for; it selects
114
+ * which recovery path applies, since only `display_file` writes metadata.
115
+ * @returns the state to render.
116
+ */
117
+ export declare function cardModel(block: ToolCallBlockLike, toolName: string): CardState;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * What the page the card lives in can do, decided by capability — never by the
3
+ * user agent.
4
+ *
5
+ * The desktop app runs the same web consumer as `dsh web`, so nothing in the
6
+ * plugin contract tells the two apart. Two facts do. The desktop renderer loads
7
+ * the app from its own `dsh-app:` scheme, and its preload publishes
8
+ * `__DSH_HOST_PATHS__` (the dropped-file path bridge) on the main frame only.
9
+ * Either one means the page is the desktop window, and with it the desktop's
10
+ * rules that matter to a card: `target=_blank` links to app URLs are silently
11
+ * denied (only http(s) leaves the window), and media loaded through the app's
12
+ * protocol forwarder is not seekable on first load (see {@link mediaSourceFor}).
13
+ * Images and PDF frames behave as in a browser tab — the window keeps
14
+ * Chromium's PDF viewer — so nothing else branches on this.
15
+ * @module @crosery/dsh-viewer/client/host
16
+ */
17
+ /** The globals {@link isDesktopShell} probes. */
18
+ interface HostGlobals {
19
+ __DSH_HOST_PATHS__?: unknown;
20
+ location?: {
21
+ protocol?: string;
22
+ };
23
+ }
24
+ /**
25
+ * Whether this page is the desktop app's window.
26
+ * @param globals - the global object to probe; the real one by default.
27
+ * @returns true inside the desktop renderer.
28
+ */
29
+ export declare function isDesktopShell(globals?: HostGlobals): boolean;
30
+ /** The desktop renderer's transport facts (`__DSH_TRANSPORT__`), as far as media needs them. */
31
+ interface TransportGlobals extends HostGlobals {
32
+ __DSH_TRANSPORT__?: {
33
+ streamBaseUrl?: unknown;
34
+ } | undefined;
35
+ }
36
+ /**
37
+ * The URL a `<video>` or `<audio>` element should load an asset from.
38
+ *
39
+ * On the web this is the signed same-origin path unchanged. In the desktop
40
+ * window a same-origin path travels through the app's `dsh-app:` protocol
41
+ * forwarder, which drops `Content-Length`; Chromium then treats the FIRST load
42
+ * of a media URL as an unseekable stream (`seekable` is `[0, 0]`, measured on
43
+ * 0.1.7-rc.2) even though every response is a correct 206 with
44
+ * `Content-Range`. The desktop publishes the Host's own loopback address as
45
+ * `__DSH_TRANSPORT__.streamBaseUrl` — its WebSocket base — and a media element
46
+ * pointed there issues ordinary HTTP range requests and seeks from the start.
47
+ * The asset route needs no cookie (it authorizes by signature), so nothing but
48
+ * the origin changes. Anything other than an http(s) loopback base is ignored.
49
+ * @param assetUrl - the signed same-origin asset path.
50
+ * @param globals - the global object to probe; the real one by default.
51
+ * @returns the URL to hand the media element.
52
+ */
53
+ export declare function mediaSourceFor(assetUrl: string, globals?: TransportGlobals): string;
54
+ export {};
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Browser half of the viewer plugin.
3
+ *
4
+ * Registers one card into the tool-view slot for two keys: the plugin's own
5
+ * `display_file`, and the shipped `read_image`. The two registrations differ in
6
+ * one way that matters. Up to 0.1.2 upstream renders `read_image` as a plain
7
+ * text row, so the plugin's card is the only picture of an image the model
8
+ * pulled into context. From 0.1.3 upstream ships its own `read_image` view at
9
+ * the default priority, and a second entry for the same key AT THE SAME
10
+ * PRIORITY is a hard error in the slot core — which is how v0.1.1 took its own
11
+ * `display_file` card down with it on 0.1.7. So `read_image` is registered one
12
+ * step behind the default: the core renders the lowest priority, which is
13
+ * upstream's view wherever one exists and this card everywhere else.
14
+ *
15
+ * Every registration is isolated from the others. A registration that throws
16
+ * inside `slots.inject` while its slot is already declared rethrows into this
17
+ * plugin's `apply`, and cordis answers a failed apply by unloading the whole
18
+ * client half — so one bad key must never be able to take the rest with it.
19
+ *
20
+ * From 0.1.6 a third registration keeps displayed files visible after their
21
+ * turn completes and folds; see `turn-tail.ts`.
22
+ *
23
+ * The card's only Host dependencies are durable image bytes — through the
24
+ * chat's own loader from 0.1.7, through `ctx.sessions` before — and everything
25
+ * else (video, audio, PDF, HTML) arrives over the Host's signed asset route as an
26
+ * ordinary same-origin URL.
27
+ */
28
+ import type { Context as ClientContext } from '@deepseek-ai/cordis';
29
+ import { type ViewerKey } from './locales.ts';
30
+ export type { CardState } from './card-model.ts';
31
+ export { cardModel, argumentPathOf, contentImageOf } from './card-model.ts';
32
+ export type { ViewerCardInjected, ViewerCardOwner } from './ViewerCard.tsx';
33
+ export { imageLoaderFor, type OwnerImageLoader, type ViewerSources } from './sources.ts';
34
+ export type { ViewerTailInjected } from './ViewerTail.tsx';
35
+ export type { ViewerKey } from './locales.ts';
36
+ export { isDesktopShell, mediaSourceFor } from './host.ts';
37
+ export { READ_IMAGE_PRIORITY, TURN_TAIL_SLOT, VIEWER_NS, contribute, turnTailJoinable } from './registration.ts';
38
+ export { VIEWER_INPUT, VIEWER_NESTED, VIEWER_TURN_DATA, VIEWER_TURN_END, displayedValueOf, foldSourceOf, foldsCompletedTurns, hasInterleavedInput, humanInputDefinition, nestedDisplayDefinition, tailDisplays, turnEndDefinition, turnStaysOpen, viewerTurnDefinition, viewerTurnDefinitions, } from './turn-tail.ts';
39
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
40
+ interface LocaleNamespaceMap {
41
+ /** The viewer card's copy. */
42
+ 'tool.viewer': ViewerKey;
43
+ }
44
+ }
45
+ /**
46
+ * Required services: the registration surface and the copy, nothing else.
47
+ *
48
+ * `sessions` is read by name when an image actually needs its bytes, not
49
+ * required: from 0.1.7 the chat supplies its own image loader and the plugin's
50
+ * reader is only the fallback, and an entry that never activates is a hard
51
+ * boot failure rather than a graceful skip.
52
+ */
53
+ export declare const inject: string[];
54
+ export declare const name = "@crosery/dsh-viewer";
55
+ /**
56
+ * Client plugin body: own the URL cache and register the card under both keys.
57
+ * @param ctx - client cordis context.
58
+ */
59
+ export declare function apply(ctx: ClientContext): void;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Card copy. Two dictionaries with identical key sets — the locale service
3
+ * fails a namespace whose dictionaries disagree, so a key added here has to be
4
+ * added to both.
5
+ */
6
+ /** Dictionary key domain of this plugin's namespace. */
7
+ export type ViewerKey = 'title.image' | 'title.video' | 'title.audio' | 'title.pdf' | 'title.document' | 'title.html' | 'title.file' | 'title.readImage' | 'state.running' | 'state.loading' | 'state.loadFailed' | 'state.retry' | 'state.unavailable' | 'state.unsupported' | 'badge.inContext' | 'badge.screenOnly' | 'action.open' | 'action.openNew' | 'action.openSidebar' | 'action.close' | 'action.expand' | 'action.collapse' | 'media.noVideo' | 'media.noAudio' | 'tail.label';
8
+ /** Simplified Chinese copy. */
9
+ export declare const zh: Record<ViewerKey, string>;
10
+ /** English copy. */
11
+ export declare const en: Record<ViewerKey, string>;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * How the browser half joins the slot registry — the part of `index.ts` that
3
+ * carries no component, so it can be exercised against the real slot core in
4
+ * tests on every train.
5
+ * @module @crosery/dsh-viewer/client/registration
6
+ */
7
+ /** Namespace owning this card's copy. */
8
+ export declare const VIEWER_NS = "tool.viewer";
9
+ /**
10
+ * Priority of the `read_image` card: one behind the default, so a view the
11
+ * harness registers for the same key renders instead (the slot core renders
12
+ * the lowest priority per key, and refuses a second entry at an occupied one),
13
+ * and this one renders where there is none.
14
+ */
15
+ export declare const READ_IMAGE_PRIORITY = 1;
16
+ /** List slot the chat renders after a completed turn's closing reply (0.1.6+). */
17
+ export declare const TURN_TAIL_SLOT = "conversation.chat.turnTail";
18
+ /**
19
+ * The slot registry as this plugin drives it: by name, including slots whose
20
+ * declarations this build does not import (the turn tail is declared by
21
+ * `dsh-client-ui-chat`, which the oldest supported train does not publish).
22
+ */
23
+ export interface LooseSlots {
24
+ inject(key: string, callback: () => () => void): () => void;
25
+ register(options: Record<string, unknown>, component: unknown): () => void;
26
+ spec?(key: string): {
27
+ kind?: string;
28
+ } | undefined;
29
+ }
30
+ /**
31
+ * Register one slot entry so that its failure stays its own.
32
+ *
33
+ * `slots.inject` runs the callback synchronously when the slot is already
34
+ * declared and rethrows what it throws — into `apply`, where cordis unloads the
35
+ * whole plugin. The callback therefore never throws: a refused registration is
36
+ * reported and replaced by a no-op disposer, and every other entry lives on.
37
+ * @param slots - the slot registry.
38
+ * @param key - the slot to register into.
39
+ * @param register - performs the registration; returns its disposer.
40
+ * @param warn - where a refusal is reported.
41
+ */
42
+ export declare function contribute(slots: Pick<LooseSlots, 'inject'>, key: string, register: () => () => void, warn?: (message: string, error: unknown) => void): void;
43
+ /**
44
+ * Whether the turn tail may be joined.
45
+ *
46
+ * Up to 0.1.5 the slot is a CHAIN — one winner per turn — so an entry there
47
+ * would compete with the harness's own delivery cards instead of sitting beside
48
+ * them. Only the list form (0.1.6+) is joined; a registry that cannot say which
49
+ * form it has is trusted to be the one the Conversation registry came with.
50
+ * @param spec - the slot's declared spec, when the registry exposes it.
51
+ * @returns true for a list slot, or an unknown one.
52
+ */
53
+ export declare function turnTailJoinable(spec: {
54
+ kind?: string;
55
+ } | undefined): boolean;