@rhizomatics/signalk-einklabel-plugin 0.10.1 → 1.0.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/dist/index.js CHANGED
@@ -1,6 +1,10 @@
1
1
  "use strict";
2
2
  const plugin_1 = require("./plugin");
3
3
  const registry_1 = require("./devices/registry");
4
+ const templateProviders_1 = require("./render/templateProviders");
5
+ const svgRenderer_1 = require("./render/svgRenderer");
6
+ const png_1 = require("./render/png");
7
+ const binding_1 = require("./render/binding");
4
8
  /**
5
9
  * Public extension point for vendor packages. A package that adds support for a new
6
10
  * ESL vendor (e.g. `signalk-esl-shoplabelcorp-plugin`) imports this module and calls
@@ -8,9 +12,11 @@ const registry_1 = require("./devices/registry");
8
12
  * `start()` (or at module load time). There's no scanning of installed packages -
9
13
  * registration is always an explicit call by the extension's own code.
10
14
  *
11
- * Declare this package as a `peerDependency` (not a regular dependency) in the
12
- * extension package, so npm resolves a single shared copy - otherwise the extension
13
- * would register into a different registry instance than the one this plugin reads from.
15
+ * Declare this package as a regular `dependency` in the extension package (**not** a
16
+ * `peerDependency` - the SignalK team's own guidance is that npm's peer-dependency resolution
17
+ * interacts poorly with the server's plugin install layout), and declare the SignalK-level
18
+ * relationship via `"signalk": { "requires": ["@rhizomatics/signalk-einklabel-plugin"] }` in the
19
+ * extension's own package.json instead, so the App Store can install/report the dependency.
14
20
  */
15
21
  function plugin(app) {
16
22
  return (0, plugin_1.createPlugin)(app);
@@ -19,5 +25,24 @@ function plugin(app) {
19
25
  plugin.registerVendorDriver = registry_1.registerDriver;
20
26
  plugin.getVendorDriver = registry_1.getDriver;
21
27
  plugin.allVendorDrivers = registry_1.allDrivers;
28
+ /**
29
+ * Public extension point for a package offering an alternative to hand-authored SVG templates - e.g.
30
+ * `@rhizomatics/signalk-einklabel-genai-plugin` generating content from an LLM prompt - see
31
+ * `TemplateProvider`'s own doc comment (`./render/templateProviders.ts`) for the full contract. Same
32
+ * regular-`dependency`-plus-`signalk.requires` convention as `registerVendorDriver` above.
33
+ */
34
+ plugin.registerTemplateProvider = templateProviders_1.registerTemplateProvider;
35
+ plugin.getTemplateProvider = templateProviders_1.findTemplateProvider;
36
+ plugin.allTemplateProviders = templateProviders_1.allTemplateProviders;
37
+ /** Rasterizes an SVG string to a `Bitmap` - a template provider needs this to turn whatever SVG it produces (e.g. an LLM's response) into paintable pixels, exactly as a bundled template is rendered. */
38
+ plugin.Renderer = svgRenderer_1.SvgRenderer;
39
+ /** Encodes a `Bitmap` as PNG bytes - useful for a CLI extension writing a preview file, same as the core plugin's own `render`/`generate` commands. */
40
+ plugin.bitmapToPng = png_1.bitmapToPng;
41
+ /** Facts about one physical label (`source=label,path=...` bindings resolve against these) - see `./render/binding.ts`. */
42
+ plugin.buildLabel = binding_1.buildLabelContext;
43
+ /** Every `{...}` placeholder referenced across one or more text fragments, parsed as bindings - see `./render/binding.ts`. */
44
+ plugin.findBindingsInText = binding_1.findTextBindings;
45
+ /** Substitutes every `{...}` placeholder in `text` with its resolved binding value - see `./render/binding.ts`. */
46
+ plugin.substituteBindingsInText = binding_1.substituteTextBindings;
22
47
  })(plugin || (plugin = {}));
23
48
  module.exports = plugin;
@@ -1,5 +1,6 @@
1
+ import { Colour } from "../devices/types";
1
2
  import { TemplateContext } from "./types";
2
- declare const SOURCES: readonly ["signalk", "resources", "einklabel"];
3
+ declare const SOURCES: readonly ["signalk", "resources", "einklabel", "label"];
3
4
  type Source = (typeof SOURCES)[number];
4
5
  /**
5
6
  * Parsed form of a `<desc>`'s `key=value,key=value` content - see `parseBinding` for the grammar.
@@ -19,7 +20,12 @@ export interface Binding {
19
20
  * for). Set this to pin a template to one provider regardless of what else is installed.
20
21
  */
21
22
  provider?: string;
22
- /** For `source === 'einklabel'`, a dotted path into the plugin's own injected `meta` (e.g. `repainted`), rather than into vessel/resource data. */
23
+ /**
24
+ * For `source === 'einklabel'`, a dotted path into the plugin's own injected `meta` (e.g. `repainted`);
25
+ * for `source === 'label'`, a dotted path into the physical label's own facts (`manufacturer`, `label`,
26
+ * `width`, `height`, `colours`, `fonts`, `description`, `position` - see `buildLabelContext` below)
27
+ * rather than into vessel/resource data.
28
+ */
23
29
  path: string;
