@rhizomatics/signalk-einklabel-genai-plugin 0.3.0 → 0.3.2

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/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ # [0.3.2]
2
+
3
+ - Fix: a disabled plugin no longer calls the LLM. Repaints of `(GenAI)` templates now fail fast with "not running" (core shows its fallback warning) instead of making paid LLM calls with the last config, or failing with a misleading "no LLM model configured" after a restart. Templates stay listed, so saved device choices are kept (#28)
4
+ - Fix: parallel renders of the same template to several labels no longer share one cached SVG file, which could paint one label with another's content at the wrong size (#28)
5
+ - `@rhizomatics/signalk-einklabel-plugin` is no longer an npm `dependency`, only `signalk.requires` (and a `devDependency` for types/tests) - the App Store installs it flat in `~/.signalk/node_modules`, so the plugin always registers with the one running copy rather than a possible nested duplicate
6
+ - Extension docs updated to match: provider packages should declare this plugin the same way
7
+
8
+ # [0.3.1]
9
+
10
+ - New `describeContent` API implemented to support debug use
11
+
1
12
  # [0.3.0]
2
13
 
3
14
  - Update timeout to 60 seconds to handle slower local or free models
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # eInk Label GenAI Templates
2
2
 
3
+ [![boat tech directory](https://boat-tech-directory.rhizomatics.org.uk/images/badge.svg)](https://boat-tech-directory.rhizomatics.org.uk) [![managed by hq](https://raw.githubusercontent.com/rhizomatics/hq/main/assets/badge-managed-by-hq.svg)](https://github.com/rhizomatics/hq)
4
+
3
5
  A companion plugin for [`@rhizomatics/signalk-einklabel-plugin`](https://github.com/rhizomatics/signalk-einklabel-plugin) that generates an image to paint on shelf label entirely from an LLM prompt, as an alternative to a hand-authored SVG template.
4
6
 
5
7
  By default it uses [OpenRouter](https://openrouter.ai), however it can use any of the [Vercel AI SDK providers](https://ai-sdk.dev/providers/ai-sdk-providers) directly, including local LLMs like Ollama.
@@ -10,19 +12,7 @@ By default it uses [OpenRouter](https://openrouter.ai), however it can use any o
10
12
 
11
13
  This plugin comes with a marine weather forecast example prompt. When installed, and SignalK server restarted, you should see this appear as a template to choose in the main eInk Label plugin.
12
14
 
13
- ### Testing on your server
14
-
15
- If you don't already have an AI account, get a [free API key from OpenRouter](https://openrouter.ai/openrouter/free) - set the _model_ to one of the [specific free models](https://openrouter.ai/openrouter/free), or `openrouter/free`, OpenRouter's free-tier routing alias, although note you will get a random model every time it is called, some of which are specialized (e.g. "content safety") and will not generate an image. Free models may not lookup web pages or APIs, so guess things like weather, and the image quality will likely be much poorer than using a mainstream LLM.
16
-
17
- Use the `esl-cli` CLI to see generated prompts or create test PNGs.
18
-
19
- ### Customizing Prompts
20
-
21
- Prompts have the same template syntax as the SVG images, so the weather forecast will give the current GPS position, and the length and type of boat.
22
-
23
- All prompts also get a generated appendix to the prompt that gives details about the eInk label, including manufacturer, name, pixel height and width, colour range, and the description provided in the main plugin.
24
-
25
- ### Full Example
15
+ ### Expanded Example
26
16
 
27
17
  #### Forecast
28
18
 
@@ -40,6 +30,18 @@ The following has been automatically added to the end of the standard prompt, wi
40
30
 
41
31
  Be careful with GenAI generated content, it will often be very good but may be prone to hallucinations or omissions.
42
32
 
33
+ ### Testing on your server
34
+
35
+ If you don't already have an AI account, get a [free API key from OpenRouter](https://openrouter.ai/openrouter/free) - set the _model_ to one of the [specific free models](https://openrouter.ai/openrouter/free), or `openrouter/free`, OpenRouter's free-tier routing alias, although note you will get a random model every time it is called, some of which are specialized (e.g. "content safety") and will not generate an image. Free models may not lookup web pages or APIs, so guess things like weather, and the image quality will likely be much poorer than using a mainstream LLM.
36
+
37
+ Use the `esl-cli` CLI to see generated prompts or create test PNGs.
38
+
39
+ ### Customizing Prompts
40
+
41
+ Prompts have the same template syntax as the SVG images, so the weather forecast will give the current GPS position, and the length and type of boat.
42
+
43
+ All prompts also get a generated appendix to the prompt that gives details about the eInk label, including manufacturer, name, pixel height and width, colour range, and the description provided in the main plugin.
44
+
43
45
  ## Installation
44
46
 
45
47
  Install both plugins from the SignalK AppStore (or `npm install @rhizomatics/signalk-einklabel-plugin @rhizomatics/signalk-einklabel-genai-plugin`), then install whichever LLM provider package(s) you'll actually use - these are `optionalDependencies`, not installed automatically, since most people will likely only ever need one. `openrouter` is the one exception: `@openrouter/ai-sdk-provider` is a regular `dependency`, so it's already installed and needs no extra step, as is the base `ai` package:
@@ -127,4 +129,4 @@ import genai from "@rhizomatics/signalk-einklabel-genai-plugin";
127
129
  genai.registerLlmProvider("my-provider", (settings) => myOwnModelFactory(settings));
128
130
  ```
129
131
 
130
- Declare this package as a regular npm `dependency` (**not** a `peerDependency` - see the core plugin's own README for why) plus `"signalk": { "requires": ["@rhizomatics/signalk-einklabel-genai-plugin"] }` in your extension's `package.json`.
132
+ Don't list this package in your extension's `dependencies` (or `peerDependencies`) - declare it via `"signalk": { "requires": ["@rhizomatics/signalk-einklabel-genai-plugin"] }` in your extension's `package.json` instead, and add it as a `devDependency` for types and tests. The App Store installs required plugins flat in `~/.signalk/node_modules`, so your `require` still resolves at runtime, to the same copy the running plugin uses - see the core plugin's [Extending](https://github.com/rhizomatics/signalk-einklabel-plugin/blob/main/docs/extending.md) docs for why.
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ import { ServerAPI, Plugin } from "@signalk/server-api";
2
2
  import { registerLlmProvider as registerLlmProviderImpl, getLlmProvider as getLlmProviderImpl } from "./llmProviderRegistry";
3
3
  import type { LlmProviderFactory as LlmProviderFactoryType, LlmSettings as LlmSettingsType } from "./llmProviderRegistry";
4
4
  /**
5
- * On `start()`, registers this plugin's `TemplateProvider` with the core `signalk-einklabel-plugin`
5
+ * When constructed, registers this plugin's `TemplateProvider` with the core `signalk-einklabel-plugin`
6
6
  * (`esl.registerTemplateProvider`, see `./templateProvider.ts` and `./plugin.ts`) - so its prompts show
7
7
  * up in the core plugin's own "Template" picker, suffixed "(GenAI)".
8
8
  */
@@ -12,8 +12,8 @@ declare namespace plugin {
12
12
  * Public extension point for a package adding support for an LLM provider this plugin doesn't
13
13
  * already bundle - see `registerLlmProvider`'s own doc comment (`./llmProviderRegistry.ts`) for the
14
14
  * full contract, and the core plugin's own `registerVendorDriver` doc comment for the dependency
15
- * convention to follow (a regular npm `dependency` plus `"signalk": { "requires": [...] }`, **not** a
16
- * `peerDependency`).
15
+ * convention to follow (`"signalk": { "requires": [...] }` plus a `devDependency` for types/tests,
16
+ * **not** a regular `dependency` or `peerDependency`).
17
17
  */
18
18
  const registerLlmProvider: typeof registerLlmProviderImpl;
19
19
  const getLlmProvider: typeof getLlmProviderImpl;
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  const plugin_1 = require("./plugin");
3
3
  const llmProviderRegistry_1 = require("./llmProviderRegistry");
4
4
  /**
5
- * On `start()`, registers this plugin's `TemplateProvider` with the core `signalk-einklabel-plugin`
5
+ * When constructed, registers this plugin's `TemplateProvider` with the core `signalk-einklabel-plugin`
6
6
  * (`esl.registerTemplateProvider`, see `./templateProvider.ts` and `./plugin.ts`) - so its prompts show
7
7
  * up in the core plugin's own "Template" picker, suffixed "(GenAI)".
8
8
  */
@@ -14,8 +14,8 @@ function plugin(app) {
14
14
  * Public extension point for a package adding support for an LLM provider this plugin doesn't
15
15
  * already bundle - see `registerLlmProvider`'s own doc comment (`./llmProviderRegistry.ts`) for the
16
16
  * full contract, and the core plugin's own `registerVendorDriver` doc comment for the dependency
17
- * convention to follow (a regular npm `dependency` plus `"signalk": { "requires": [...] }`, **not** a
18
- * `peerDependency`).
17
+ * convention to follow (`"signalk": { "requires": [...] }` plus a `devDependency` for types/tests,
18
+ * **not** a regular `dependency` or `peerDependency`).
19
19
  */
20
20
  plugin.registerLlmProvider = llmProviderRegistry_1.registerLlmProvider;
21
21
  plugin.getLlmProvider = llmProviderRegistry_1.getLlmProvider;
@@ -18,9 +18,10 @@ export type LlmProviderFactory = (settings: LlmSettings) => LlmModel;
18
18
  * forking this plugin - `resolveModel` in `./llmGateway.ts` checks this registry first, by `llmProvider`
19
19
  * name, before falling through to the built-in switch. Call this from your own SignalK plugin's
20
20
  * `start()` (or `esl-cli --require <module>`, loaded before parsing per the core plugin's own CLI
21
- * extension timing - see its README). Declare this package as a regular npm `dependency` (not a
22
- * `peerDependency` - see the core plugin's own `registerVendorDriver` doc comment for why) so npm
23
- * resolves a real copy to register into.
21
+ * extension timing - see its README). Declare this package via `"signalk": { "requires": [...] }` plus
22
+ * a `devDependency` for types/tests - not a regular `dependency` or `peerDependency`, so `require`
23
+ * resolves the one copy the App Store installed (see the core plugin's own `registerVendorDriver`
24
+ * doc comment for why).
24
25
  */
25
26
  export declare function registerLlmProvider(name: string, factory: LlmProviderFactory): void;
26
27
  export declare function getLlmProvider(name: string): LlmProviderFactory | undefined;
@@ -14,9 +14,10 @@ const providers = new Map();
14
14
  * forking this plugin - `resolveModel` in `./llmGateway.ts` checks this registry first, by `llmProvider`
15
15
  * name, before falling through to the built-in switch. Call this from your own SignalK plugin's
16
16
  * `start()` (or `esl-cli --require <module>`, loaded before parsing per the core plugin's own CLI
17
- * extension timing - see its README). Declare this package as a regular npm `dependency` (not a
18
- * `peerDependency` - see the core plugin's own `registerVendorDriver` doc comment for why) so npm
19
- * resolves a real copy to register into.
17
+ * extension timing - see its README). Declare this package via `"signalk": { "requires": [...] }` plus
18
+ * a `devDependency` for types/tests - not a regular `dependency` or `peerDependency`, so `require`
19
+ * resolves the one copy the App Store installed (see the core plugin's own `registerVendorDriver`
20
+ * doc comment for why).
20
21
  */
21
22
  function registerLlmProvider(name, factory) {
22
23
  providers.set(name, factory);
package/dist/plugin.js CHANGED
@@ -41,8 +41,10 @@ function createPlugin(app) {
41
41
  // Updated by `start()` on every (re)start - `createTemplateProvider`'s functions all read through
42
42
  // this closure rather than capturing one fixed config object, so a saved config change takes effect
43
43
  // on the *next* repaint without needing the core plugin (which holds the actual repaint schedule) to
44
- // know or care that anything changed on this side.
45
- let currentConfig = (0, config_1.defaultConfig)();
44
+ // know or care that anything changed on this side. `undefined` while the plugin isn't running: the
45
+ // provider stays registered (the core plugin has no way to unregister one, and the server constructs
46
+ // every installed plugin, enabled or not), so `render()` uses this to refuse LLM calls while disabled.
47
+ let currentConfig;
46
48
  signalk_einklabel_plugin_1.default.registerTemplateProvider((0, templateProvider_1.createTemplateProvider)(app, () => currentConfig));
47
49
  return {
48
50
  id: "signalk-einklabel-genai-plugin",
@@ -118,6 +120,8 @@ function createPlugin(app) {
118
120
  runConnectionTest(app, currentConfig).catch((err) => app.debug(`connection test failed unexpectedly: ${err.message}`));
119
121
  }
120
122
  },
121
- stop() { },
123
+ stop() {
124
+ currentConfig = undefined;
125
+ },
122
126
  };
123
127
  }
@@ -5,8 +5,18 @@ import { PluginConfig } from "./config";
5
5
  export declare const SUFFIX = "(GenAI)";
6
6
  /**
7
7
  * The `esl.TemplateProvider` this plugin registers with the core plugin (`esl.registerTemplateProvider`,
8
- * called from `./plugin.ts`'s `start()`). `listTemplates`/`describeBindings`/`render` all resolve the
8
+ * called when `./plugin.ts`'s `createPlugin` constructs the plugin, whether or not it is enabled). `listTemplates`/`describeBindings`/`render` all resolve the
9
9
  * current `promptsDir` fresh each time, rather than once at startup, so adding/editing a `.md` prompt
10
10
  * file takes effect without a plugin restart - same as the core plugin's own template directory.
11
11
  */
12
- export declare function createTemplateProvider(app: ServerAPI, getConfig: () => PluginConfig): esl.TemplateProvider;
12
+ /**
13
+ * `esl.TemplateProvider` with its optional `describeContent` made required, so callers (and tests) can
14
+ * use it without a guard. Core logs it to debug output before each `render()`, so you can see what the
15
+ * LLM is sent; a future core feature (e.g. emailing a label) could reuse it too. Older core versions
16
+ * without `describeContent` simply never call it.
17
+ */
18
+ export type GenAiTemplateProvider = esl.TemplateProvider & {
19
+ describeContent(request: esl.TemplateRenderRequest): string;
20
+ };
21
+ /** `getConfig` returns `undefined` while the plugin isn't running: templates are still listed, but `render()` rejects without calling the LLM. */
22
+ export declare function createTemplateProvider(app: ServerAPI, getConfig: () => PluginConfig | undefined): GenAiTemplateProvider;
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.SUFFIX = void 0;
7
7
  exports.createTemplateProvider = createTemplateProvider;
8
+ const crypto_1 = require("crypto");
8
9
  const fs_1 = require("fs");
9
10
  const path_1 = require("path");
10
11
  const signalk_einklabel_plugin_1 = __importDefault(require("@rhizomatics/signalk-einklabel-plugin"));
@@ -18,15 +19,10 @@ const GUIDANCE_NAME = (0, path_1.join)(".assets", "target_guidance.md");
18
19
  function promptNameFor(templateName) {
19
20
  return `${templateName.slice(0, templateName.length - ` ${exports.SUFFIX}`.length)}.md`;
20
21
  }
21
- /**
22
- * The `esl.TemplateProvider` this plugin registers with the core plugin (`esl.registerTemplateProvider`,
23
- * called from `./plugin.ts`'s `start()`). `listTemplates`/`describeBindings`/`render` all resolve the
24
- * current `promptsDir` fresh each time, rather than once at startup, so adding/editing a `.md` prompt
25
- * file takes effect without a plugin restart - same as the core plugin's own template directory.
26
- */
22
+ /** `getConfig` returns `undefined` while the plugin isn't running: templates are still listed, but `render()` rejects without calling the LLM. */
27
23
  function createTemplateProvider(app, getConfig) {
28
24
  function promptsDir() {
29
- return (0, config_1.resolvePromptsDir)(getConfig().promptsDir);
25
+ return (0, config_1.resolvePromptsDir)((getConfig() ?? (0, config_1.defaultConfig)()).promptsDir);
30
26
  }
31
27
  function promptPathFor(templateName) {
32
28
  const dir = promptsDir();
@@ -35,6 +31,13 @@ function createTemplateProvider(app, getConfig) {
35
31
  guidancePath: (0, config_1.resolvePromptPath)(dir, GUIDANCE_NAME),
36
32
  };
37
33
  }
34
+ /** Shared by `render` (sent to the LLM) and `describeContent` (for debugging what the LLM is sent) - the prompt+guidance text with every `{...}` placeholder substituted against `context`. */
35
+ function buildPrompt(templateName, context) {
36
+ const { promptPath, guidancePath } = promptPathFor(templateName);
37
+ const promptText = (0, fs_1.readFileSync)(promptPath, "utf-8");
38
+ const guidanceText = (0, fs_1.readFileSync)(guidancePath, "utf-8");
39
+ return `${signalk_einklabel_plugin_1.default.substituteBindingsInText(promptText, context).trim()}\n\n${signalk_einklabel_plugin_1.default.substituteBindingsInText(guidanceText, context).trim()}\n`;
40
+ }
38
41
  return {
39
42
  suffix: exports.SUFFIX,
40
43
  listTemplates() {
@@ -46,14 +49,15 @@ function createTemplateProvider(app, getConfig) {
46
49
  const guidanceText = (0, fs_1.readFileSync)(guidancePath, "utf-8");
47
50
  return signalk_einklabel_plugin_1.default.findBindingsInText(promptText, guidanceText);
48
51
  },
52
+ describeContent(request) {
53
+ return buildPrompt(request.templateName, request.context);
54
+ },
49
55
  async render(request) {
50
- const { promptPath, guidancePath } = promptPathFor(request.templateName);
51
- const promptText = (0, fs_1.readFileSync)(promptPath, "utf-8");
52
- const guidanceText = (0, fs_1.readFileSync)(guidancePath, "utf-8");
53
- const prompt = `${signalk_einklabel_plugin_1.default.substituteBindingsInText(promptText, request.context).trim()}\n\n${signalk_einklabel_plugin_1.default
54
- .substituteBindingsInText(guidanceText, request.context)
55
- .trim()}\n`;
56
56
  const config = getConfig();
57
+ if (!config) {
58
+ throw new Error('signalk-einklabel-genai-plugin is not running - enable it to render "(GenAI)" templates');
59
+ }
60
+ const prompt = buildPrompt(request.templateName, request.context);
57
61
  const llmSettings = {
58
62
  llmProvider: config.llmProvider,
59
63
  llmApiKey: config.llmApiKey,
@@ -77,9 +81,16 @@ function createTemplateProvider(app, getConfig) {
77
81
  // reused unchanged, rather than needing its own copy of that logic.
78
82
  const cacheDir = (0, path_1.join)(promptsDir(), ".genai-cache");
79
83
  (0, fs_1.mkdirSync)(cacheDir, { recursive: true });
80
- const cachePath = (0, path_1.join)(cacheDir, `${Buffer.from(request.templateName).toString("hex")}.svg`);
84
+ // One file per render: core repaints a device's targets in parallel, so a name shared by every
85
+ // render of the same template could be overwritten by another target's SVG before it is read.
86
+ const cachePath = (0, path_1.join)(cacheDir, `${Buffer.from(request.templateName).toString("hex")}-${(0, crypto_1.randomUUID)()}.svg`);
81
87
  (0, fs_1.writeFileSync)(cachePath, svg);
82
- return renderer.render(cachePath, request.context, request.width, request.height);
88
+ try {
89
+ return await renderer.render(cachePath, request.context, request.width, request.height);
90
+ }
91
+ finally {
92
+ (0, fs_1.rmSync)(cachePath, { force: true });
93
+ }
83
94
  },
84
95
  };
85
96
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizomatics/signalk-einklabel-genai-plugin",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "GenAI prompt generated labels for the eInk Electronic Shelf Label plugin. Contributes prompts as an alternative to hand-authored SVG templates.",
5
5
  "keywords": [
6
6
  "eink",
@@ -52,14 +52,14 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "@openrouter/ai-sdk-provider": "^3.0.0",
55
- "@rhizomatics/signalk-einklabel-plugin": "^1.0.0",
56
55
  "ai": "^7.0.37"
57
56
  },
58
57
  "devDependencies": {
58
+ "@rhizomatics/signalk-einklabel-plugin": "^1.0.0",
59
59
  "@signalk/server-api": "^2.28.0",
60
- "@types/node": "^20.14.0",
61
- "commander": "^12.1.0",
62
- "oxfmt": "^0.59.0",
60
+ "@types/node": "^26.6.2",
61
+ "commander": "^15.0.0",
62
+ "oxfmt": "^0.74.0",
63
63
  "oxlint": "^1.74.0",
64
64
  "oxlint-tsgolint": "^7.0.0",
65
65
  "ts-node": "^10.9.2",
@@ -72,11 +72,12 @@
72
72
  "@ai-sdk/moonshotai": "^3.0.17",
73
73
  "@ai-sdk/openai": "^4.0.20",
74
74
  "@ai-sdk/openai-compatible": "^3.0.14",
75
- "@ai-sdk/xai": "^4.0.18",
75
+ "@ai-sdk/xai": "^5.0.4",
76
76
  "ai-sdk-ollama": "^4.1.0"
77
77
  },
78
78
  "engines": {
79
- "node": ">=18"
79
+ "node": ">=22",
80
+ "signalk-server": ">=2.25.0"
80
81
  },
81
82
  "signalk": {
82
83
  "displayName": "eInk Label GenAI Templates",