@rhizomatics/signalk-einklabel-genai-plugin 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -5
- package/README.md +32 -9
- package/dist/cliExtension.js +8 -3
- package/dist/config.d.ts +7 -0
- package/dist/config.js +1 -1
- package/dist/llmGateway.d.ts +43 -6
- package/dist/llmGateway.js +77 -11
- package/dist/llmProviderRegistry.d.ts +2 -0
- package/dist/llmProviderRegistry.js +5 -0
- package/dist/plugin.js +49 -7
- package/dist/templateProvider.d.ts +2 -1
- package/dist/templateProvider.js +11 -3
- package/package.json +2 -2
- package/prompts/.assets/target_guidance.md +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
|
-
# [
|
|
1
|
+
# [0.3.0]
|
|
2
|
+
|
|
3
|
+
- Update timeout to 60 seconds to handle slower local or free models
|
|
4
|
+
- Better handle retry for responses that don't have cleanly extractable SVG
|
|
5
|
+
- OpenRouter model and token usage now logged
|
|
6
|
+
- Strip HTML punctuation that breaks XML before converting SVG
|
|
7
|
+
|
|
8
|
+
# [0.2.0]
|
|
9
|
+
|
|
10
|
+
- AI providers drop-down now restricted to available AI SDK plugins
|
|
11
|
+
- Test option added to validate AI configuration, keys and connectivity
|
|
12
|
+
|
|
13
|
+
# [0.1.1]
|
|
2
14
|
|
|
3
15
|
- Packaging fixes
|
|
4
|
-
- Simplified adding GenAI support to `esl-cli`
|
|
5
|
-
- Added `openrouter` as the default built-in AI provider
|
|
6
|
-
- Other providers are available by `npm install`
|
|
7
16
|
|
|
8
|
-
# [1.0
|
|
17
|
+
# [0.1.0]
|
|
9
18
|
|
|
10
19
|
- Initial release with support for major cloud GenAI services and local LLMs
|
|
11
20
|
- As an alternative to a template, an image can be generated by an LLM based on customized prompt
|
package/README.md
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
|
-
# eInk Label GenAI
|
|
1
|
+
# eInk Label GenAI Templates
|
|
2
2
|
|
|
3
|
-
A companion plugin for [`@rhizomatics/signalk-einklabel-plugin`](https://github.com/rhizomatics/signalk-einklabel-plugin) that generates
|
|
3
|
+
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
4
|
|
|
5
5
|
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.
|
|
6
6
|
|
|
7
|
+

|
|
8
|
+
|
|
7
9
|
## Working Example
|
|
8
10
|
|
|
9
11
|
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.
|
|
10
12
|
|
|
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
|
+
|
|
11
19
|
### Customizing Prompts
|
|
12
20
|
|
|
13
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.
|
|
@@ -16,17 +24,25 @@ All prompts also get a generated appendix to the prompt that gives details about
|
|
|
16
24
|
|
|
17
25
|
### Full Example
|
|
18
26
|
|
|
19
|
-
|
|
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
|
|
27
|
+
#### Forecast
|
|
21
28
|
|
|
22
|
-
|
|
29
|
+
> 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
|
|
23
30
|
|
|
24
|
-
|
|
25
|
-
|
|
31
|
+
#### Target Guidance
|
|
32
|
+
|
|
33
|
+
The following has been automatically added to the end of the standard prompt, with information specific to which label is being targetted.
|
|
34
|
+
|
|
35
|
+
> 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.
|
|
36
|
+
>
|
|
37
|
+
> 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 and generation timestamp.
|
|
38
|
+
|
|
39
|
+
### CAUTION
|
|
40
|
+
|
|
41
|
+
Be careful with GenAI generated content, it will often be very good but may be prone to hallucinations or omissions.
|
|
26
42
|
|
|
27
43
|
## Installation
|
|
28
44
|
|
|
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
|
|
45
|
+
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
46
|
|
|
31
47
|
```bash
|
|
32
48
|
npm install ai @ai-sdk/openai # llmProvider: "openai"
|
|
@@ -41,14 +57,21 @@ npm install ai @ai-sdk/openai-compatible # llmProvider: "local" (LM Studio, vLL
|
|
|
41
57
|
|
|
42
58
|
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
59
|
|
|
60
|
+
### Local LLMs
|
|
61
|
+
|
|
62
|
+
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/).
|
|
63
|
+
|
|
64
|
+
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.
|
|
65
|
+
|
|
44
66
|
## Configuration
|
|
45
67
|
|
|
46
|
-
- `llmProvider` - `openai`, `anthropic`, `google`, `xai`, `deepseek`, `moonshotai`, `openrouter`, `ollama`, or `local` (any other OpenAI-compatible server)
|
|
68
|
+
- `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
69
|
- `llmApiKey` - not needed for `ollama`/`local` unless your server itself checks one
|
|
48
70
|
- `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
71
|
- `llmBaseUrl` - required for `local`; optional for `ollama` (defaults to `http://localhost:11434/v1`)
|
|
50
72
|
- `llmTimeoutSeconds`/`llmRetries` - how long to wait, and how many attempts, before giving up (the core plugin then shows its fallback warning)
|
|
51
73
|
- `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)
|
|
74
|
+
- `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
75
|
|
|
53
76
|
## Writing Prompts
|
|
54
77
|
|
package/dist/cliExtension.js
CHANGED
|
@@ -106,7 +106,7 @@ addPromptOptions(cli.program
|
|
|
106
106
|
.option("--llm-api-key <key>", "LLM API key - falls back to the provider's usual environment variable if omitted")
|
|
107
107
|
.requiredOption("--llm-model <name>", "provider-specific model id/name, e.g. gpt-4o, claude-sonnet-4-5, gemini-2.5-flash, grok-4, or a local/ollama model tag")
|
|
108
108
|
.option("--llm-base-url <url>", 'OpenAI-compatible endpoint - required if --llm-provider is "local"; for "ollama" defaults to http://localhost:11434/v1')
|
|
109
|
-
.option("--llm-timeout <seconds>", "LLM call timeout", "
|
|
109
|
+
.option("--llm-timeout <seconds>", "LLM call timeout", "60")
|
|
110
110
|
.option("--llm-retries <n>", "LLM call attempts before giving up", "2")
|
|
111
111
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
112
112
|
.action(async (name, opts) => {
|
|
@@ -118,8 +118,13 @@ addPromptOptions(cli.program
|
|
|
118
118
|
llmBaseUrl: opts.llmBaseUrl,
|
|
119
119
|
llmTimeoutSeconds: Number(opts.llmTimeout),
|
|
120
120
|
};
|
|
121
|
-
const
|
|
122
|
-
|
|
121
|
+
const { text: svg, model, usage, } = await (0, retry_1.withRetries)(Number(opts.llmRetries), async () => {
|
|
122
|
+
const result = await (0, llmGateway_1.callLlm)(llmSettings, prompt);
|
|
123
|
+
return { ...result, text: (0, llmGateway_1.extractSvg)(result.text) };
|
|
124
|
+
});
|
|
125
|
+
if ((0, llmGateway_1.isOpenRouterProvider)(llmSettings.llmProvider)) {
|
|
126
|
+
console.log(`openrouter model=${model ?? "unknown"} usage=${JSON.stringify(usage)}`);
|
|
127
|
+
}
|
|
123
128
|
const svgPath = opts.saveSvg ?? (0, path_1.join)((0, os_1.tmpdir)(), `esl-cli-generate-${Date.now()}.svg`);
|
|
124
129
|
await (0, promises_1.writeFile)(svgPath, svg);
|
|
125
130
|
const width = Number(opts.width);
|
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/config.js
CHANGED
|
@@ -21,7 +21,7 @@ const SIGNALK_HOME_DIR = (0, path_1.join)((0, os_1.homedir)(), ".signalk");
|
|
|
21
21
|
const DEFAULT_PROMPTS_DIR = (0, path_1.join)(SIGNALK_HOME_DIR, "einklabel-genai", "prompts");
|
|
22
22
|
function defaultConfig() {
|
|
23
23
|
return {
|
|
24
|
-
llmTimeoutSeconds:
|
|
24
|
+
llmTimeoutSeconds: 60,
|
|
25
25
|
llmRetries: 2,
|
|
26
26
|
promptsDir: "",
|
|
27
27
|
};
|
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,16 +15,53 @@ 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;
|
|
18
20
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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.
|
|
22
30
|
*/
|
|
23
|
-
export declare function
|
|
31
|
+
export declare function availableProviders(): string[];
|
|
32
|
+
/** Token counts for one `callLlm` call, straight off the Vercel AI SDK's own normalized `usage` - see `CallLlmResult`. */
|
|
33
|
+
export interface LlmUsage {
|
|
34
|
+
promptTokens?: number;
|
|
35
|
+
completionTokens?: number;
|
|
36
|
+
totalTokens?: number;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* `callLlm`'s result - `text` is the raw response (not yet extracted/validated as SVG, see `extractSvg`).
|
|
40
|
+
* `model`/`usage` are mainly useful for `"openrouter"`, where `llmModel` is often a routing alias (e.g.
|
|
41
|
+
* the `"openrouter/free"` default - see `DEFAULT_OPENROUTER_MODEL`) that resolves to a *different* actual
|
|
42
|
+
* model each call - callers that care (see `isOpenRouterProvider`) can log which one actually answered
|
|
43
|
+
* and how many tokens it used.
|
|
44
|
+
*/
|
|
45
|
+
export interface CallLlmResult {
|
|
46
|
+
text: string;
|
|
47
|
+
model?: string;
|
|
48
|
+
usage?: LlmUsage;
|
|
49
|
+
}
|
|
50
|
+
/** Whether `provider` (or its default) is `"openrouter"` - gates the model/usage debug logging `CallLlmResult` exists for for, since that detail is really only interesting there (see `CallLlmResult`'s doc comment). */
|
|
51
|
+
export declare function isOpenRouterProvider(provider?: string): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Calls the configured LLM gateway with `prompt`. Wrapped in its own timeout (`AbortController`) since
|
|
54
|
+
* this is the one genuinely slow/unreliable network call in this plugin.
|
|
55
|
+
*/
|
|
56
|
+
export declare function callLlm(settings: LlmSettings, prompt: string): Promise<CallLlmResult>;
|
|
24
57
|
/**
|
|
25
58
|
* Strips everything outside the first `<svg`...last `</svg>` span - a chat model asked for "only raw
|
|
26
59
|
* SVG markup" still often wraps it in a markdown code fence or adds a sentence of commentary either
|
|
27
|
-
* side, despite the target-guidance fragment telling it not to.
|
|
60
|
+
* side, despite the target-guidance fragment telling it not to. Also swaps any ` ` for the literal
|
|
61
|
+
* U+00A0 character it means - ` ` isn't one of XML's five predefined entities (only
|
|
62
|
+
* `&`/`<`/`>`/`'`/`"` are), so a model used to writing HTML emits it anyway, and the
|
|
63
|
+
* core plugin's `@xmldom/xmldom` DOMParser then fails to parse the document at all over one undefined
|
|
64
|
+
* entity reference. The raw character needs no entity encoding, so this sidesteps the problem entirely.
|
|
28
65
|
*/
|
|
29
66
|
export declare function extractSvg(raw: string): string;
|
|
30
67
|
export type { PluginConfig };
|
package/dist/llmGateway.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
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;
|
|
6
|
+
exports.isOpenRouterProvider = isOpenRouterProvider;
|
|
4
7
|
exports.callLlm = callLlm;
|
|
5
8
|
exports.extractSvg = extractSvg;
|
|
9
|
+
const fs_1 = require("fs");
|
|
10
|
+
const path_1 = require("path");
|
|
6
11
|
const ai_sdk_provider_1 = require("@openrouter/ai-sdk-provider");
|
|
7
12
|
const llmProviderRegistry_1 = require("./llmProviderRegistry");
|
|
8
13
|
/**
|
|
9
14
|
* `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` -
|
|
15
|
+
* see below) are `optionalDependencies` (see package.json), not plain `dependencies` - user picks
|
|
11
16
|
* exactly one provider, so there's no reason to force every one of them to install, and an install that
|
|
12
17
|
* fails to fetch one it doesn't even use (a network hiccup, `npm install --omit=optional`, an unsupported
|
|
13
18
|
* platform) must not break this plugin for everyone else. That means every reference to one of these
|
|
@@ -27,6 +32,56 @@ function loadOptional(moduleName) {
|
|
|
27
32
|
}
|
|
28
33
|
/** Ollama's own default local listen address - see the `"ollama"` case in `resolveModel` below. */
|
|
29
34
|
const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434/v1";
|
|
35
|
+
/** Whether `moduleName` resolves at all - cheaper than `loadOptional`, since it never actually evaluates the module. */
|
|
36
|
+
function isPackageInstalled(moduleName) {
|
|
37
|
+
try {
|
|
38
|
+
require.resolve(moduleName);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A package name from this plugin's own `optionalDependencies` doesn't always match the `llmProvider`
|
|
47
|
+
* id `resolveModel`'s switch uses it for: `ai-sdk-ollama` isn't under the `@ai-sdk/` scope at all, and
|
|
48
|
+
* `@ai-sdk/openai-compatible` backs `"local"`, not a provider literally named "openai-compatible".
|
|
49
|
+
* Every other optional package *does* match `@ai-sdk/<id>` (see `resolveModel`'s cases), so only these
|
|
50
|
+
* two irregular ones need spelling out - everything else is derived by stripping the scope below.
|
|
51
|
+
*/
|
|
52
|
+
const PACKAGE_TO_PROVIDER_ID = {
|
|
53
|
+
"ai-sdk-ollama": "ollama",
|
|
54
|
+
"@ai-sdk/openai-compatible": "local",
|
|
55
|
+
};
|
|
56
|
+
/** Preferred dropdown order - purely cosmetic; anything not listed here (a future package, a custom-registered name) sorts after these, alphabetically. */
|
|
57
|
+
const PROVIDER_DISPLAY_ORDER = ["openrouter", "openai", "anthropic", "google", "xai", "deepseek", "moonshotai", "ollama", "local"];
|
|
58
|
+
function byDisplayOrder(a, b) {
|
|
59
|
+
const ai = PROVIDER_DISPLAY_ORDER.indexOf(a);
|
|
60
|
+
const bi = PROVIDER_DISPLAY_ORDER.indexOf(b);
|
|
61
|
+
if (ai === -1 && bi === -1)
|
|
62
|
+
return a.localeCompare(b);
|
|
63
|
+
if (ai === -1 || bi === -1)
|
|
64
|
+
return ai === -1 ? 1 : -1;
|
|
65
|
+
return ai - bi;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Every `llmProvider` name actually usable right now, for the plugin config screen's `llmProvider`
|
|
69
|
+
* dropdown (`plugin.ts`'s `schema()`) - so a provider whose package isn't installed doesn't even show
|
|
70
|
+
* up as pickable. Rather than a second hardcoded provider-to-package table duplicating `resolveModel`'s
|
|
71
|
+
* switch, this reads this plugin's own `optionalDependencies` straight out of `package.json` (the
|
|
72
|
+
* actual, already-curated list of installable provider packages - `require.resolve`-able but never
|
|
73
|
+
* `require()`'d, so a failed optional install can't crash this) and checks each with `isPackageInstalled`.
|
|
74
|
+
* `"openrouter"` is always included (it's a regular `dependency`, see this module's top doc comment);
|
|
75
|
+
* any name added via `registerLlmProvider` is included unconditionally too, since there's no package to
|
|
76
|
+
* check for those.
|
|
77
|
+
*/
|
|
78
|
+
function availableProviders() {
|
|
79
|
+
const packageJson = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(__dirname, "..", "package.json"), "utf-8"));
|
|
80
|
+
const installed = Object.keys(packageJson.optionalDependencies ?? {})
|
|
81
|
+
.filter(isPackageInstalled)
|
|
82
|
+
.map((pkg) => PACKAGE_TO_PROVIDER_ID[pkg] ?? pkg.replace(/^@ai-sdk\//, ""));
|
|
83
|
+
return [...new Set(["openrouter", ...installed, ...(0, llmProviderRegistry_1.listRegisteredProviders)()])].sort(byDisplayOrder);
|
|
84
|
+
}
|
|
30
85
|
/**
|
|
31
86
|
* Resolves `settings` to a Vercel AI SDK model handle. Checks `registerLlmProvider`'s registry
|
|
32
87
|
* (`./llmProviderRegistry.ts`) first, by exact `llmProvider` name, before falling through to this
|
|
@@ -93,19 +148,26 @@ function resolveModel(settings) {
|
|
|
93
148
|
}
|
|
94
149
|
}
|
|
95
150
|
}
|
|
151
|
+
/** Whether `provider` (or its default) is `"openrouter"` - gates the model/usage debug logging `CallLlmResult` exists for for, since that detail is really only interesting there (see `CallLlmResult`'s doc comment). */
|
|
152
|
+
function isOpenRouterProvider(provider) {
|
|
153
|
+
return (provider ?? "openrouter") === "openrouter";
|
|
154
|
+
}
|
|
96
155
|
/**
|
|
97
|
-
* Calls the configured LLM gateway with `prompt
|
|
98
|
-
*
|
|
99
|
-
* one genuinely slow/unreliable network call in this plugin.
|
|
156
|
+
* Calls the configured LLM gateway with `prompt`. Wrapped in its own timeout (`AbortController`) since
|
|
157
|
+
* this is the one genuinely slow/unreliable network call in this plugin.
|
|
100
158
|
*/
|
|
101
159
|
async function callLlm(settings, prompt) {
|
|
102
|
-
const
|
|
160
|
+
const languageModel = resolveModel(settings);
|
|
103
161
|
const { generateText } = loadOptional("ai");
|
|
104
162
|
const controller = new AbortController();
|
|
105
|
-
const timer = setTimeout(() => controller.abort(), (settings.llmTimeoutSeconds ??
|
|
163
|
+
const timer = setTimeout(() => controller.abort(), (settings.llmTimeoutSeconds ?? 60) * 1000);
|
|
106
164
|
try {
|
|
107
|
-
const { text } = await generateText({ model, prompt, abortSignal: controller.signal });
|
|
108
|
-
return
|
|
165
|
+
const { text, response, usage } = await generateText({ model: languageModel, prompt, abortSignal: controller.signal });
|
|
166
|
+
return {
|
|
167
|
+
text,
|
|
168
|
+
model: response.modelId,
|
|
169
|
+
usage: { promptTokens: usage.inputTokens, completionTokens: usage.outputTokens, totalTokens: usage.totalTokens },
|
|
170
|
+
};
|
|
109
171
|
}
|
|
110
172
|
finally {
|
|
111
173
|
clearTimeout(timer);
|
|
@@ -114,13 +176,17 @@ async function callLlm(settings, prompt) {
|
|
|
114
176
|
/**
|
|
115
177
|
* Strips everything outside the first `<svg`...last `</svg>` span - a chat model asked for "only raw
|
|
116
178
|
* SVG markup" still often wraps it in a markdown code fence or adds a sentence of commentary either
|
|
117
|
-
* side, despite the target-guidance fragment telling it not to.
|
|
179
|
+
* side, despite the target-guidance fragment telling it not to. Also swaps any ` ` for the literal
|
|
180
|
+
* U+00A0 character it means - ` ` isn't one of XML's five predefined entities (only
|
|
181
|
+
* `&`/`<`/`>`/`'`/`"` are), so a model used to writing HTML emits it anyway, and the
|
|
182
|
+
* core plugin's `@xmldom/xmldom` DOMParser then fails to parse the document at all over one undefined
|
|
183
|
+
* entity reference. The raw character needs no entity encoding, so this sidesteps the problem entirely.
|
|
118
184
|
*/
|
|
119
185
|
function extractSvg(raw) {
|
|
120
186
|
const start = raw.indexOf("<svg");
|
|
121
187
|
const end = raw.lastIndexOf("</svg>");
|
|
122
188
|
if (start === -1 || end === -1 || end < start) {
|
|
123
|
-
throw new Error(
|
|
189
|
+
throw new Error(`LLM response did not contain a "<svg>...</svg>" document - first 512 chars: ${raw.slice(0, 512)}`);
|
|
124
190
|
}
|
|
125
|
-
return raw.slice(start, end + "</svg>".length);
|
|
191
|
+
return raw.slice(start, end + "</svg>".length).replace(/ /gi, " ");
|
|
126
192
|
}
|
|
@@ -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,17 +6,47 @@ 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 { text, model, usage } = await (0, retry_1.withRetries)(config.llmRetries, () => (0, llmGateway_1.callLlm)(config, TEST_PROMPT));
|
|
25
|
+
if ((0, llmGateway_1.isOpenRouterProvider)(config.llmProvider)) {
|
|
26
|
+
app.debug(`openrouter model=${model ?? "unknown"} usage=${JSON.stringify(usage)}`);
|
|
27
|
+
}
|
|
28
|
+
app.setPluginStatus(`LLM test OK (${label}): ${text.trim().slice(0, 200)}`);
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
app.setPluginError(`LLM test failed (${label}): ${err.message}`);
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
app.savePluginOptions({ ...config, testConnection: false }, (err) => {
|
|
35
|
+
if (err)
|
|
36
|
+
app.debug(`failed to clear testConnection flag: ${err.message}`);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
10
40
|
function createPlugin(app) {
|
|
11
41
|
// Updated by `start()` on every (re)start - `createTemplateProvider`'s functions all read through
|
|
12
42
|
// this closure rather than capturing one fixed config object, so a saved config change takes effect
|
|
13
43
|
// on the *next* repaint without needing the core plugin (which holds the actual repaint schedule) to
|
|
14
44
|
// know or care that anything changed on this side.
|
|
15
45
|
let currentConfig = (0, config_1.defaultConfig)();
|
|
16
|
-
signalk_einklabel_plugin_1.default.registerTemplateProvider((0, templateProvider_1.createTemplateProvider)(() => currentConfig));
|
|
46
|
+
signalk_einklabel_plugin_1.default.registerTemplateProvider((0, templateProvider_1.createTemplateProvider)(app, () => currentConfig));
|
|
17
47
|
return {
|
|
18
48
|
id: "signalk-einklabel-genai-plugin",
|
|
19
|
-
name: "eInk Label GenAI
|
|
49
|
+
name: "eInk Label GenAI Templates",
|
|
20
50
|
description: "Contributes LLM-generated prompts as an alternative to hand-authored SVG templates for signalk-einklabel-plugin",
|
|
21
51
|
schema: () => ({
|
|
22
52
|
type: "object",
|
|
@@ -24,11 +54,12 @@ function createPlugin(app) {
|
|
|
24
54
|
llmProvider: {
|
|
25
55
|
type: "string",
|
|
26
56
|
title: "LLM provider",
|
|
27
|
-
description:
|
|
28
|
-
"
|
|
29
|
-
'
|
|
30
|
-
'OpenAI-compatible endpoint (e.g. LM Studio)
|
|
31
|
-
|
|
57
|
+
description: "Which LLM gateway to call - only providers whose npm package is actually installed are listed here; " +
|
|
58
|
+
"install another one's optional package (e.g. `npm install ai @ai-sdk/anthropic`, see README) and reopen " +
|
|
59
|
+
'this page to add it. "OpenRouter" is bundled and needs no extra install. "Ollama" defaults to a local ' +
|
|
60
|
+
'Ollama server with no further config; "Local" is any other OpenAI-compatible endpoint (e.g. LM Studio) ' +
|
|
61
|
+
'and needs "LLM base URL" below.',
|
|
62
|
+
enum: (0, llmGateway_1.availableProviders)(),
|
|
32
63
|
default: "openrouter",
|
|
33
64
|
},
|
|
34
65
|
llmApiKey: {
|
|
@@ -69,12 +100,23 @@ function createPlugin(app) {
|
|
|
69
100
|
'picker, suffixed "(GenAI)".',
|
|
70
101
|
default: (0, config_1.defaultConfig)().promptsDir,
|
|
71
102
|
},
|
|
103
|
+
testConnection: {
|
|
104
|
+
type: "boolean",
|
|
105
|
+
title: "Test connection now",
|
|
106
|
+
description: "Check this and save to send one test message to the LLM settings above as soon as the plugin (re)starts. " +
|
|
107
|
+
"Watch this plugin's entry on the Plugin Config page for the result (success or the error) - the checkbox " +
|
|
108
|
+
"clears itself afterwards, so it won't refire on the next ordinary restart.",
|
|
109
|
+
default: false,
|
|
110
|
+
},
|
|
72
111
|
},
|
|
73
112
|
}),
|
|
74
113
|
uiSchema: () => ({ llmApiKey: { "ui:widget": "password" } }),
|
|
75
114
|
start(config) {
|
|
76
115
|
currentConfig = { ...(0, config_1.defaultConfig)(), ...config };
|
|
77
116
|
app.debug(`eInk Label GenAI plugin started (llmProvider=${currentConfig.llmProvider ?? "openrouter"})`);
|
|
117
|
+
if (currentConfig.testConnection) {
|
|
118
|
+
runConnectionTest(app, currentConfig).catch((err) => app.debug(`connection test failed unexpectedly: ${err.message}`));
|
|
119
|
+
}
|
|
78
120
|
},
|
|
79
121
|
stop() { },
|
|
80
122
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ServerAPI } from "@signalk/server-api";
|
|
1
2
|
import esl from "@rhizomatics/signalk-einklabel-plugin";
|
|
2
3
|
import { PluginConfig } from "./config";
|
|
3
4
|
/** Appended to every prompt name this plugin offers in the core plugin's "Template" dropdown. */
|
|
@@ -8,4 +9,4 @@ export declare const SUFFIX = "(GenAI)";
|
|
|
8
9
|
* current `promptsDir` fresh each time, rather than once at startup, so adding/editing a `.md` prompt
|
|
9
10
|
* file takes effect without a plugin restart - same as the core plugin's own template directory.
|
|
10
11
|
*/
|
|
11
|
-
export declare function createTemplateProvider(getConfig: () => PluginConfig): esl.TemplateProvider;
|
|
12
|
+
export declare function createTemplateProvider(app: ServerAPI, getConfig: () => PluginConfig): esl.TemplateProvider;
|
package/dist/templateProvider.js
CHANGED
|
@@ -24,7 +24,7 @@ function promptNameFor(templateName) {
|
|
|
24
24
|
* current `promptsDir` fresh each time, rather than once at startup, so adding/editing a `.md` prompt
|
|
25
25
|
* file takes effect without a plugin restart - same as the core plugin's own template directory.
|
|
26
26
|
*/
|
|
27
|
-
function createTemplateProvider(getConfig) {
|
|
27
|
+
function createTemplateProvider(app, getConfig) {
|
|
28
28
|
function promptsDir() {
|
|
29
29
|
return (0, config_1.resolvePromptsDir)(getConfig().promptsDir);
|
|
30
30
|
}
|
|
@@ -61,8 +61,16 @@ function createTemplateProvider(getConfig) {
|
|
|
61
61
|
llmBaseUrl: config.llmBaseUrl,
|
|
62
62
|
llmTimeoutSeconds: config.llmTimeoutSeconds,
|
|
63
63
|
};
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
// `extractSvg` runs inside the retry loop, not after it - an unparseable response (the model
|
|
65
|
+
// wrapped its SVG oddly, got cut off, whatever) gets a fresh attempt the same way a network/
|
|
66
|
+
// timeout failure does, rather than failing the whole render on one bad completion.
|
|
67
|
+
const { text: svg, model, usage, } = await (0, retry_1.withRetries)(config.llmRetries, async () => {
|
|
68
|
+
const result = await (0, llmGateway_1.callLlm)(llmSettings, prompt);
|
|
69
|
+
return { ...result, text: (0, llmGateway_1.extractSvg)(result.text) };
|
|
70
|
+
});
|
|
71
|
+
if ((0, llmGateway_1.isOpenRouterProvider)(config.llmProvider)) {
|
|
72
|
+
app.debug(`openrouter model=${model ?? "unknown"} usage=${JSON.stringify(usage)}`);
|
|
73
|
+
}
|
|
66
74
|
const renderer = new signalk_einklabel_plugin_1.default.Renderer();
|
|
67
75
|
// `esl.Renderer.render()` reads its template from a file path, not a string - the LLM's SVG is
|
|
68
76
|
// written out as one so the exact same rasterization path a bundled/local template gets is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rhizomatics/signalk-einklabel-genai-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
@@ -79,7 +79,7 @@
|
|
|
79
79
|
"node": ">=18"
|
|
80
80
|
},
|
|
81
81
|
"signalk": {
|
|
82
|
-
"displayName": "eInk Label GenAI
|
|
82
|
+
"displayName": "eInk Label GenAI Templates",
|
|
83
83
|
"appIcon": "docs/assets/icons/icon_tmp.png",
|
|
84
84
|
"screenshots": [
|
|
85
85
|
"docs/assets/screenshots/example_weather_openai.png"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
Generate an SVG image for the result suitable for a {source=label,path=manufacturer,default=} {source=label,path=label,default=} eInk Electronic Shelf Label display with pixel height {source=label,path=height} and width {source=label,path=width}. Available colours are {source=label,path=colours,format=csv}. Fonts available are {source=label,path=fonts,format=csv}.
|
|
1
|
+
Generate an SVG image for the result suitable for a {source=label,path=manufacturer,default=} {source=label,path=label,default=} eInk Electronic Shelf Label display with pixel height {source=label,path=height} and width {source=label,path=width}. Available colours are {source=label,path=colours,format=csv}. Fonts available are {source=label,path=fonts,format=csv}, use text no smaller than 9pt.
|
|
2
2
|
|
|
3
|
-
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.
|
|
3
|
+
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. Always use high contrast for text. Include in minimal size font the source of the information and generation timestamp.
|
|
4
4
|
|
|
5
5
|
{source=label,path=description}
|