@rhizomatics/signalk-einklabel-genai-plugin 0.1.1 → 0.1.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,8 @@
1
+ # [1.0.2]
2
+
3
+ - AI providers drop-down now restricted to available AI SDK plugins
4
+ - Test option added to validate AI configuration, keys and connectivity
5
+
1
6
  # [1.0.1]
2
7
 
3
8
  - Packaging fixes
package/README.md CHANGED
@@ -16,17 +16,17 @@ All prompts also get a generated appendix to the prompt that gives details about
16
16
 
17
17
  ### Full Example
18
18
 
19
- ```markdown
20
- Summarize the maritime weather outlook for the next 24 hours, appropriate for the sailor of a 13.6m Sailing craft positioned at latitude 40.689247, longitude -74.044502
19
+ > Summarize the maritime weather outlook for the next 24 hours, appropriate for the sailor of a 13.6m Sailing craft positioned at latitude 40.689247, longitude -74.044502
20
+ >
21
+ > Generate an SVG image for the result suitable for a ZhunyCo 4.13" eInk Electronic Shelf Label display with pixel height 240 and width 416. Available colours are black (#000000), white (#FFFFFF), red (#FF0000), yellow (#FFFF00). Fonts available are serif, sans-serif, monospace.
22
+ >
23
+ > Use graphics to make it easier to read and use the space better, and use colour and layout to draw attention to safety critical information. Include in minimal size font the source of the information.
21
24
 
22
- Generate an SVG image for the result suitable for a ZhunyCo 4.13" eInk Electronic Shelf Label display with pixel height 240 and width 416. Available colours are black (#000000), white (#FFFFFF), red (#FF0000), yellow (#FFFF00). Fonts available are serif, sans-serif, monospace.
23
-
24
- Use graphics to make it easier to read and use the space better, and use colour and layout to draw attention to safety critical information. Include in minimal size font the source of the information.
25
- ```
25
+ CAUTION: Be careful with GenAI generated content, it will often be very good but may be prone to hallucinations or omissions.
26
26
 
27
27
  ## Installation
28
28
 
29
- 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 a user only ever needs 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:
29
+ 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:
30
30
 
31
31
  ```bash
32
32
  npm install ai @ai-sdk/openai # llmProvider: "openai"
@@ -41,14 +41,21 @@ npm install ai @ai-sdk/openai-compatible # llmProvider: "local" (LM Studio, vLL
41
41
 
42
42
  If the package for your configured provider isn't installed, a GenAI-backed device falls back to the core plugin's warning template with a log message naming exactly which package to `npm install`.
43
43
 
44
+ ### Local LLMs
45
+
46
+ Many Raspberry Pi type tiny computers either have an onboard NPU for AI work, or an add-on 'hat', like the [Raspberry Pi AI Kit](https://www.raspberrypi.com/products/ai-kit/).
47
+
48
+ Combined with a distilled model, for example Llama 3.2 3B, Phi-4 Mini 3.8B, or Qwen3.5-4B, it is possible to have something reasonable in 8Gb, and the eInk use case is ideal, since it runs in the background a couple of times a day, so slow token generation isn't an issue.
49
+
44
50
  ## Configuration
45
51
 
46
- - `llmProvider` - `openai`, `anthropic`, `google`, `xai`, `deepseek`, `moonshotai`, `openrouter`, `ollama`, or `local` (any other OpenAI-compatible server)
52
+ - `llmProvider` - `openai`, `anthropic`, `google`, `xai`, `deepseek`, `moonshotai`, `openrouter`, `ollama`, or `local` (any other OpenAI-compatible server). The config screen's dropdown only lists providers whose package is actually installed (`openrouter` always is) - install another one's optional package and reopen the page to add it.
47
53
  - `llmApiKey` - not needed for `ollama`/`local` unless your server itself checks one
48
54
  - `llmModel` - provider-specific model id, e.g. `gpt-4o`, `claude-sonnet-4-5`, `gemini-2.5-flash`, `grok-4`, or an Ollama/local model tag
49
55
  - `llmBaseUrl` - required for `local`; optional for `ollama` (defaults to `http://localhost:11434/v1`)