24
30
  /** A named formatter (see `./formatters.ts`), or `'raw'` to suppress automatic unit conversion (see `renderBinding`). */
25
31
  format?: string;
@@ -35,6 +41,14 @@ export interface Binding {
35
41
  * template doesn't also require duplicating its bundled asset sets.
36
42
  */
37
43
  assets?: string;
44
+ /**
45
+ * Substituted in place of the resolved value when that value is missing (`undefined`/`null`,
46
+ * e.g. an unpublished SignalK path) - see `renderBinding`. Distinct from an *unset* default (this
47
+ * field itself being `undefined`), which falls through to the pre-existing "" fallback - explicitly
48
+ * writing `default=` (an empty value) still counts as "given", so a binding can deliberately default
49
+ * to blank rather than to `substituteTextBindings`' own "???" below.
50
+ */
51
+ default?: string;
38
52
  }
39
53
  /**
40
54
  * Parses a `<desc>` element's text content into a `Binding`, e.g.
@@ -69,6 +83,8 @@ export declare function resolveBinding(binding: Binding, context: TemplateContex
69
83
  * the CLI's `field`/`fields` commands show the same thing a real render would.
70
84
  *
71
85
  * Precedence for a numeric value:
86
+ * 0. A missing value (`undefined`/`null`, e.g. an unpublished path) with an explicit `default=` given -
87
+ * that default, verbatim, bypassing every step below (there's nothing to format).
72
88
  * 1. An explicit named `format=` (anything other than `raw`) - `local_time`/`utc_offset`/`position`.
73
89
  * 2. An explicit `category=` - for values with no path metadata of their own, e.g. a `source=resources`
74
90
  * value.
@@ -76,7 +92,65 @@ export declare function resolveBinding(binding: Binding, context: TemplateContex
76
92
  * `context.pathMeta`) by default - `format=raw` opts out of this step only.
77
93
  * 4. Falls through to `round=` (`toFixed`), `JSON.stringify` for an unformatted object/array value
78
94
  * (e.g. a path that resolved to a whole sub-tree rather than a leaf) instead of the useless
79
- * `String(value)` -> `"[object Object]"`, else `String`.
95
+ * `String(value)` -> `"[object Object]"`, else `String`. A missing value with no `default=` given
96
+ * still falls through to the pre-existing "" here, unchanged from before `default=` existed.
80
97
  */
81
98
  export declare function renderBinding(binding: Binding, context: TemplateContext): string;
99
+ /** Facts about one physical label a `source=label,path=...` binding can reference - see `buildLabelContext`. */
100
+ export interface LabelMeta {
101
+ manufacturer: string;
102
+ /** The physical panel's own size label, e.g. `'3.7"'` - `DeviceMetadata.label` verbatim, see `../devices/types.ts`. */
103
+ label: string;
104
+ width: number;
105
+ height: number;
106
+ colours: Colour[];
107
+ description?: string;
108
+ position?: {
109
+ latitude: number;
110
+ longitude: number;
111
+ };
112
+ }
113
+ /**
114
+ * Builds the `context.label` object a `source=label,path=...` binding addresses (e.g.
115
+ * `{source=label,path=width}` in free text, or a `<desc>source=label,path=width</desc>` in an SVG
116
+ * template - every `{...}`/`<desc>` placeholder is a real binding, so a `label` path always needs the
117
+ * explicit `source=label,path=` form to disambiguate it from a `signalk` self path). `colours`/`fonts`
118
+ * are left as arrays (each colour entry pre-annotated with its hex code, e.g. `"black (#000000)"`)
119
+ * rather than joined into a single string, so a caller can either use them bare (renders as JSON) or add
120
+ * `format=csv` (see `./formatters.ts`) for a plain comma-separated list. `position`, if given, is rounded
121
+ * to ~2 decimal places (~1.1km) - fine-grained enough to be meaningfully "for here", coarse enough that
122
+ * ordinary GPS jitter at anchor doesn't change it tick to tick, which matters because `considerRepaint`
123
+ * (`../repaintScheduler.ts`) folds this whole object into every device's dedup hash - full-precision
124
+ * jitter here would otherwise force a repaint (and, for a provider-rendered template, a fresh paid API
125
+ * call) far more often than the underlying position has actually meaningfully changed.
126
+ */
127
+ export declare function buildLabelContext(meta: LabelMeta): Record<string, unknown>;
128
+ /**
129
+ * Every binding referenced across one or more free-text fragments - every `{...}` placeholder,
130
+ * deduplicated across all of `texts` combined, parsed exactly the way a template's `<desc>` binding is
131
+ * (`parseBinding` above), so a bare path (`{design.length}`, `source=signalk,context=self` shorthand),
132
+ * the full binding grammar (`{source=signalk,path=navigation.position,format=position}`), and a
133
+ * `source=label,path=...` binding all work uniformly. Pass the result to `assembleRawContext`
134
+ * (`../repaintScheduler.ts`) exactly as a template's own bindings are - it fetches the `signalk`/
135
+ * `resources`-sourced ones and silently ignores `label`/`einklabel`-sourced ones, which resolve directly
136
+ * against `context.label`/`context.meta` instead (built by the caller, not fetched).
137
+ *
138
+ * A placeholder that isn't valid binding grammar (e.g. a typo like `{source=taheight}`) is silently
139
+ * skipped here rather than thrown - the same per-field isolation `SvgRenderer` gives a bad `<desc>`
140
+ * binding, so one malformed placeholder doesn't take down the whole text. `substituteTextBindings` hits
141
+ * the identical parse error at substitution time and turns it into "???" for just that field.
142
+ */
143
+ export declare function findTextBindings(...texts: string[]): Binding[];
144
+ /**
145
+ * Substitutes every `{...}` placeholder in `text` with its resolved binding value against `context`
146
+ * (built by `assembleRawContext` plus `context.label` from `buildLabelContext` - see
147
+ * `findTextBindings`). Mirrors `renderBinding`'s per-field isolation, but substitutes "???" for anything
148
+ * that resolves to no value at all (missing path, invalid binding grammar) rather than "" - prose with a
149
+ * silently-blank word reads as a fact ("for the sailor of a m vessel"), not as a gap the reader would
150
+ * notice. Two things override that "???": a binding that resolves successfully to a legitimately empty
151
+ * string (e.g. an unset `{source=label,path=description}`) is left as empty, since that's a real answer,
152
+ * not a miss; and a binding with an explicit `default=` uses that default instead, since the caller has
153
+ * already said what a missing value should read as.
154
+ */
155
+ export declare function substituteTextBindings(text: string, context: TemplateContext): string;
82
156
  export {};
