@rhizomatics/signalk-einklabel-genai-plugin 0.1.2 → 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 +21 -6
- package/README.md +29 -11
- package/dist/cliExtension.js +8 -3
- package/dist/config.js +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/llmGateway.d.ts +28 -5
- package/dist/llmGateway.js +22 -10
- package/dist/llmProviderRegistry.d.ts +4 -3
- package/dist/llmProviderRegistry.js +4 -3
- package/dist/plugin.js +14 -7
- package/dist/templateProvider.d.ts +13 -2
- package/dist/templateProvider.js +37 -18
- package/package.json +9 -8
- package/prompts/.assets/target_guidance.md +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,16 +1,31 @@
|
|
|
1
|
-
# [
|
|
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
|
+
|
|
12
|
+
# [0.3.0]
|
|
13
|
+
|
|
14
|
+
- Update timeout to 60 seconds to handle slower local or free models
|
|
15
|
+
- Better handle retry for responses that don't have cleanly extractable SVG
|
|
16
|
+
- OpenRouter model and token usage now logged
|
|
17
|
+
- Strip HTML punctuation that breaks XML before converting SVG
|
|
18
|
+
|
|
19
|
+
# [0.2.0]
|
|
2
20
|
|
|
3
21
|
- AI providers drop-down now restricted to available AI SDK plugins
|
|
4
22
|
- Test option added to validate AI configuration, keys and connectivity
|
|
5
23
|
|
|
6
|
-
# [
|
|
24
|
+
# [0.1.1]
|
|
7
25
|
|
|
8
26
|
- Packaging fixes
|
|
9
|
-
- Simplified adding GenAI support to `esl-cli`
|
|
10
|
-
- Added `openrouter` as the default built-in AI provider
|
|
11
|
-
- Other providers are available by `npm install`
|
|
12
27
|
|
|
13
|
-
# [1.0
|
|
28
|
+
# [0.1.0]
|
|
14
29
|
|
|
15
30
|
- Initial release with support for major cloud GenAI services and local LLMs
|
|
16
31
|
- As an alternative to a template, an image can be generated by an LLM based on customized prompt
|
package/README.md
CHANGED
|
@@ -1,28 +1,46 @@
|
|
|
1
|
-
# eInk Label GenAI
|
|
1
|
+
# eInk Label GenAI Templates
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://boat-tech-directory.rhizomatics.org.uk) [](https://github.com/rhizomatics/hq)
|
|
4
|
+
|
|
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.
|
|
6
8
|
|
|
9
|
+