50
56
  - `llmTimeoutSeconds`/`llmRetries` - how long to wait, and how many attempts, before giving up (the core plugin then shows its fallback warning)
51
57
  - `promptsDir` - where to look for your own `.md` prompt files, same convention as the core plugin's `templatesDir` (empty for the default, a relative path resolves against `~/.signalk`, absolute used as-is)
58
+ - `testConnection` - not a persistent setting, just a one-shot checkbox: check it and save to send one test message to the LLM settings above as soon as the plugin (re)starts. The result (success, or the error) shows up on this plugin's own entry on the Plugin Config page - the checkbox clears itself afterwards, so it won't refire on the next ordinary restart.
52
59
 
53
60
  ## Writing Prompts
54
61
 
package/dist/config.d.ts CHANGED
@@ -29,6 +29,13 @@ export interface PluginConfig {
29
29
  * absolute path. Use `resolvePromptsDir` to turn this into an actual path.
30
30
  */
31
31
  promptsDir: string;
32
+ /**
33
+ * When `true`, `start()` sends one test message to the configured LLM and reports the result via
34
+ * `app.setPluginStatus`/`setPluginError` (visible on the server's Plugin Config page) - then clears
35
+ * itself back to `false` via `savePluginOptions` so it doesn't refire on every ordinary restart. Not
36
+ * a persistent setting, just a one-shot "test connection now" checkbox - see `plugin.ts`'s `start()`.
37
+ */
38
+ testConnection?: boolean;
32
39
  }
33
40
  /**
34
41
  * This package's own bundled `prompts/` directory (ships alongside `dist/`, see package.json's
@@ -5,7 +5,7 @@ export type LlmSettings = RegistryLlmSettings & {
5
5
  };
6
6
  /**
7
7
  * `ai` and every `@ai-sdk/*`/`ai-sdk-ollama` provider package (other than `@openrouter/ai-sdk-provider`,
8
- * see below) are `optionalDependencies` (see package.json), not plain `dependencies` - a user picks
8
+ * see below) are `optionalDependencies` (see package.json), not plain `dependencies` - user picks
9
9
  * exactly one provider, so there's no reason to force every one of them to install, and an install that
10
10
  * fails to fetch one it doesn't even use (a network hiccup, `npm install --omit=optional`, an unsupported
11
11
  * platform) must not break this plugin for everyone else. That means every reference to one of these
@@ -15,6 +15,20 @@ export type LlmSettings = RegistryLlmSettings & {
15
15
  * is erased entirely at compile time and leaves no runtime trace for `tsc` to eagerly require.
16
16
  */
17
17
  export declare function loadOptional<M>(moduleName: string): M;
