@rhizomatics/signalk-einklabel-genai-plugin 0.1.2 → 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 CHANGED
@@ -1,16 +1,20 @@
1
- # [1.0.2]
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]
2
9
 
3
10
  - AI providers drop-down now restricted to available AI SDK plugins
4
11
  - Test option added to validate AI configuration, keys and connectivity
5
12
 
6
- # [1.0.1]
13
+ # [0.1.1]
7
14
 
8
15
  - 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
16
 
13
- # [1.0.0]
17
+ # [0.1.0]
14
18
 
15
19
  - Initial release with support for major cloud GenAI services and local LLMs
16
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 Rendering
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 a device's content from an LLM prompt, as an alternative to a hand-authored SVG template.
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
+ ![Example Forecast](docs/assets/screenshots/example_weather_openai.png)
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,13 +24,21 @@ All prompts also get a generated appendix to the prompt that gives details about
16
24
 
17
25
  ### Full Example
18
26
 
27
+ #### Forecast
28
+
19
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
20
- >
30
+
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
+
21
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.
22
36
  >
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.
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
24
40
 
25
- CAUTION: Be careful with GenAI generated content, it will often be very good but may be prone to hallucinations or omissions.
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
 
@@ -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", "30")
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 raw = await (0, retry_1.withRetries)(Number(opts.llmRetries), () => (0, llmGateway_1.callLlm)(llmSettings, prompt));
122
- const svg = (0, llmGateway_1.extractSvg)(raw);
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: 30,
24
+ llmTimeoutSeconds: 60,
25
25
  llmRetries: 2,
26
26
  promptsDir: "",
27
27
  };
@@ -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
- * Calls the configured LLM gateway with `prompt`, returning its raw text response - not yet extracted/
34
- * validated as SVG, see `extractSvg`. Wrapped in its own timeout (`AbortController`) since this is the
35
- * one genuinely slow/unreliable network call in this plugin.
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 declare function callLlm(settings: LlmSettings, prompt: string): Promise<string>;
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 `&nbsp;` for the literal
61
+ * U+00A0 character it means - `&nbsp;` isn't one of XML's five predefined entities (only
62
+ * `&amp;`/`&lt;`/`&gt;`/`&apos;`/`&quot;` 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 };
@@ -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`, returning its raw text response - not yet extracted/
152
- * validated as SVG, see `extractSvg`. Wrapped in its own timeout (`AbortController`) since this is the
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 model = resolveModel(settings);
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 ?? 30) * 1000);
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 text;
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 `&nbsp;` for the literal
180
+ * U+00A0 character it means - `&nbsp;` isn't one of XML's five predefined entities (only
181
+ * `&amp;`/`&lt;`/`&gt;`/`&apos;`/`&quot;` 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('LLM response did not contain a "<svg>...</svg>" document');
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(/&nbsp;/gi, " ");
180
192
  }
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 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)}`);
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}`);
@@ -40,10 +43,10 @@ function createPlugin(app) {
40
43
  // on the *next* repaint without needing the core plugin (which holds the actual repaint schedule) to
41
44
  // know or care that anything changed on this side.
42
45
  let currentConfig = (0, config_1.defaultConfig)();
43
- signalk_einklabel_plugin_1.default.registerTemplateProvider((0, templateProvider_1.createTemplateProvider)(() => currentConfig));
46
+ signalk_einklabel_plugin_1.default.registerTemplateProvider((0, templateProvider_1.createTemplateProvider)(app, () => currentConfig));
44
47
  return {
45
48
  id: "signalk-einklabel-genai-plugin",
46
- name: "eInk Label GenAI Rendering",
49
+ name: "eInk Label GenAI Templates",
47
50
  description: "Contributes LLM-generated prompts as an alternative to hand-authored SVG templates for signalk-einklabel-plugin",
48
51
  schema: () => ({
49
52
  type: "object",
@@ -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;
@@ -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
- const raw = await (0, retry_1.withRetries)(config.llmRetries, () => (0, llmGateway_1.callLlm)(llmSettings, prompt));
65
- const svg = (0, llmGateway_1.extractSvg)(raw);
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.1.2",
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 Rendering",
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}