@@ -5,10 +5,13 @@ exports.resourceContextKey = resourceContextKey;
5
5
  exports.findBindings = findBindings;
6
6
  exports.resolveBinding = resolveBinding;
7
7
  exports.renderBinding = renderBinding;
8
+ exports.buildLabelContext = buildLabelContext;
9
+ exports.findTextBindings = findTextBindings;
10
+ exports.substituteTextBindings = substituteTextBindings;
8
11
  const xmldom_1 = require("@xmldom/xmldom");
9
12
  const formatters_1 = require("./formatters");
10
- const SOURCES = ["signalk", "resources", "einklabel"];
11
- const KNOWN_KEYS = new Set(["source", "context", "resource", "provider", "path", "format", "category", "round", "assets"]);
13
+ const SOURCES = ["signalk", "resources", "einklabel", "label"];
14
+ const KNOWN_KEYS = new Set(["source", "context", "resource", "provider", "path", "format", "category", "round", "assets", "default"]);
12
15
  /**
13
16
  * Parses a `<desc>` element's text content into a `Binding`, e.g.
14
17
  * `source=resources,resource=tides,path=extremes[0].level,category=depth,round=2` or, using the
@@ -62,6 +65,7 @@ function parseBinding(desc) {
62
65
  category: fields.category,
63
66
  round: fields.round !== undefined ? Number(fields.round) : undefined,
64
67
  assets: fields.assets,
68
+ default: fields.default,
65
69
  };
66
70
  }
67
71
  /**
@@ -126,6 +130,13 @@ function resolveBinding(binding, context) {
126
130
  }
127
131
  return getAtPath(meta, binding.path);
128
132
  }
133
+ if (binding.source === "label") {
134
+ const label = context.label;
135
+ if (label === undefined) {
136
+ throw new Error('binding references source "label" but no "label" is present in the render context');
137
+ }
138
+ return getAtPath(label, binding.path);
139
+ }
129
140
  const resources = context.resources;
130
141
  const resourceKey = resourceContextKey(binding);
131
142
  const resource = resources?.[resourceKey];
@@ -166,6 +177,8 @@ function resolveCategoryDisplayUnits(binding, context) {
166
177
  * the CLI's `field`/`fields` commands show the same thing a real render would.
167
178
  *
168
179
  * Precedence for a numeric value:
180
+ * 0. A missing value (`undefined`/`null`, e.g. an unpublished path) with an explicit `default=` given -
181
+ * that default, verbatim, bypassing every step below (there's nothing to format).
169
182
  * 1. An explicit named `format=` (anything other than `raw`) - `local_time`/`utc_offset`/`position`.
170
183
  * 2. An explicit `category=` - for values with no path metadata of their own, e.g. a `source=resources`
171
184
  * value.
@@ -173,10 +186,13 @@ function resolveCategoryDisplayUnits(binding, context) {
173
186
  * `context.pathMeta`) by default - `format=raw` opts out of this step only.
174
187
  * 4. Falls through to `round=` (`toFixed`), `JSON.stringify` for an unformatted object/array value
175
188
  * (e.g. a path that resolved to a whole sub-tree rather than a leaf) instead of the useless
176
- * `String(value)` -> `"[object Object]"`, else `String`.
189
+ * `String(value)` -> `"[object Object]"`, else `String`. A missing value with no `default=` given
190
+ * still falls through to the pre-existing "" here, unchanged from before `default=` existed.
177
191
  */