18
+ /** Whether `moduleName` resolves at all - cheaper than `loadOptional`, since it never actually evaluates the module. */
19
+ export declare function isPackageInstalled(moduleName: string): boolean;
20
+ /**
21
+ * Every `llmProvider` name actually usable right now, for the plugin config screen's `llmProvider`
22
+ * dropdown (`plugin.ts`'s `schema()`) - so a provider whose package isn't installed doesn't even show
23
+ * up as pickable. Rather than a second hardcoded provider-to-package table duplicating `resolveModel`'s
24
+ * switch, this reads this plugin's own `optionalDependencies` straight out of `package.json` (the
25
+ * actual, already-curated list of installable provider packages - `require.resolve`-able but never
26
+ * `require()`'d, so a failed optional install can't crash this) and checks each with `isPackageInstalled`.
27
+ * `"openrouter"` is always included (it's a regular `dependency`, see this module's top doc comment);
28
+ * any name added via `registerLlmProvider` is included unconditionally too, since there's no package to
29
+ * check for those.
30
+ */
31
+ export declare function availableProviders(): string[];
18
32
  /**
19
33
  * Calls the configured LLM gateway with `prompt`, returning its raw text response - not yet extracted/
20
34
  * validated as SVG, see `extractSvg`. Wrapped in its own timeout (`AbortController`) since this is the
@@ -1,13 +1,17 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.loadOptional = loadOptional;
4
+ exports.isPackageInstalled = isPackageInstalled;
5
+ exports.availableProviders = availableProviders;
4
6
  exports.callLlm = callLlm;
5
7
  exports.extractSvg = extractSvg;
8
+ const fs_1 = require("fs");
9
+ const path_1 = require("path");
6
10
  const ai_sdk_provider_1 = require("@openrouter/ai-sdk-provider");
7
11
  const llmProviderRegistry_1 = require("./llmProviderRegistry");
8
12
  /**
9
13
  * `ai` and every `@ai-sdk/*`/`ai-sdk-ollama` provider package (other than `@openrouter/ai-sdk-provider`,
10
- * see below) are `optionalDependencies` (see package.json), not plain `dependencies` - a user picks
14
+ * see below) are `optionalDependencies` (see package.json), not plain `dependencies` - user picks
11
15
  * exactly one provider, so there's no reason to force every one of them to install, and an install that
12
16
  * fails to fetch one it doesn't even use (a network hiccup, `npm install --omit=optional`, an unsupported
13
17
  * platform) must not break this plugin for everyone else. That means every reference to one of these
@@ -27,6 +31,56 @@ function loadOptional(moduleName) {
27
31
  }
28
32
  /** Ollama's own default local listen address - see the `"ollama"` case in `resolveModel` below. */
29
33
  const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434/v1";
