@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 +5 -0
- package/README.md +15 -8
- package/dist/config.d.ts +7 -0
- package/dist/llmGateway.d.ts +15 -1
- package/dist/llmGateway.js +55 -1
- package/dist/llmProviderRegistry.d.ts +2 -0
- package/dist/llmProviderRegistry.js +5 -0
- package/dist/plugin.js +44 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/dist/llmGateway.d.ts
CHANGED
|
@@ -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` -
|
|
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
|
package/dist/llmGateway.js
CHANGED
|
@@ -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` -
|
|
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:
|
|
28
|
-
"
|
|
29
|
-
'
|
|
30
|
-
'OpenAI-compatible endpoint (e.g. LM Studio)
|
|
31
|
-
|
|
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.
|
|
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",
|