|
|
10
|
+
|
|
7
11
|
## Working Example
|
|
8
12
|
|
|
9
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.
|
|
10
14
|
|
|
11
|
-
###
|
|
15
|
+
### Expanded Example
|
|
12
16
|
|
|
13
|
-
|
|
17
|
+
#### Forecast
|
|
14
18
|
|
|
15
|
-
|
|
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
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
#### Target Guidance
|
|
22
|
+
|
|
23
|
+
The following has been automatically added to the end of the standard prompt, with information specific to which label is being targetted.
|
|
18
24
|
|
|
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
25
|
> 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
26
|
>
|
|
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.
|
|
27
|
+
> 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.
|
|
28
|
+
|
|
29
|
+
### CAUTION
|
|
24
30
|
|
|
25
|
-
|
|
31
|
+
Be careful with GenAI generated content, it will often be very good but may be prone to hallucinations or omissions.
|
|
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.
|
|
26
44
|
|
|
27
45
|
## Installation
|
|
28
46
|
|
|
@@ -111,4 +129,4 @@ import genai from "@rhizomatics/signalk-einklabel-genai-plugin";
|
|
|
111
129
|
genai.registerLlmProvider("my-provider", (settings) => myOwnModelFactory(settings));
|
|
112
130
|
```
|
|
113
131
|
|
|
114
|
-
|
|
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/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.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/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
|
-
*
|
|
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 (
|
|
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
|
-
*
|
|
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 (
|
|
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;
|
package/dist/llmGateway.d.ts
CHANGED
|
@@ -29,16 +29,39 @@ export declare function isPackageInstalled(moduleName: string): boolean;
|
|
|
29
29
|
* check for those.
|
|
30
30
|
*/
|
|
31
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
|
+
}
|
|
32
38
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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.
|
|
36
44
|
*/
|
|
37
|
-
export
|
|
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>;
|
|
38
57
|
/**
|
|
39
58
|
* Strips everything outside the first `<svg`...last `</svg>` span - a chat model asked for "only raw
|
|
40
59
|
* SVG markup" still often wraps it in a markdown code fence or adds a sentence of commentary either
|
|
41
|
-
* 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.
|
|
42
65
|
*/
|
|
43
66
|
export declare function extractSvg(raw: string): string;
|
|
44
67
|
export type { PluginConfig };
|
package/dist/llmGateway.js
CHANGED
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.loadOptional = loadOptional;
|
|
4
4
|
exports.isPackageInstalled = isPackageInstalled;
|
|
5
5
|
exports.availableProviders = availableProviders;
|
|
6
|
+
exports.isOpenRouterProvider = isOpenRouterProvider;
|
|
6
7
|
exports.callLlm = callLlm;
|
|
7
8
|
exports.extractSvg = extractSvg;
|
|
8
9
|
const fs_1 = require("fs");
|
|
@@ -147,19 +148,26 @@ function resolveModel(settings) {
|
|
|
147
148
|
}
|
|
148
149
|
}
|
|
149
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
|
+
}
|
|
150
155
|
/**
|
|
151
|
-
* Calls the configured LLM gateway with `prompt
|
|
152
|
-
*
|
|
153
|
-
* 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.
|
|
154
158
|
*/
|
|
155
159
|
async function callLlm(settings, prompt) {
|
|
156
|
-
const
|
|
160
|
+
const languageModel = resolveModel(settings);
|
|
157
161
|
const { generateText } = loadOptional("ai");
|
|
158
162
|
const controller = new AbortController();
|
|
159
|
-
const timer = setTimeout(() => controller.abort(), (settings.llmTimeoutSeconds ??
|
|
163
|
+
const timer = setTimeout(() => controller.abort(), (settings.llmTimeoutSeconds ?? 60) * 1000);
|
|
160
164
|
try {
|
|
161
|
-
const { text } = await generateText({ model, prompt, abortSignal: controller.signal });
|
|
162
|
-
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
|
+
};
|
|
163
171
|
}
|
|
164
172
|
finally {
|
|
165
173
|
clearTimeout(timer);
|
|
@@ -168,13 +176,17 @@ async function callLlm(settings, prompt) {
|
|
|
168
176
|
/**
|
|
169
177
|
* Strips everything outside the first `<svg`...last `</svg>` span - a chat model asked for "only raw
|
|
170
178
|
* SVG markup" still often wraps it in a markdown code fence or adds a sentence of commentary either
|
|
171
|
-
* 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.
|
|
172
184
|
*/
|
|
173
185
|
function extractSvg(raw) {
|
|
174
186
|
const start = raw.indexOf("<svg");
|
|
175
187
|
const end = raw.lastIndexOf("</svg>");
|
|
176
188
|
if (start === -1 || end === -1 || end < start) {
|
|
177
|
-
throw new Error(
|
|
189
|
+
throw new Error(`LLM response did not contain a "<svg>...</svg>" document - first 512 chars: ${raw.slice(0, 512)}`);
|
|
178
190
|
}
|
|
179
|
-
return raw.slice(start, end + "</svg>".length);
|
|
191
|
+
return raw.slice(start, end + "</svg>".length).replace(/ /gi, " ");
|
|
180
192
|
}
|
|
@@ -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
|
|
22
|
-
* `
|
|
23
|
-
* resolves
|
|
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
|
|
18
|
-
* `
|
|
19
|
-
* resolves
|
|
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
|
@@ -21,8 +21,11 @@ const TEST_PROMPT = 'Reply with exactly the word "OK", nothing else.';
|
|
|
21
21
|
async function runConnectionTest(app, config) {
|
|
22
22
|
const label = `${config.llmProvider ?? "openrouter"}/${config.llmModel ?? "(no model configured)"}`;
|
|
23
23
|
try {
|
|
24
|
-
const
|
|
25
|
-
|
|
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)}`);
|
|
26
29
|
}
|
|
27
30
|
catch (err) {
|
|
28
31
|
app.setPluginError(`LLM test failed (${label}): ${err.message}`);
|
|
@@ -38,12 +41,14 @@ function createPlugin(app) {
|
|
|
38
41
|
// Updated by `start()` on every (re)start - `createTemplateProvider`'s functions all read through
|
|
39
42
|
// this closure rather than capturing one fixed config object, so a saved config change takes effect
|
|
40
43
|
// on the *next* repaint without needing the core plugin (which holds the actual repaint schedule) to
|
|
41
|
-
// know or care that anything changed on this side.
|
|
42
|
-
|
|
43
|
-
|
|
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;
|
|
48
|
+
signalk_einklabel_plugin_1.default.registerTemplateProvider((0, templateProvider_1.createTemplateProvider)(app, () => currentConfig));
|
|
44
49
|
return {
|
|
45
50
|
id: "signalk-einklabel-genai-plugin",
|
|
46
|
-
name: "eInk Label GenAI
|
|
51
|
+
name: "eInk Label GenAI Templates",
|
|
47
52
|
description: "Contributes LLM-generated prompts as an alternative to hand-authored SVG templates for signalk-einklabel-plugin",
|
|
48
53
|
schema: () => ({
|
|
49
54
|
type: "object",
|
|
@@ -115,6 +120,8 @@ function createPlugin(app) {
|
|
|
115
120
|
runConnectionTest(app, currentConfig).catch((err) => app.debug(`connection test failed unexpectedly: ${err.message}`));
|
|
116
121
|
}
|
|
117
122
|
},
|
|
118
|
-
stop() {
|
|
123
|
+
stop() {
|
|
124
|
+
currentConfig = undefined;
|
|
125
|
+
},
|
|
119
126
|
};
|
|
120
127
|
}
|
|
@@ -1,11 +1,22 @@
|
|
|
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. */
|
|
4
5
|
export declare const SUFFIX = "(GenAI)";
|
|
5
6
|
/**
|
|
6
7
|
* The `esl.TemplateProvider` this plugin registers with the core plugin (`esl.registerTemplateProvider`,
|
|
7
|
-
* called
|
|
8
|
+
* called when `./plugin.ts`'s `createPlugin` constructs the plugin, whether or not it is enabled). `listTemplates`/`describeBindings`/`render` all resolve the
|
|
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
|
-
|
|
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;
|
package/dist/templateProvider.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*/
|
|
27
|
-
function createTemplateProvider(getConfig) {
|
|
22
|
+
/** `getConfig` returns `undefined` while the plugin isn't running: templates are still listed, but `render()` rejects without calling the LLM. */
|
|
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(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(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,
|
|
@@ -61,17 +65,32 @@ function createTemplateProvider(getConfig) {
|
|
|
61
65
|
llmBaseUrl: config.llmBaseUrl,
|
|
62
66
|
llmTimeoutSeconds: config.llmTimeoutSeconds,
|
|
63
67
|
};
|
|
64
|
-
|
|
65
|
-
|
|
68
|
+
// `extractSvg` runs inside the retry loop, not after it - an unparseable response (the model
|
|
69
|
+
// wrapped its SVG oddly, got cut off, whatever) gets a fresh attempt the same way a network/
|
|
70
|
+
// timeout failure does, rather than failing the whole render on one bad completion.
|
|
71
|
+
const { text: svg, model, usage, } = await (0, retry_1.withRetries)(config.llmRetries, async () => {
|
|
72
|
+
const result = await (0, llmGateway_1.callLlm)(llmSettings, prompt);
|
|
73
|
+
return { ...result, text: (0, llmGateway_1.extractSvg)(result.text) };
|
|
74
|
+
});
|
|
75
|
+
if ((0, llmGateway_1.isOpenRouterProvider)(config.llmProvider)) {
|
|
76
|
+
app.debug(`openrouter model=${model ?? "unknown"} usage=${JSON.stringify(usage)}`);
|
|
77
|
+
}
|
|
66
78
|
const renderer = new signalk_einklabel_plugin_1.default.Renderer();
|
|
67
79
|
// `esl.Renderer.render()` reads its template from a file path, not a string - the LLM's SVG is
|
|
68
80
|
// written out as one so the exact same rasterization path a bundled/local template gets is
|
|
69
81
|
// reused unchanged, rather than needing its own copy of that logic.
|
|
70
82
|
const cacheDir = (0, path_1.join)(promptsDir(), ".genai-cache");
|
|
71
83
|
(0, fs_1.mkdirSync)(cacheDir, { recursive: true });
|
|
72
|
-
|
|
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`);
|
|
73
87
|
(0, fs_1.writeFileSync)(cachePath, svg);
|
|
74
|
-
|
|
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
|
+
}
|
|
75
94
|
},
|
|
76
95
|
};
|
|
77
96
|
}
|
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.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": "^
|
|
61
|
-
"commander": "^
|
|
62
|
-
"oxfmt": "^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,14 +72,15 @@
|
|
|
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": "^
|
|
75
|
+
"@ai-sdk/xai": "^5.0.4",
|
|
76
76
|
"ai-sdk-ollama": "^4.1.0"
|
|
77
77
|
},
|
|
78
78
|
"engines": {
|
|
79
|
-
"node": ">=
|
|
79
|
+
"node": ">=22",
|
|
80
|
+
"signalk-server": ">=2.25.0"
|
|
80
81
|
},
|
|
81
82
|
"signalk": {
|
|
82
|
-
"displayName": "eInk Label GenAI
|
|
83
|
+
"displayName": "eInk Label GenAI Templates",
|
|
83
84
|
"appIcon": "docs/assets/icons/icon_tmp.png",
|
|
84
85
|
"screenshots": [
|
|
85
86
|
"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}
|