34
+ /** Whether `moduleName` resolves at all - cheaper than `loadOptional`, since it never actually evaluates the module. */
35
+ function isPackageInstalled(moduleName) {
36
+ try {
37
+ require.resolve(moduleName);
38
+ return true;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
44
+ /**
45
+ * A package name from this plugin's own `optionalDependencies` doesn't always match the `llmProvider`
46
+ * id `resolveModel`'s switch uses it for: `ai-sdk-ollama` isn't under the `@ai-sdk/` scope at all, and
47
+ * `@ai-sdk/openai-compatible` backs `"local"`, not a provider literally named "openai-compatible".
48
+ * Every other optional package *does* match `@ai-sdk/<id>` (see `resolveModel`'s cases), so only these
49
+ * two irregular ones need spelling out - everything else is derived by stripping the scope below.
50
+ */
51
+ const PACKAGE_TO_PROVIDER_ID = {
52
+ "ai-sdk-ollama": "ollama",
53
+ "@ai-sdk/openai-compatible": "local",
54
+ };
55
+ /** Preferred dropdown order - purely cosmetic; anything not listed here (a future package, a custom-registered name) sorts after these, alphabetically. */
56
+ const PROVIDER_DISPLAY_ORDER = ["openrouter", "openai", "anthropic", "google", "xai", "deepseek", "moonshotai", "ollama", "local"];
57
+ function byDisplayOrder(a, b) {
58
+ const ai = PROVIDER_DISPLAY_ORDER.indexOf(a);
59
+ const bi = PROVIDER_DISPLAY_ORDER.indexOf(b);
60
+ if (ai === -1 && bi === -1)
61
+ return a.localeCompare(b);
62
+ if (ai === -1 || bi === -1)
63
+ return ai === -1 ? 1 : -1;
64
+ return ai - bi;
65
+ }
66
+ /**
67
+ * Every `llmProvider` name actually usable right now, for the plugin config screen's `llmProvider`
68
+ * dropdown (`plugin.ts`'s `schema()`) - so a provider whose package isn't installed doesn't even show
69
+ * up as pickable. Rather than a second hardcoded provider-to-package table duplicating `resolveModel`'s
70
+ * switch, this reads this plugin's own `optionalDependencies` straight out of `package.json` (the
71
+ * actual, already-curated list of installable provider packages - `require.resolve`-able but never
72
+ * `require()`'d, so a failed optional install can't crash this) and checks each with `isPackageInstalled`.
73
+ * `"openrouter"` is always included (it's a regular `dependency`, see this module's top doc comment);
74
+ * any name added via `registerLlmProvider` is included unconditionally too, since there's no package to
75
+ * check for those.
76
+ */
77
+ function availableProviders() {
78
+ const packageJson = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(__dirname, "..", "package.json"), "utf-8"));
79
+ const installed = Object.keys(packageJson.optionalDependencies ?? {})
80
+ .filter(isPackageInstalled)
81
+ .map((pkg) => PACKAGE_TO_PROVIDER_ID[pkg] ?? pkg.replace(/^@ai-sdk\//, ""));
82
+ return [...new Set(["openrouter", ...installed, ...(0, llmProviderRegistry_1.listRegisteredProviders)()])].sort(byDisplayOrder);
83
+ }
30
84
  /**
31
85
  * Resolves `settings` to a Vercel AI SDK model handle. Checks `registerLlmProvider`'s registry
32
86
  * (`./llmProviderRegistry.ts`) first, by exact `llmProvider` name, before falling through to this
@@ -24,3 +24,5 @@ export type LlmProviderFactory = (settings: LlmSettings) => LlmModel;
24
24
  */
25
25
  export declare function registerLlmProvider(name: string, factory: LlmProviderFactory): void;
26
26
  export declare function getLlmProvider(name: string): LlmProviderFactory | undefined;
27
+ /** Names of every custom provider registered via `registerLlmProvider` - see `llmGateway.ts`'s `availableProviders`. */
28
+ export declare function listRegisteredProviders(): string[];
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerLlmProvider = registerLlmProvider;
4
4
  exports.getLlmProvider = getLlmProvider;
5
+ exports.listRegisteredProviders = listRegisteredProviders;
5
6
  const providers = new Map();
6
7
  /**
7
8
  * Public extension point mirroring the core `signalk-einklabel-plugin`'s own `registerVendorDriver`/
@@ -23,3 +24,7 @@ function registerLlmProvider(name, factory) {
23
24
  function getLlmProvider(name) {
24
25
  return providers.get(name);
25
26
  }
27
+ /** Names of every custom provider registered via `registerLlmProvider` - see `llmGateway.ts`'s `availableProviders`. */
28
+ function listRegisteredProviders() {
29
+ return [...providers.keys()];
30
+ }
package/dist/plugin.js CHANGED
@@ -6,7 +6,34 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createPlugin = createPlugin;
7
7
  const signalk_einklabel_plugin_1 = __importDefault(require("@rhizomatics/signalk-einklabel-plugin"));
8
8
  const config_1 = require("./config");
9
+ const llmGateway_1 = require("./llmGateway");
10
+ const retry_1 = require("./retry");
9
11
  const templateProvider_1 = require("./templateProvider");
12
+ /** Sent by the `testConnection` checkbox - short and cheap on tokens, just enough to prove the round trip works. */
13
+ const TEST_PROMPT = 'Reply with exactly the word "OK", nothing else.';
14
+ /**
15
+ * Runs one test call against `config`'s LLM settings and reports the outcome via
16
+ * `setPluginStatus`/`setPluginError` - both shown on the server's Plugin Config page - then clears the
17
+ * one-shot `testConnection` flag via `savePluginOptions` so it doesn't refire on the next ordinary
18
+ * restart. Fire-and-forget from `start()`: failures are reported through `setPluginError`, not thrown,
19
+ * since nothing in `start()`'s own contract is waiting on this.
20
+ */
21
+ async function runConnectionTest(app, config) {
22
+ const label = `${config.llmProvider ?? "openrouter"}/${config.llmModel ?? "(no model configured)"}`;
23
+ try {
24
+ const reply = await (0, retry_1.withRetries)(config.llmRetries, () => (0, llmGateway_1.callLlm)(config, TEST_PROMPT));
25
+ app.setPluginStatus(`LLM test OK (${label}): ${reply.trim().slice(0, 200)}`);
26
+ }
27
+ catch (err) {
28
+ app.setPluginError(`LLM test failed (${label}): ${err.message}`);
29
+ }
30
+ finally {
31
+ app.savePluginOptions({ ...config, testConnection: false }, (err) => {
32
+ if (err)
33
+ app.debug(`failed to clear testConnection flag: ${err.message}`);
34
+ });
35
+ }
36
+ }
10
37
  function createPlugin(app) {
11
38
  // Updated by `start()` on every (re)start - `createTemplateProvider`'s functions all read through
12
39
  // this closure rather than capturing one fixed config object, so a saved config change takes effect
@@ -24,11 +51,12 @@ function createPlugin(app) {
24
51
  llmProvider: {
25
52
  type: "string",
26
53
  title: "LLM provider",
27
- description: 'Which LLM gateway to call - "OpenRouter" is bundled and needs no extra install; every other option ' +
28
- "(other than local/ollama) needs its own optional npm package installed, e.g. `npm install ai @ai-sdk/anthropic` " +
29
- '(see README). "Ollama" defaults to a local Ollama server with no further config; "Local" is any other ' +
30
- 'OpenAI-compatible endpoint (e.g. LM Studio) and needs "LLM base URL" below.',
31
- enum: ["openrouter", "openai", "anthropic", "google", "xai", "deepseek", "moonshotai", "ollama", "local"],
54
+ description: "Which LLM gateway to call - only providers whose npm package is actually installed are listed here; " +
55
+ "install another one's optional package (e.g. `npm install ai @ai-sdk/anthropic`, see README) and reopen " +
56
+ 'this page to add it. "OpenRouter" is bundled and needs no extra install. "Ollama" defaults to a local ' +
57
+ 'Ollama server with no further config; "Local" is any other OpenAI-compatible endpoint (e.g. LM Studio) ' +
58
+ 'and needs "LLM base URL" below.',
59
+ enum: (0, llmGateway_1.availableProviders)(),
32
60
  default: "openrouter",
33
61
  },
34
62
  llmApiKey: {
@@ -69,12 +97,23 @@ function createPlugin(app) {
69
97
  'picker, suffixed "(GenAI)".',
70
98
  default: (0, config_1.defaultConfig)().promptsDir,
71
99
  },
100
+ testConnection: {
101
+ type: "boolean",
102
+ title: "Test connection now",
103
+ description: "Check this and save to send one test message to the LLM settings above as soon as the plugin (re)starts. " +
104
+ "Watch this plugin's entry on the Plugin Config page for the result (success or the error) - the checkbox " +
105
+ "clears itself afterwards, so it won't refire on the next ordinary restart.",
106
+ default: false,
107
+ },
72
108
  },
73
109
  }),
74
110
  uiSchema: () => ({ llmApiKey: { "ui:widget": "password" } }),
75
111
  start(config) {
76
112
  currentConfig = { ...(0, config_1.defaultConfig)(), ...config };
77
113
  app.debug(`eInk Label GenAI plugin started (llmProvider=${currentConfig.llmProvider ?? "openrouter"})`);
114
+ if (currentConfig.testConnection) {
115
+ runConnectionTest(app, currentConfig).catch((err) => app.debug(`connection test failed unexpectedly: ${err.message}`));
116
+ }
78
117
  },
79
118
  stop() { },
80
119
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rhizomatics/signalk-einklabel-genai-plugin",
3
- "version": "0.1.1",
3
+ "version": "0.1.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",