178
192
  function renderBinding(binding, context) {
179
193
  const value = resolveBinding(binding, context);
194
+ if ((value === null || value === undefined) && binding.default !== undefined)
195
+ return binding.default;
180
196
  if (binding.format && binding.format !== "raw")
181
197
  return (0, formatters_1.applyFormat)(binding.format, value, context, binding.round);
182
198
  if (typeof value === "number") {
@@ -194,3 +210,98 @@ function renderBinding(binding, context) {
194
210
  return JSON.stringify(value);
195
211
  return String(value);
196
212
  }
213
+ const COLOUR_HEX = { black: "#000000", white: "#FFFFFF", red: "#FF0000", yellow: "#FFFF00" };
214
+ /** The only `font-family` values `SvgRenderer` is guaranteed to render - see `expandGenericFontFamilies` in `./svgRenderer.ts`. */
215
+ const SAFE_FONT_FAMILIES = ["serif", "sans-serif", "monospace"];
216
+ /**
217
+ * Builds the `context.label` object a `source=label,path=...` binding addresses (e.g.
218
+ * `{source=label,path=width}` in free text, or a `<desc>source=label,path=width</desc>` in an SVG
219
+ * template - every `{...}`/`<desc>` placeholder is a real binding, so a `label` path always needs the
220
+ * explicit `source=label,path=` form to disambiguate it from a `signalk` self path). `colours`/`fonts`
221
+ * are left as arrays (each colour entry pre-annotated with its hex code, e.g. `"black (#000000)"`)
222
+ * rather than joined into a single string, so a caller can either use them bare (renders as JSON) or add
223
+ * `format=csv` (see `./formatters.ts`) for a plain comma-separated list. `position`, if given, is rounded
224
+ * to ~2 decimal places (~1.1km) - fine-grained enough to be meaningfully "for here", coarse enough that
225
+ * ordinary GPS jitter at anchor doesn't change it tick to tick, which matters because `considerRepaint`
226
+ * (`../repaintScheduler.ts`) folds this whole object into every device's dedup hash - full-precision
227
+ * jitter here would otherwise force a repaint (and, for a provider-rendered template, a fresh paid API
228
+ * call) far more often than the underlying position has actually meaningfully changed.
229
+ */
230
+ function buildLabelContext(meta) {
231
+ const position = meta.position && {
232
+ latitude: Math.round(meta.position.latitude * 100) / 100,
233
+ longitude: Math.round(meta.position.longitude * 100) / 100,
234
+ };
235
+ return {
236
+ manufacturer: meta.manufacturer,
237
+ label: meta.label,
238
+ width: meta.width,
239
+ height: meta.height,
240
+ colours: meta.colours.map((colour) => `${colour} (${COLOUR_HEX[colour]})`),
241
+ fonts: SAFE_FONT_FAMILIES,
242
+ description: meta.description ?? "",
243
+ position: position ? (0, formatters_1.applyFormat)("position", position, {}, 2) : undefined,
244
+ };
245
+ }
246
+ function placeholderContents(text) {
247
+ return [...new Set([...text.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1].trim()))];
248
+ }
249
+ /**
250
+ * Every binding referenced across one or more free-text fragments - every `{...}` placeholder,
251
+ * deduplicated across all of `texts` combined, parsed exactly the way a template's `<desc>` binding is
252
+ * (`parseBinding` above), so a bare path (`{design.length}`, `source=signalk,context=self` shorthand),
253
+ * the full binding grammar (`{source=signalk,path=navigation.position,format=position}`), and a
254
+ * `source=label,path=...` binding all work uniformly. Pass the result to `assembleRawContext`
255
+ * (`../repaintScheduler.ts`) exactly as a template's own bindings are - it fetches the `signalk`/
256
+ * `resources`-sourced ones and silently ignores `label`/`einklabel`-sourced ones, which resolve directly
257
+ * against `context.label`/`context.meta` instead (built by the caller, not fetched).
258
+ *
259
+ * A placeholder that isn't valid binding grammar (e.g. a typo like `{source=taheight}`) is silently
260
+ * skipped here rather than thrown - the same per-field isolation `SvgRenderer` gives a bad `<desc>`
261
+ * binding, so one malformed placeholder doesn't take down the whole text. `substituteTextBindings` hits
262
+ * the identical parse error at substitution time and turns it into "???" for just that field.
263
+ */
264
+ function findTextBindings(...texts) {
265
+ const seen = new Set();
266
+ const bindings = [];
267
+ for (const text of texts) {
268
+ for (const key of placeholderContents(text)) {
269
+ if (seen.has(key))
270
+ continue;
271
+ seen.add(key);
272
+ try {
273
+ bindings.push(parseBinding(key));
274
+ }
275
+ catch {
276
+ // see doc comment above - left for `substituteTextBindings` to turn into "???"
277
+ }
278
+ }
279
+ }
280
+ return bindings;
281
+ }
282
+ /**
283
+ * Substitutes every `{...}` placeholder in `text` with its resolved binding value against `context`
284
+ * (built by `assembleRawContext` plus `context.label` from `buildLabelContext` - see
285
+ * `findTextBindings`). Mirrors `renderBinding`'s per-field isolation, but substitutes "???" for anything
286
+ * that resolves to no value at all (missing path, invalid binding grammar) rather than "" - prose with a
287
+ * silently-blank word reads as a fact ("for the sailor of a m vessel"), not as a gap the reader would
288
+ * notice. Two things override that "???": a binding that resolves successfully to a legitimately empty
289
+ * string (e.g. an unset `{source=label,path=description}`) is left as empty, since that's a real answer,
290
+ * not a miss; and a binding with an explicit `default=` uses that default instead, since the caller has
291
+ * already said what a missing value should read as.
292
+ */
293
+ function substituteTextBindings(text, context) {
294
+ return text.replace(/\{([^{}]+)\}/g, (_match, raw) => {
295
+ const key = raw.trim();
296
+ try {
297
+ const binding = parseBinding(key);
298
+ const value = resolveBinding(binding, context);
299
+ if ((value === undefined || value === null) && binding.default === undefined)
300
+ return "???";
301
+ return renderBinding(binding, context);
302
+ }
303
+ catch {
304
+ return "???";
305
+ }
306
+ });
307
+ }
@@ -101,6 +101,22 @@ function formatPosition(value, round) {
101
101
  const lonHemisphere = position.longitude >= 0 ? "E" : "W";
102
102
  return `${lat}°${latHemisphere} ${lon}°${lonHemisphere}`;
103
103
  }
104
+ /**
105
+ * A resolved value that's an array (e.g. `source=label,path=colours` - see `../render/llmPrompt.ts`'s
106
+ * `buildLabelContext`) joined into a plain comma-separated list, e.g. `["black","white"]` ->
107
+ * `"black, white"`. A non-array value falls through to the same null/object/scalar handling
108
+ * `renderBinding` uses for its own generic fallback, so `format=csv` is harmless on an ordinary
109
+ * scalar binding too.
110
+ */
111
+ function formatCsv(value) {
112
+ if (Array.isArray(value))
113
+ return value.map((entry) => String(entry)).join(", ");
114
+ if (value === null || value === undefined)
115
+ return "";
116
+ if (typeof value === "object")
117
+ return JSON.stringify(value);
118
+ return String(value);
119
+ }
104
120
  /** Applies a named `format=` formatter to a resolved binding value. */
105
121
  function applyFormat(name, value, context, round) {
106
122
  switch (name) {
@@ -114,6 +130,8 @@ function applyFormat(name, value, context, round) {
114
130
  return formatUtcOffset(value);
115
131
  case "position":
116
132
  return formatPosition(value, round);
133
+ case "csv":
134
+ return formatCsv(value);
117
135
  default:
118
136
  throw new Error(`unknown format "${name}"`);
119
137
  }
@@ -0,0 +1,86 @@
1
+ import { PluginConfig } from "../config";
2
+ import { Colour } from "../devices/types";
3
+ import { Binding } from "./binding";
4
+ import { TemplateContext } from "./types";
5
+ /** Facts about one physical label a prompt's `source=label,path=...` placeholders can reference - see `buildLabelContext`. */
6
+ export interface LabelMeta {
7
+ manufacturer: string;
8
+ /** The physical panel's own size label, e.g. `'3.7"'` - `DeviceMetadata.label` verbatim, see `../devices/types.ts`. */
9
+ label: string;
10
+ width: number;
11
+ height: number;
12
+ colours: Colour[];
13
+ description?: string;
14
+ position?: {
15
+ latitude: number;
16
+ longitude: number;
17
+ };
18
+ }
19
+ /**
20
+ * Builds the `context.label` object a prompt/target-guidance fragment addresses via
21
+ * `source=label,path=...` bindings (e.g. `{source=label,path=width}`, or the bare-path shorthand
22
+ * `{width}` is *not* supported here deliberately - see `./binding.ts`'s `parseBinding`, every `{...}`
23
+ * placeholder is a real binding, so a `label` path always needs the explicit `source=label,path=`
24
+ * form to disambiguate it from a `signalk` self path). `colours`/`fonts` are left as arrays (each
25
+ * colour entry pre-annotated with its hex code, e.g. `"black (#000000)"`) rather than joined into a
26
+ * single string, so a prompt author can either use them bare (renders as JSON, e.g. for a model that
27
+ * parses structured hints) or add `format=csv` (see `./formatters.ts`) for a plain comma-separated list.
28
+ */
29
+ export declare function buildLabelContext(meta: LabelMeta): Record<string, unknown>;
30
+ /**
31
+ * Every binding referenced across one or more prompt fragments - every `{...}` placeholder, deduplicated
32
+ * across all of `texts` combined, parsed exactly the way a template's `<desc>` binding is
33
+ * (`parseBinding`, see `./binding.ts`), so a bare path (`{design.length}`, `source=signalk,context=self`
34
+ * shorthand), the full binding grammar (`{source=signalk,path=navigation.position,format=position}`),
35
+ * and a `source=label,path=...` binding all work uniformly. Pass the result to `assembleRawContext`
36
+ * (repaintScheduler.ts) exactly as a template's own bindings are - it fetches the `signalk`/
37
+ * `resources`-sourced ones and silently ignores `label`/`einklabel`-sourced ones, which resolve directly
38
+ * against `context.label`/`context.meta` instead (built by the caller, not fetched).
39
+ *
40
+ * A placeholder that isn't valid binding grammar (e.g. a typo like `{source=taheight}`) is silently
41
+ * skipped here rather than thrown - the same per-field isolation `SvgRenderer` gives a bad `<desc>`
42
+ * binding, so one malformed placeholder doesn't take down the whole prompt. `substitutePlaceholders`
43
+ * hits the identical parse error at substitution time and turns it into "???" for just that field.
44
+ */
45
+ export declare function findPromptBindings(...texts: string[]): Binding[];
46
+ /**
47
+ * Substitutes every `{...}` placeholder in `text` with its resolved binding value against `context`
48
+ * (built by `assembleRawContext` plus `context.label` from `buildLabelContext` - see
49
+ * `findPromptBindings`). Mirrors `renderBinding`'s per-field isolation, but substitutes "???" for
50
+ * anything that resolves to no value at all (missing path, invalid binding grammar) rather than "" -
51
+ * prose with a silently-blank word reads as a fact ("for the sailor of a m vessel"), not as a gap the
52
+ * reader would notice. Two things override that "???": a binding that resolves successfully to a
53
+ * legitimately empty string (e.g. an unset `{source=label,path=description}`) is left as empty, since
54
+ * that's a real answer, not a miss; and a binding with an explicit `default=` (see `./binding.ts`) uses
55
+ * that default instead, since the prompt author has already said what a missing value should read as.
56
+ */
57
+ export declare function substitutePlaceholders(text: string, context: TemplateContext): string;
58
+ /**
59
+ * Strips everything outside the first `<svg`...last `</svg>` span - a chat model asked for "only raw
60
+ * SVG markup" still often wraps it in a markdown code fence or adds a sentence of commentary either
61
+ * side, despite the target-guidance fragment telling it not to.
62
+ */
63
+ export declare function extractSvg(raw: string): string;
64
+ export type LlmSettings = Pick<PluginConfig, "llmProvider" | "llmApiKey" | "llmModel" | "llmBaseUrl"> & {
65
+ llmTimeoutSeconds?: number;
66
+ };
67
+ /**
68
+ * `ai` and every `@ai-sdk/*` provider package are `optionalDependencies` (see package.json), not plain
69
+ * `dependencies` - most installs (every device using `renderMode: "svg-template"` only) never touch an
70
+ * LLM at all, so they shouldn't have to pull in this whole gateway stack, and an install that fails to
71
+ * fetch one of these (network hiccup, `npm install --omit=optional`, an unsupported platform) must not
72
+ * break BLE painting/templates, which have nothing to do with it. That means every reference to one of
73
+ * these packages has to be a `require()` reached only when a `renderMode: "llm-prompt"` device actually
74
+ * calls `callLlm` - a top-level `import` would be resolved eagerly the moment this module loads (which
75
+ * is every plugin start, via `repaintScheduler.ts`), throwing before any config is even read. Types are
76
+ * still fully checked via `typeof import(...)` below, which - unlike a value `import` - is erased
77
+ * entirely at compile time and leaves no runtime trace for `tsc` to eagerly require.
78
+ */
79
+ export declare function loadOptional<M>(moduleName: string): M;
80
+ /**
81
+ * Calls the configured LLM gateway with `prompt`, returning its raw text response - not yet extracted/
82
+ * validated as SVG, see `extractSvg`. This is the first network call in the codebase that needs its own
83
+ * timeout (`fetchJson` in `../httpJson.ts` has none - every existing call is to the local SignalK
84
+ * server's own REST API).
85
+ */
86
+ export declare function callLlm(settings: LlmSettings, prompt: string): Promise<string>;
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildLabelContext = buildLabelContext;
4
+ exports.findPromptBindings = findPromptBindings;
5
+ exports.substitutePlaceholders = substitutePlaceholders;
6
+ exports.extractSvg = extractSvg;
7
+ exports.loadOptional = loadOptional;
8
+ exports.callLlm = callLlm;
9
+ const binding_1 = require("./binding");
10
+ const formatters_1 = require("./formatters");
11
+ const COLOUR_HEX = { black: "#000000", white: "#FFFFFF", red: "#FF0000", yellow: "#FFFF00" };
12
+ /** The only `font-family` values `SvgRenderer` is guaranteed to render - see `expandGenericFontFamilies` in `./svgRenderer.ts`. */
13
+ const SAFE_FONT_FAMILIES = ["serif", "sans-serif", "monospace"];
14
+ /**
15
+ * Builds the `context.label` object a prompt/target-guidance fragment addresses via
16
+ * `source=label,path=...` bindings (e.g. `{source=label,path=width}`, or the bare-path shorthand
17
+ * `{width}` is *not* supported here deliberately - see `./binding.ts`'s `parseBinding`, every `{...}`
18
+ * placeholder is a real binding, so a `label` path always needs the explicit `source=label,path=`
19
+ * form to disambiguate it from a `signalk` self path). `colours`/`fonts` are left as arrays (each
20
+ * colour entry pre-annotated with its hex code, e.g. `"black (#000000)"`) rather than joined into a
21
+ * single string, so a prompt author can either use them bare (renders as JSON, e.g. for a model that
22
+ * parses structured hints) or add `format=csv` (see `./formatters.ts`) for a plain comma-separated list.
23
+ */
24
+ function buildLabelContext(meta) {
25
+ return {
26
+ manufacturer: meta.manufacturer,
27
+ label: meta.label,
28
+ width: meta.width,
29
+ height: meta.height,
30
+ colours: meta.colours.map((colour) => `${colour} (${COLOUR_HEX[colour]})`),
31
+ fonts: SAFE_FONT_FAMILIES,
32
+ description: meta.description ?? "",
33
+ position: meta.position ? (0, formatters_1.applyFormat)("position", meta.position, {}, 3) : undefined,
34
+ };
35
+ }
36
+ /**
37
+ * Every binding referenced across one or more prompt fragments - every `{...}` placeholder, deduplicated
38
+ * across all of `texts` combined, parsed exactly the way a template's `<desc>` binding is
39
+ * (`parseBinding`, see `./binding.ts`), so a bare path (`{design.length}`, `source=signalk,context=self`
40
+ * shorthand), the full binding grammar (`{source=signalk,path=navigation.position,format=position}`),
41
+ * and a `source=label,path=...` binding all work uniformly. Pass the result to `assembleRawContext`
42
+ * (repaintScheduler.ts) exactly as a template's own bindings are - it fetches the `signalk`/
43
+ * `resources`-sourced ones and silently ignores `label`/`einklabel`-sourced ones, which resolve directly
44
+ * against `context.label`/`context.meta` instead (built by the caller, not fetched).
45
+ *
46
+ * A placeholder that isn't valid binding grammar (e.g. a typo like `{source=taheight}`) is silently
47
+ * skipped here rather than thrown - the same per-field isolation `SvgRenderer` gives a bad `<desc>`
48
+ * binding, so one malformed placeholder doesn't take down the whole prompt. `substitutePlaceholders`
49
+ * hits the identical parse error at substitution time and turns it into "???" for just that field.
50
+ */
51
+ function findPromptBindings(...texts) {
52
+ const seen = new Set();
53
+ const bindings = [];
54
+ for (const text of texts) {
55
+ for (const match of text.matchAll(/\{([^{}]+)\}/g)) {
56
+ const key = match[1].trim();
57
+ if (seen.has(key))
58
+ continue;
59
+ seen.add(key);
60
+ try {
61
+ bindings.push((0, binding_1.parseBinding)(key));
62
+ }
63
+ catch {
64
+ // see doc comment above - left for `substitutePlaceholders` to turn into "???"
65
+ }
66
+ }
67
+ }
68
+ return bindings;
69
+ }
70
+ /**
71
+ * Substitutes every `{...}` placeholder in `text` with its resolved binding value against `context`
72
+ * (built by `assembleRawContext` plus `context.label` from `buildLabelContext` - see
73
+ * `findPromptBindings`). Mirrors `renderBinding`'s per-field isolation, but substitutes "???" for
74
+ * anything that resolves to no value at all (missing path, invalid binding grammar) rather than "" -
75
+ * prose with a silently-blank word reads as a fact ("for the sailor of a m vessel"), not as a gap the
76
+ * reader would notice. Two things override that "???": a binding that resolves successfully to a
77
+ * legitimately empty string (e.g. an unset `{source=label,path=description}`) is left as empty, since
78
+ * that's a real answer, not a miss; and a binding with an explicit `default=` (see `./binding.ts`) uses
79
+ * that default instead, since the prompt author has already said what a missing value should read as.
80
+ */
81
+ function substitutePlaceholders(text, context) {
82
+ return text.replace(/\{([^{}]+)\}/g, (_match, raw) => {
83
+ const key = raw.trim();
84
+ try {
85
+ const binding = (0, binding_1.parseBinding)(key);
86
+ const value = (0, binding_1.resolveBinding)(binding, context);
87
+ if ((value === undefined || value === null) && binding.default === undefined)
88
+ return "???";
89
+ return (0, binding_1.renderBinding)(binding, context);
90
+ }
91
+ catch {
92
+ return "???";
93
+ }
94
+ });
95
+ }
96
+ /**
97
+ * Strips everything outside the first `<svg`...last `</svg>` span - a chat model asked for "only raw
98
+ * SVG markup" still often wraps it in a markdown code fence or adds a sentence of commentary either
99
+ * side, despite the target-guidance fragment telling it not to.
100
+ */
101
+ function extractSvg(raw) {
102
+ const start = raw.indexOf("<svg");
103
+ const end = raw.lastIndexOf("</svg>");
104
+ if (start === -1 || end === -1 || end < start) {
105
+ throw new Error('LLM response did not contain a "<svg>...</svg>" document');
106
+ }
107
+ return raw.slice(start, end + "</svg>".length);
108
+ }
109
+ /**
110
+ * `ai` and every `@ai-sdk/*` provider package are `optionalDependencies` (see package.json), not plain
111
+ * `dependencies` - most installs (every device using `renderMode: "svg-template"` only) never touch an
112
+ * LLM at all, so they shouldn't have to pull in this whole gateway stack, and an install that fails to
113
+ * fetch one of these (network hiccup, `npm install --omit=optional`, an unsupported platform) must not
114
+ * break BLE painting/templates, which have nothing to do with it. That means every reference to one of
115
+ * these packages has to be a `require()` reached only when a `renderMode: "llm-prompt"` device actually
116
+ * calls `callLlm` - a top-level `import` would be resolved eagerly the moment this module loads (which
117
+ * is every plugin start, via `repaintScheduler.ts`), throwing before any config is even read. Types are
118
+ * still fully checked via `typeof import(...)` below, which - unlike a value `import` - is erased
119
+ * entirely at compile time and leaves no runtime trace for `tsc` to eagerly require.
120
+ */
121
+ function loadOptional(moduleName) {
122
+ try {
123
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
124
+ return require(moduleName);
125
+ }
126
+ catch (err) {
127
+ throw new Error(`LLM provider support needs the optional dependency "${moduleName}", which isn't installed - run "npm install ${moduleName}" (${err.message})`);
128
+ }
129
+ }
130
+ /** Ollama's own default local listen address - see the `"ollama"` case in `resolveModel` below. */
131
+ const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434/v1";
132
+ /**
133
+ * Resolves `settings` to a Vercel AI SDK model handle. `"ollama"` and `"local"` both go through the
134
+ * generic `@ai-sdk/openai-compatible` provider (Ollama/LM Studio/vLLM all expose an OpenAI-compatible
135
+ * endpoint, and none of them has - or needs - its own dedicated `@ai-sdk/*` package): `"ollama"`
136
+ * defaults `llmBaseUrl` to Ollama's own standard local address so it works with no further config
137
+ * (override it only if Ollama is running elsewhere, e.g. on the SignalK server's own host reached over
138
+ * the network); `"local"` is for anything else OpenAI-compatible, where there's no sensible universal
139
+ * default, so `llmBaseUrl` is required.
140
+ */
141
+ function resolveModel(settings) {
142
+ const model = settings.llmModel;
143
+ if (!model) {
144
+ throw new Error("no LLM model configured (PluginConfig.llmModel)");
145
+ }
146
+ switch (settings.llmProvider ?? "openai") {
147
+ case "anthropic": {
148
+ const { createAnthropic } = loadOptional("@ai-sdk/anthropic");
149
+ return createAnthropic({ apiKey: settings.llmApiKey })(model);
150
+ }
151
+ case "google": {
152
+ const { createGoogleGenerativeAI } = loadOptional("@ai-sdk/google");
153
+ return createGoogleGenerativeAI({ apiKey: settings.llmApiKey })(model);
154
+ }
155
+ case "xai": {
156
+ const { createXai } = loadOptional("@ai-sdk/xai");
157
+ return createXai({ apiKey: settings.llmApiKey })(model);
158
+ }
159
+ case "ollama": {
160
+ const { createOpenAICompatible } = loadOptional("@ai-sdk/openai-compatible");
161
+ return createOpenAICompatible({
162
+ name: "ollama",
163
+ baseURL: settings.llmBaseUrl || DEFAULT_OLLAMA_BASE_URL,
164
+ apiKey: settings.llmApiKey,
165
+ })(model);
166
+ }
167
+ case "local": {
168
+ if (!settings.llmBaseUrl) {
169
+ throw new Error('llmBaseUrl is required when llmProvider is "local"');
170
+ }
171
+ const { createOpenAICompatible } = loadOptional("@ai-sdk/openai-compatible");
172
+ return createOpenAICompatible({ name: "local", baseURL: settings.llmBaseUrl, apiKey: settings.llmApiKey })(model);
173
+ }
174
+ case "openai":
175
+ default: {
176
+ const { createOpenAI } = loadOptional("@ai-sdk/openai");
177
+ return createOpenAI({ apiKey: settings.llmApiKey })(model);
178
+ }
179
+ }
180
+ }
181
+ /**
182
+ * Calls the configured LLM gateway with `prompt`, returning its raw text response - not yet extracted/
183
+ * validated as SVG, see `extractSvg`. This is the first network call in the codebase that needs its own
184
+ * timeout (`fetchJson` in `../httpJson.ts` has none - every existing call is to the local SignalK
185
+ * server's own REST API).
186
+ */
187
+ async function callLlm(settings, prompt) {
188
+ const model = resolveModel(settings);
189
+ const { generateText } = loadOptional("ai");
190
+ const controller = new AbortController();
191
+ const timer = setTimeout(() => controller.abort(), (settings.llmTimeoutSeconds ?? 30) * 1000);
192
+ try {
193
+ const { text } = await generateText({ model, prompt, abortSignal: controller.signal });
194
+ return text;
195
+ }
196
+ finally {
197
+ clearTimeout(timer);
198
+ }
199
+ }