@rhizomatics/signalk-einklabel-genai-plugin 0.1.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/LICENSE +201 -0
- package/README.md +85 -0
- package/dist/cliExtension.d.ts +1 -0
- package/dist/cliExtension.js +124 -0
- package/dist/config.d.ts +50 -0
- package/dist/config.js +58 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +23 -0
- package/dist/llmGateway.d.ts +30 -0
- package/dist/llmGateway.js +122 -0
- package/dist/llmProviderRegistry.d.ts +25 -0
- package/dist/llmProviderRegistry.js +24 -0
- package/dist/plugin.d.ts +2 -0
- package/dist/plugin.js +80 -0
- package/dist/retry.d.ts +6 -0
- package/dist/retry.js +20 -0
- package/dist/templateProvider.d.ts +11 -0
- package/dist/templateProvider.js +77 -0
- package/package.json +74 -0
- package/prompts/.assets/target_guidance.md +5 -0
- package/prompts/forecast.md +1 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
10
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
11
|
+
|
|
12
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
13
|
+
the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
16
|
+
other entities that control, are controlled by, or are under common
|
|
17
|
+
control with that entity. For the purposes of this definition,
|
|
18
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
19
|
+
direction or management of such entity, whether by contract or
|
|
20
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
21
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
22
|
+
|
|
23
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
24
|
+
exercising permissions granted by this License.
|
|
25
|
+
|
|
26
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
27
|
+
including but not limited to software source code, documentation
|
|
28
|
+
source, and configuration files.
|
|
29
|
+
|
|
30
|
+
"Object" form shall mean any form resulting from mechanical
|
|
31
|
+
transformation or translation of a Source form, including but
|
|
32
|
+
not limited to compiled object code, generated documentation,
|
|
33
|
+
and conversions to other media types.
|
|
34
|
+
|
|
35
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
36
|
+
Object form, made available under the License, as indicated by a
|
|
37
|
+
copyright notice that is included in or attached to the work
|
|
38
|
+
(an example is provided in the Appendix below).
|
|
39
|
+
|
|
40
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
41
|
+
form, that is based on (or derived from) the Work and for which the
|
|
42
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
43
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
44
|
+
of this License, Derivative Works shall not include works that remain
|
|
45
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
46
|
+
the Work and Derivative Works thereof.
|
|
47
|
+
|
|
48
|
+
"Contribution" shall mean any work of authorship, including
|
|
49
|
+
the original version of the Work and any modifications or additions
|
|
50
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
51
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
52
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
53
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
54
|
+
means any form of electronic, verbal, or written communication sent
|
|
55
|
+
to the Licensor or its representatives, including but not limited to
|
|
56
|
+
communication on electronic mailing lists, source code control systems,
|
|
57
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
58
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
59
|
+
excluding communication that is conspicuously marked or otherwise
|
|
60
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
61
|
+
|
|
62
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
63
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
64
|
+
subsequently incorporated within the Work.
|
|
65
|
+
|
|
66
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
67
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
68
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
69
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
70
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
71
|
+
Work and such Derivative Works in Source or Object form.
|
|
72
|
+
|
|
73
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
74
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
75
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
76
|
+
(except as stated in this section) patent license to make, have made,
|
|
77
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
78
|
+
where such license applies only to those patent claims licensable
|
|
79
|
+
by such Contributor that are necessarily infringed by their
|
|
80
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
81
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
82
|
+
institute patent litigation against any entity (including a
|
|
83
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
84
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
85
|
+
or contributory patent infringement, then any patent licenses
|
|
86
|
+
granted to You under this License for that Work shall terminate
|
|
87
|
+
as of the date such litigation is filed.
|
|
88
|
+
|
|
89
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
90
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
91
|
+
modifications, and in Source or Object form, provided that You
|
|
92
|
+
meet the following conditions:
|
|
93
|
+
|
|
94
|
+
(a) You must give any other recipients of the Work or
|
|
95
|
+
Derivative Works a copy of this License; and
|
|
96
|
+
|
|
97
|
+
(b) You must cause any modified files to carry prominent notices
|
|
98
|
+
stating that You changed the files; and
|
|
99
|
+
|
|
100
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
101
|
+
that You distribute, all copyright, patent, trademark, and
|
|
102
|
+
attribution notices from the Source form of the Work,
|
|
103
|
+
excluding those notices that do not pertain to any part of
|
|
104
|
+
the Derivative Works; and
|
|
105
|
+
|
|
106
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
107
|
+
distribution, then any Derivative Works that You distribute must
|
|
108
|
+
include a readable copy of the attribution notices contained
|
|
109
|
+
within such NOTICE file, excluding those notices that do not
|
|
110
|
+
pertain to any part of the Derivative Works, in at least one
|
|
111
|
+
of the following places: within a NOTICE text file distributed
|
|
112
|
+
as part of the Derivative Works; within the Source form or
|
|
113
|
+
documentation, if provided along with the Derivative Works; or,
|
|
114
|
+
within a display generated by the Derivative Works, if and
|
|
115
|
+
wherever such third-party notices normally appear. The contents
|
|
116
|
+
of the NOTICE file are for informational purposes only and
|
|
117
|
+
do not modify the License. You may add Your own attribution
|
|
118
|
+
notices within Derivative Works that You distribute, alongside
|
|
119
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
120
|
+
that such additional attribution notices cannot be construed
|
|
121
|
+
as modifying the License.
|
|
122
|
+
|
|
123
|
+
You may add Your own copyright statement to Your modifications and
|
|
124
|
+
may provide additional or different license terms and conditions
|
|
125
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
126
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
127
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
128
|
+
the conditions stated in this License.
|
|
129
|
+
|
|
130
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
131
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
132
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
133
|
+
this License, without any additional terms or conditions.
|
|
134
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
135
|
+
the terms of any separate license agreement you may have executed
|
|
136
|
+
with Licensor regarding such Contributions.
|
|
137
|
+
|
|
138
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
139
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
140
|
+
except as required for reasonable and customary use in describing the
|
|
141
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
142
|
+
|
|
143
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
144
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
145
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
146
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
147
|
+
implied, including, without limitation, any warranties or conditions
|
|
148
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
149
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
150
|
+
appropriateness of using or redistributing the Work and assume any
|
|
151
|
+
risks associated with Your exercise of permissions under this License.
|
|
152
|
+
|
|
153
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
154
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
155
|
+
unless required by applicable law (such as deliberate and grossly
|
|
156
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
157
|
+
liable to You for damages, including any direct, indirect, special,
|
|
158
|
+
incidental, or consequential damages of any character arising as a
|
|
159
|
+
result of this License or out of the use or inability to use the
|
|
160
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
161
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
162
|
+
other commercial damages or losses), even if such Contributor
|
|
163
|
+
has been advised of the possibility of such damages.
|
|
164
|
+
|
|
165
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
166
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
167
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
168
|
+
or other liability obligations and/or rights consistent with this
|
|
169
|
+
License. However, in accepting such obligations, You may act only
|
|
170
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
171
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
172
|
+
defend, and hold each Contributor harmless for any liability
|
|
173
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
174
|
+
of your accepting any such warranty or additional liability.
|
|
175
|
+
|
|
176
|
+
END OF TERMS AND CONDITIONS
|
|
177
|
+
|
|
178
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
179
|
+
|
|
180
|
+
To apply the Apache License to your work, attach the following
|
|
181
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
182
|
+
replaced with your own identifying information. (Don't include
|
|
183
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
184
|
+
comment syntax for the file format. We also recommend that a
|
|
185
|
+
file or class name and description of purpose be included on the
|
|
186
|
+
same "printed page" as the copyright notice for easier
|
|
187
|
+
identification within third-party archives.
|
|
188
|
+
|
|
189
|
+
Copyright [yyyy] [name of copyright owner]
|
|
190
|
+
|
|
191
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
192
|
+
you may not use this file except in compliance with the License.
|
|
193
|
+
You may obtain a copy of the License at
|
|
194
|
+
|
|
195
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
196
|
+
|
|
197
|
+
Unless required by applicable law or agreed to in writing, software
|
|
198
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
199
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
200
|
+
See the License for the specific language governing permissions and
|
|
201
|
+
limitations under the License.
|
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# eInk Label GenAI Rendering
|
|
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.
|
|
4
|
+
|
|
5
|
+
This is deliberately a separate plugin, not a feature of the core one: the core plugin has nothing to do with any LLM SDK or API key, so installing it never pulls in GenAI dependencies unless you also explicitly install and enable this one - useful for anyone on a small/constrained install, or who'd simply rather not have any GenAI code in their install at all.
|
|
6
|
+
|
|
7
|
+
## How it fits together
|
|
8
|
+
|
|
9
|
+
This plugin registers a `TemplateProvider` with the core plugin (the same extension mechanism a new vendor's hardware driver uses - see the core plugin's README, "Extending"). Once both plugins are installed and enabled:
|
|
10
|
+
|
|
11
|
+
- Its prompts show up as ordinary entries in the core plugin's own "Template" picker, suffixed `(GenAI)` - pick one exactly like you'd pick a `.svg` file. No separate "render mode" setting.
|
|
12
|
+
- All LLM provider/model/API-key configuration lives in **this** plugin's own config screen - the core plugin knows nothing about any of that.
|
|
13
|
+
- If the LLM call fails (network/API error, exhausted retries) or its response isn't a renderable SVG, the **core** plugin pushes its own generic fallback-warning template instead of leaving the previous, possibly now-wrong, content on screen - the same safety net a broken hand-authored template gets. The next scheduled repaint retries automatically.
|
|
14
|
+
|
|
15
|
+
Since each repaint may call a (usually paid) LLM API, a GenAI-backed device should use Repaint Trigger `interval`, not `subscription`.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
Install both plugins from the SignalK AppStore (or `npm install @rhizomatics/signalk-einklabel-plugin @rhizomatics/signalk-einklabel-genai-plugin`), then install whichever LLM provider package(s) you'll actually use - these are `optionalDependencies`, not installed automatically, since a user only ever needs one:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install ai @ai-sdk/openai # llmProvider: "openai"
|
|
23
|
+
npm install ai @ai-sdk/anthropic # llmProvider: "anthropic"
|
|
24
|
+
npm install ai @ai-sdk/google # llmProvider: "google"
|
|
25
|
+
npm install ai @ai-sdk/xai # llmProvider: "xai"
|
|
26
|
+
npm install ai @ai-sdk/deepseek # llmProvider: "deepseek"
|
|
27
|
+
npm install ai @ai-sdk/moonshotai # llmProvider: "moonshotai"
|
|
28
|
+
npm install ai ai-sdk-ollama # llmProvider: "ollama"
|
|
29
|
+
npm install ai @ai-sdk/openai-compatible # llmProvider: "local" (LM Studio, vLLM, ...)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
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`.
|
|
33
|
+
|
|
34
|
+
## Configuration
|
|
35
|
+
|
|
36
|
+
- `llmProvider` - `openai`, `anthropic`, `google`, `xai`, `deepseek`, `moonshotai`, `ollama`, or `local` (any other OpenAI-compatible server)
|
|
37
|
+
- `llmApiKey` - not needed for `ollama`/`local` unless your server itself checks one
|
|
38
|
+
- `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
|
|
39
|
+
- `llmBaseUrl` - required for `local`; optional for `ollama` (defaults to `http://localhost:11434/v1`)
|
|
40
|
+
- `llmTimeoutSeconds`/`llmRetries` - how long to wait, and how many attempts, before giving up (the core plugin then shows its fallback warning)
|
|
41
|
+
- `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)
|
|
42
|
+
|
|
43
|
+
### Adding another LLM provider
|
|
44
|
+
|
|
45
|
+
The provider list above is `resolveModel`'s (`src/llmGateway.ts`) built-in switch, but you're not limited to it: call `registerLlmProvider(name, factory)` (exported from this plugin, mirroring the core plugin's own extension points) from your own SignalK plugin's `start()` to add support for a provider this plugin doesn't bundle, without forking it. A registered name takes priority over the built-in switch, so it can even replace one of the names above.
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import genai from "@rhizomatics/signalk-einklabel-genai-plugin";
|
|
49
|
+
genai.registerLlmProvider("my-provider", (settings) => myOwnModelFactory(settings));
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Declare this package as a regular npm `dependency` (**not** a `peerDependency` - see the core plugin's own README for why) plus `"signalk": { "requires": ["@rhizomatics/signalk-einklabel-genai-plugin"] }` in your extension's `package.json`.
|
|
53
|
+
|
|
54
|
+
## Writing Prompts
|
|
55
|
+
|
|
56
|
+
A prompt (`.md` file) is just prose, with the same `{...}` binding syntax as a template's `<desc>` in the core plugin (see its README, "Template Source Specification") - a bare path like `{design.length.overall}`, or the full `source=...,path=...,format=...` grammar, resolve exactly the way they would in a template. One more source is available here, `source=label`, giving facts about the physical label the LLM needs to design for:
|
|
57
|
+
|
|
58
|
+
- `path=width`/`path=height` - exact pixel size the returned SVG must match
|
|
59
|
+
- `path=colours` - the label's colour palette (add `format=csv` for a plain comma-separated list)
|
|
60
|
+
- `path=fonts` - the three font-family keywords the renderer can actually display (also supports `format=csv`)
|
|
61
|
+
- `path=description` - the device's own configured Location/description field
|
|
62
|
+
- `path=manufacturer`/`path=label` - the panel's real-world brand and its own size label (e.g. `3.7"`)
|
|
63
|
+
- `path=position` - the vessel's current GPS position, pre-formatted as decimal degrees with hemisphere letters
|
|
64
|
+
|
|
65
|
+
A placeholder that doesn't resolve to any value becomes `???` in the prompt text - a visible gap the LLM can reason around, rather than a silent, misleading blank. Add `default=<value>` to any binding to use that value instead when the data is missing.
|
|
66
|
+
|
|
67
|
+
Every prompt also has a bundled `.assets/target_guidance.md` fragment appended automatically, telling the model the exact size/colour/font constraints and that it must respond with nothing but a single self-contained `<svg>...</svg>` document. Override it the same way you'd override any bundled prompt - put your own `.assets/target_guidance.md` in your `promptsDir`.
|
|
68
|
+
|
|
69
|
+
## Testing Prompts from the CLI
|
|
70
|
+
|
|
71
|
+
This plugin contributes `prompt`/`generate` subcommands to the core plugin's `esl-cli`, loaded via `-r`:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
esl-cli -r node_modules/@rhizomatics/signalk-einklabel-genai-plugin/dist/cliExtension.js prompt forecast -e examples
|
|
75
|
+
esl-cli -r node_modules/@rhizomatics/signalk-einklabel-genai-plugin/dist/cliExtension.js generate forecast -e examples -o forecast.png --llm-provider ollama --llm-model llama3.2
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- `prompt [name]` - resolve a prompt's placeholders against a live SignalK server (or example data) and print the exact text an LLM would be sent, with no LLM call
|
|
79
|
+
- `generate [name]` - call the configured LLM and write the rendered SVG's PNG, optionally saving the raw SVG too with `--save-svg`
|
|
80
|
+
|
|
81
|
+
The optional `[name]` argument names a prompt the same way the config UI's picker does - a full path also works. Use `--help` on either for the full option list.
|
|
82
|
+
|
|
83
|
+
## Publishing note
|
|
84
|
+
|
|
85
|
+
This plugin's own `package.json` currently points its `@rhizomatics/signalk-einklabel-plugin` dependency at a local `file:` path for development against an unpublished core plugin - update it to a real semver range once both are published.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
/**
|
|
7
|
+
* Contributes `prompt`/`generate` subcommands to the core plugin's `esl-cli`, for testing prompts
|
|
8
|
+
* without a device or a live SignalK server. Load via `esl-cli -r <path-to-this-file's-dist> prompt ...`
|
|
9
|
+
* - the core CLI pre-scans `-r`/`--require` and loads those modules *before* building its own command
|
|
10
|
+
* tree (see its own README/source for why), which is what makes adding a brand-new subcommand from here
|
|
11
|
+
* possible at all, unlike a module that only registers a vendor driver/template provider.
|
|
12
|
+
*
|
|
13
|
+
* A plain top-level `require()` (rather than a static `import`) of the core CLI module is deliberate -
|
|
14
|
+
* it makes the load-time side effect (adding commands to an already-running CLI process) obvious to a
|
|
15
|
+
* reader, and avoids a static import accidentally triggering a *second*, unrelated full execution of
|
|
16
|
+
* that module (including its own `parseAsync(process.argv)`) if this file were ever `require()`'d
|
|
17
|
+
* outside of `esl-cli -r`'s intended flow - within that flow, the core CLI module is already
|
|
18
|
+
* mid-execution and cached, so this just reads back the shared `program`/helpers it already exported.
|
|
19
|
+
*/
|
|
20
|
+
const fs_1 = require("fs");
|
|
21
|
+
const promises_1 = require("fs/promises");
|
|
22
|
+
const os_1 = require("os");
|
|
23
|
+
const path_1 = require("path");
|
|
24
|
+
const signalk_einklabel_plugin_1 = __importDefault(require("@rhizomatics/signalk-einklabel-plugin"));
|
|
25
|
+
const config_1 = require("./config");
|
|
26
|
+
const llmGateway_1 = require("./llmGateway");
|
|
27
|
+
const retry_1 = require("./retry");
|
|
28
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
29
|
+
const cli = require("@rhizomatics/signalk-einklabel-plugin/dist/cli/index.js");
|
|
30
|
+
const DEFAULT_GUIDANCE_PATH = (0, path_1.join)(config_1.BUNDLED_PROMPTS_DIR, ".assets", "target_guidance.md");
|
|
31
|
+
/** Shared -p/-g/-w/--height/--colours/-d/--manufacturer/--label/-u/-e options for `prompt` and `generate`. */
|
|
32
|
+
function addPromptOptions(command) {
|
|
33
|
+
return command
|
|
34
|
+
.option("-p, --prompt <path>", "path to a .md prompt file", (0, path_1.join)(config_1.BUNDLED_PROMPTS_DIR, config_1.DEFAULT_PROMPT_NAME))
|
|
35
|
+
.option("-g, --guidance <path>", "path to the target-guidance fragment appended after the prompt", DEFAULT_GUIDANCE_PATH)
|
|
36
|
+
.option("-u, --url <url>", "SignalK server base URL - if omitted, tries each of " + cli.DEFAULT_SIGNALK_URLS.join(", ") + " in turn")
|
|
37
|
+
.option("-e, --example-data <dir>", "load vessels/resources from local example JSON files in <dir> instead of a live SignalK server")
|
|
38
|
+
.option("-w, --width <px>", "value for {source=label,path=width}", "416")
|
|
39
|
+
.option("--height <px>", "value for {source=label,path=height}", "240")
|
|
40
|
+
.option("--colours <code>", "value for {source=label,path=colours}: BW, BWR, or BWRY", "BWRY")
|
|
41
|
+
.option("-d, --description <text>", "value for {source=label,path=description} (a label's configured location/notes)")
|
|
42
|
+
.option("--manufacturer <name>", "value for {source=label,path=manufacturer}", "preview")
|
|
43
|
+
.option("--label <name>", "value for {source=label,path=label} (the panel's own size label, e.g. '3.7\"')", "preview");
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Resolves the optional positional `[name]` argument (e.g. `esl-cli prompt forecast`) to a prompt file
|
|
47
|
+
* path, overriding `-p/--prompt` when given - a bare name (no extension) is resolved as a prompt name
|
|
48
|
+
* via `resolvePromptPath`/`resolvePromptsDir` (the same lookup a real device's prompt gets).
|
|
49
|
+
*/
|
|
50
|
+
function resolvePromptArg(name, promptOption) {
|
|
51
|
+
if (!name)
|
|
52
|
+
return promptOption;
|
|
53
|
+
if ((0, fs_1.existsSync)(name))
|
|
54
|
+
return name;
|
|
55
|
+
const fileName = /\.[a-z0-9]+$/i.test(name) ? name : `${name}.md`;
|
|
56
|
+
return (0, config_1.resolvePromptPath)((0, config_1.resolvePromptsDir)(""), fileName);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Resolves a prompt+guidance fragment's placeholders against a live SignalK server (or example data),
|
|
60
|
+
* building the same `context.label` (`esl.buildLabel`) a real repaint would - shared by `prompt` and
|
|
61
|
+
* `generate`. `navigation.position` is always fetched (even if no other binding references it) so
|
|
62
|
+
* `{source=label,path=position}` works without the caller needing to name it explicitly.
|
|
63
|
+
*/
|
|
64
|
+
async function resolvePrompt(opts) {
|
|
65
|
+
const promptText = await (0, promises_1.readFile)(opts.prompt, "utf-8");
|
|
66
|
+
const guidanceText = await (0, promises_1.readFile)(opts.guidance, "utf-8");
|
|
67
|
+
const positionBinding = { source: "signalk", context: "self", path: "navigation.position" };
|
|
68
|
+
const bindings = [...signalk_einklabel_plugin_1.default.findBindingsInText(promptText, guidanceText), positionBinding];
|
|
69
|
+
const context = await cli.assembleContext(opts, bindings);
|
|
70
|
+
const rawPosition = context.signalk?.self?.navigation?.position;
|
|
71
|
+
const position = typeof rawPosition?.latitude === "number" && typeof rawPosition?.longitude === "number"
|
|
72
|
+
? { latitude: rawPosition.latitude, longitude: rawPosition.longitude }
|
|
73
|
+
: undefined;
|
|
74
|
+
const label = signalk_einklabel_plugin_1.default.buildLabel({
|
|
75
|
+
manufacturer: opts.manufacturer,
|
|
76
|
+
label: opts.label,
|
|
77
|
+
width: Number(opts.width),
|
|
78
|
+
height: Number(opts.height),
|
|
79
|
+
colours: cli.parseColours(opts.colours),
|
|
80
|
+
description: opts.description,
|
|
81
|
+
position,
|
|
82
|
+
});
|
|
83
|
+
const promptContext = { ...context, label };
|
|
84
|
+
return `${signalk_einklabel_plugin_1.default.substituteBindingsInText(promptText, promptContext).trim()}\n\n${signalk_einklabel_plugin_1.default.substituteBindingsInText(guidanceText, promptContext).trim()}\n`;
|
|
85
|
+
}
|
|
86
|
+
addPromptOptions(cli.program
|
|
87
|
+
.command("prompt")
|
|
88
|
+
.argument("[name]", 'prompt name (e.g. "forecast") or path - overrides -p/--prompt if given')
|
|
89
|
+
.description("Resolve a prompt file's placeholders against a live SignalK server (or example data) and print the exact text an LLM would be sent - no LLM call")).action(async (name, opts) => {
|
|
90
|
+
console.log(await resolvePrompt({ ...opts, prompt: resolvePromptArg(name, opts.prompt) }));
|
|
91
|
+
});
|
|
92
|
+
addPromptOptions(cli.program
|
|
93
|
+
.command("generate")
|
|
94
|
+
.argument("[name]", 'prompt name (e.g. "forecast") or path - overrides -p/--prompt if given')
|
|
95
|
+
.description("Call an LLM with a resolved prompt and write the rendered SVG's PNG, without needing a device"))
|
|
96
|
+
.requiredOption("-o, --output <path>", "output PNG path")
|
|
97
|
+
.option("--save-svg <path>", "also write the LLM's raw SVG response to this path (feed it straight into the core plugin's `paint -t`/`render -t` afterwards)")
|
|
98
|
+
.option("--llm-provider <name>", "openai, anthropic, google, xai, deepseek, moonshotai, ollama, or local", "openai")
|
|
99
|
+
.option("--llm-api-key <key>", "LLM API key - falls back to the provider's usual environment variable if omitted")
|
|
100
|
+
.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")
|
|
101
|
+
.option("--llm-base-url <url>", 'OpenAI-compatible endpoint - required if --llm-provider is "local"; for "ollama" defaults to http://localhost:11434/v1')
|
|
102
|
+
.option("--llm-timeout <seconds>", "LLM call timeout", "30")
|
|
103
|
+
.option("--llm-retries <n>", "LLM call attempts before giving up", "2")
|
|
104
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
105
|
+
.action(async (name, opts) => {
|
|
106
|
+
const prompt = await resolvePrompt({ ...opts, prompt: resolvePromptArg(name, opts.prompt) });
|
|
107
|
+
const llmSettings = {
|
|
108
|
+
llmProvider: opts.llmProvider,
|
|
109
|
+
llmApiKey: opts.llmApiKey,
|
|
110
|
+
llmModel: opts.llmModel,
|
|
111
|
+
llmBaseUrl: opts.llmBaseUrl,
|
|
112
|
+
llmTimeoutSeconds: Number(opts.llmTimeout),
|
|
113
|
+
};
|
|
114
|
+
const raw = await (0, retry_1.withRetries)(Number(opts.llmRetries), () => (0, llmGateway_1.callLlm)(llmSettings, prompt));
|
|
115
|
+
const svg = (0, llmGateway_1.extractSvg)(raw);
|
|
116
|
+
const svgPath = opts.saveSvg ?? (0, path_1.join)((0, os_1.tmpdir)(), `esl-cli-generate-${Date.now()}.svg`);
|
|
117
|
+
await (0, promises_1.writeFile)(svgPath, svg);
|
|
118
|
+
const width = Number(opts.width);
|
|
119
|
+
const height = Number(opts.height);
|
|
120
|
+
const renderer = new signalk_einklabel_plugin_1.default.Renderer();
|
|
121
|
+
const bitmap = await renderer.render(svgPath, { meta: { repainted: new Date().toISOString(), description: opts.description ?? "" } }, width, height);
|
|
122
|
+
await (0, promises_1.writeFile)(opts.output, signalk_einklabel_plugin_1.default.bitmapToPng(bitmap));
|
|
123
|
+
console.log(`wrote ${opts.output} (${bitmap.width}x${bitmap.height})${opts.saveSvg ? `, svg saved to ${opts.saveSvg}` : ""}`);
|
|
124
|
+
});
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export interface PluginConfig {
|
|
2
|
+
/**
|
|
3
|
+
* Which LLM gateway a prompt is sent to - see `./llmGateway.ts`. `"ollama"`/`"local"` both go through
|
|
4
|
+
* the generic OpenAI-compatible provider; any other value not in this list is looked up in the
|
|
5
|
+
* `registerLlmProvider` registry (`./llmProviderRegistry.ts`) before giving up - so someone keen can
|
|
6
|
+
* add support for another provider entirely without forking this plugin. Defaults to `"openai"`.
|
|
7
|
+
*/
|
|
8
|
+
llmProvider?: "openai" | "anthropic" | "google" | "xai" | "deepseek" | "moonshotai" | "ollama" | "local";
|
|
9
|
+
/** Ignored for `llmProvider: "ollama"`/`"local"` unless the server itself requires one. */
|
|
10
|
+
llmApiKey?: string;
|
|
11
|
+
/** Provider-specific model id, e.g. `"gpt-4o"`, `"claude-sonnet-4-5"`, `"gemini-2.5-flash"`, or an Ollama/local model tag. */
|
|
12
|
+
llmModel?: string;
|
|
13
|
+
/**
|
|
14
|
+
* An OpenAI-compatible endpoint. Optional for `llmProvider: "ollama"` - defaults to Ollama's own
|
|
15
|
+
* standard local address, `http://localhost:11434/v1`; only needed there to point at a differently
|
|
16
|
+
* configured/remote Ollama instance. Required for `llmProvider: "local"` (any other OpenAI-compatible
|
|
17
|
+
* server, e.g. LM Studio/vLLM), which has no such universal default.
|
|
18
|
+
*/
|
|
19
|
+
llmBaseUrl?: string;
|
|
20
|
+
/** How long to wait for the LLM's response before giving up on an attempt. */
|
|
21
|
+
llmTimeoutSeconds: number;
|
|
22
|
+
/** How many times to attempt the LLM call (including the first try) before giving up (the core plugin then shows its own generic fallback warning). */
|
|
23
|
+
llmRetries: number;
|
|
24
|
+
/**
|
|
25
|
+
* Directory this plugin scans for `.md` prompt files - same convention as the core plugin's own
|
|
26
|
+
* `templatesDir`: empty for the default, a relative path resolved against `~/.signalk`, or an
|
|
27
|
+
* absolute path. Use `resolvePromptsDir` to turn this into an actual path.
|
|
28
|
+
*/
|
|
29
|
+
promptsDir: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* This package's own bundled `prompts/` directory (ships alongside `dist/`, see package.json's
|
|
33
|
+
* `files`) - `forecast.md` (the built-in default) and the always-appended
|
|
34
|
+
* `.assets/target_guidance.md` fragment live here. A same-named file in the user's `promptsDir` takes
|
|
35
|
+
* priority, letting users add or override their own prompts.
|
|
36
|
+
*/
|
|
37
|
+
export declare const BUNDLED_PROMPTS_DIR: string;
|
|
38
|
+
/** Bundled prompt used when `promptName` is left unset. */
|
|
39
|
+
export declare const DEFAULT_PROMPT_NAME = "forecast.md";
|
|
40
|
+
export declare function defaultConfig(): PluginConfig;
|
|
41
|
+
/** Mirrors the core plugin's `resolveTemplatesDir` - see its own doc comment. */
|
|
42
|
+
export declare function resolvePromptsDir(promptsDir: string | undefined): string;
|
|
43
|
+
/** Local prompts take priority over a same-named bundled one; both show up as options. */
|
|
44
|
+
export declare function promptNameOptions(promptsDir: string): string[];
|
|
45
|
+
/**
|
|
46
|
+
* Resolves a prompt name (or the `.assets/target_guidance.md` fragment, passed as
|
|
47
|
+
* `".assets/target_guidance.md"`) to an actual file - a local file under `promptsDir` overrides the
|
|
48
|
+
* bundled one of the same name.
|
|
49
|
+
*/
|
|
50
|
+
export declare function resolvePromptPath(promptsDir: string, name: string): string;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_PROMPT_NAME = exports.BUNDLED_PROMPTS_DIR = void 0;
|
|
4
|
+
exports.defaultConfig = defaultConfig;
|
|
5
|
+
exports.resolvePromptsDir = resolvePromptsDir;
|
|
6
|
+
exports.promptNameOptions = promptNameOptions;
|
|
7
|
+
exports.resolvePromptPath = resolvePromptPath;
|
|
8
|
+
const fs_1 = require("fs");
|
|
9
|
+
const os_1 = require("os");
|
|
10
|
+
const path_1 = require("path");
|
|
11
|
+
/**
|
|
12
|
+
* This package's own bundled `prompts/` directory (ships alongside `dist/`, see package.json's
|
|
13
|
+
* `files`) - `forecast.md` (the built-in default) and the always-appended
|
|
14
|
+
* `.assets/target_guidance.md` fragment live here. A same-named file in the user's `promptsDir` takes
|
|
15
|
+
* priority, letting users add or override their own prompts.
|
|
16
|
+
*/
|
|
17
|
+
exports.BUNDLED_PROMPTS_DIR = (0, path_1.join)(__dirname, "..", "prompts");
|
|
18
|
+
/** Bundled prompt used when `promptName` is left unset. */
|
|
19
|
+
exports.DEFAULT_PROMPT_NAME = "forecast.md";
|
|
20
|
+
const SIGNALK_HOME_DIR = (0, path_1.join)((0, os_1.homedir)(), ".signalk");
|
|
21
|
+
const DEFAULT_PROMPTS_DIR = (0, path_1.join)(SIGNALK_HOME_DIR, "einklabel-genai", "prompts");
|
|
22
|
+
function defaultConfig() {
|
|
23
|
+
return {
|
|
24
|
+
llmTimeoutSeconds: 30,
|
|
25
|
+
llmRetries: 2,
|
|
26
|
+
promptsDir: "",
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Mirrors the core plugin's `resolveTemplatesDir` - see its own doc comment. */
|
|
30
|
+
function resolvePromptsDir(promptsDir) {
|
|
31
|
+
const trimmed = promptsDir?.trim();
|
|
32
|
+
if (!trimmed)
|
|
33
|
+
return DEFAULT_PROMPTS_DIR;
|
|
34
|
+
return (0, path_1.isAbsolute)(trimmed) ? trimmed : (0, path_1.join)(SIGNALK_HOME_DIR, trimmed);
|
|
35
|
+
}
|
|
36
|
+
function listMarkdownFiles(dir) {
|
|
37
|
+
try {
|
|
38
|
+
return (0, fs_1.readdirSync)(dir).filter((name) => name.endsWith(".md"));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Local prompts take priority over a same-named bundled one; both show up as options. */
|
|
45
|
+
function promptNameOptions(promptsDir) {
|
|
46
|
+
const local = listMarkdownFiles(promptsDir);
|
|
47
|
+
const bundled = listMarkdownFiles(exports.BUNDLED_PROMPTS_DIR).filter((name) => !local.includes(name));
|
|
48
|
+
return [...local, ...bundled];
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Resolves a prompt name (or the `.assets/target_guidance.md` fragment, passed as
|
|
52
|
+
* `".assets/target_guidance.md"`) to an actual file - a local file under `promptsDir` overrides the
|
|
53
|
+
* bundled one of the same name.
|
|
54
|
+
*/
|
|
55
|
+
function resolvePromptPath(promptsDir, name) {
|
|
56
|
+
const localPath = (0, path_1.join)(promptsDir, name);
|
|
57
|
+
return (0, fs_1.existsSync)(localPath) ? localPath : (0, path_1.join)(exports.BUNDLED_PROMPTS_DIR, name);
|
|
58
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ServerAPI, Plugin } from "@signalk/server-api";
|
|
2
|
+
import { registerLlmProvider as registerLlmProviderImpl, getLlmProvider as getLlmProviderImpl } from "./llmProviderRegistry";
|
|
3
|
+
import type { LlmProviderFactory as LlmProviderFactoryType, LlmSettings as LlmSettingsType } from "./llmProviderRegistry";
|
|
4
|
+
/**
|
|
5
|
+
* On `start()`, registers this plugin's `TemplateProvider` with the core `signalk-einklabel-plugin`
|
|
6
|
+
* (`esl.registerTemplateProvider`, see `./templateProvider.ts` and `./plugin.ts`) - so its prompts show
|
|
7
|
+
* up in the core plugin's own "Template" picker, suffixed "(GenAI)".
|
|
8
|
+
*/
|
|
9
|
+
declare function plugin(app: ServerAPI): Plugin;
|
|
10
|
+
declare namespace plugin {
|
|
11
|
+
/**
|
|
12
|
+
* Public extension point for a package adding support for an LLM provider this plugin doesn't
|
|
13
|
+
* already bundle - see `registerLlmProvider`'s own doc comment (`./llmProviderRegistry.ts`) for the
|
|
14
|
+
* full contract, and the core plugin's own `registerVendorDriver` doc comment for the dependency
|
|
15
|
+
* convention to follow (a regular npm `dependency` plus `"signalk": { "requires": [...] }`, **not** a
|
|
16
|
+
* `peerDependency`).
|
|
17
|
+
*/
|
|
18
|
+
const registerLlmProvider: typeof registerLlmProviderImpl;
|
|
19
|
+
const getLlmProvider: typeof getLlmProviderImpl;
|
|
20
|
+
type LlmProviderFactory = LlmProviderFactoryType;
|
|
21
|
+
type LlmSettings = LlmSettingsType;
|
|
22
|
+
}
|
|
23
|
+
export = plugin;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
const plugin_1 = require("./plugin");
|
|
3
|
+
const llmProviderRegistry_1 = require("./llmProviderRegistry");
|
|
4
|
+
/**
|
|
5
|
+
* On `start()`, registers this plugin's `TemplateProvider` with the core `signalk-einklabel-plugin`
|
|
6
|
+
* (`esl.registerTemplateProvider`, see `./templateProvider.ts` and `./plugin.ts`) - so its prompts show
|
|
7
|
+
* up in the core plugin's own "Template" picker, suffixed "(GenAI)".
|
|
8
|
+
*/
|
|
9
|
+
function plugin(app) {
|
|
10
|
+
return (0, plugin_1.createPlugin)(app);
|
|
11
|
+
}
|
|
12
|
+
(function (plugin) {
|
|
13
|
+
/**
|
|
14
|
+
* Public extension point for a package adding support for an LLM provider this plugin doesn't
|
|
15
|
+
* already bundle - see `registerLlmProvider`'s own doc comment (`./llmProviderRegistry.ts`) for the
|
|
16
|
+
* full contract, and the core plugin's own `registerVendorDriver` doc comment for the dependency
|
|
17
|
+
* convention to follow (a regular npm `dependency` plus `"signalk": { "requires": [...] }`, **not** a
|
|
18
|
+
* `peerDependency`).
|
|
19
|
+
*/
|
|
20
|
+
plugin.registerLlmProvider = llmProviderRegistry_1.registerLlmProvider;
|
|
21
|
+
plugin.getLlmProvider = llmProviderRegistry_1.getLlmProvider;
|
|
22
|
+
})(plugin || (plugin = {}));
|
|
23
|
+
module.exports = plugin;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { PluginConfig } from "./config";
|
|
2
|
+
import { LlmSettings as RegistryLlmSettings } from "./llmProviderRegistry";
|
|
3
|
+
export type LlmSettings = RegistryLlmSettings & {
|
|
4
|
+
llmTimeoutSeconds?: number;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* `ai` and every `@ai-sdk/*`/`ai-sdk-ollama` provider package are `optionalDependencies` (see
|
|
8
|
+
* package.json), not plain `dependencies` - a user picks exactly one provider, so there's no reason to
|
|
9
|
+
* force every one of them to install, and an install that fails to fetch one it doesn't even use (a
|
|
10
|
+
* network hiccup, `npm install --omit=optional`, an unsupported platform) must not break this plugin
|
|
11
|
+
* for everyone else. That means every reference to one of these packages has to be a `require()` reached
|
|
12
|
+
* only when `callLlm` actually needs it - a top-level `import` would be resolved eagerly the moment this
|
|
13
|
+
* module loads, throwing before any config is even read. Types are still fully checked via
|
|
14
|
+
* `typeof import(...)` at each call site, which - unlike a value `import` - is erased entirely at
|
|
15
|
+
* compile time and leaves no runtime trace for `tsc` to eagerly require.
|
|
16
|
+
*/
|
|
17
|
+
export declare function loadOptional<M>(moduleName: string): M;
|
|
18
|
+
/**
|
|
19
|
+
* Calls the configured LLM gateway with `prompt`, returning its raw text response - not yet extracted/
|
|
20
|
+
* validated as SVG, see `extractSvg`. Wrapped in its own timeout (`AbortController`) since this is the
|
|
21
|
+
* one genuinely slow/unreliable network call in this plugin.
|
|
22
|
+
*/
|
|
23
|
+
export declare function callLlm(settings: LlmSettings, prompt: string): Promise<string>;
|
|
24
|
+
/**
|
|
25
|
+
* Strips everything outside the first `<svg`...last `</svg>` span - a chat model asked for "only raw
|
|
26
|
+
* 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.
|
|
28
|
+
*/
|
|
29
|
+
export declare function extractSvg(raw: string): string;
|
|
30
|
+
export type { PluginConfig };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.loadOptional = loadOptional;
|
|
4
|
+
exports.callLlm = callLlm;
|
|
5
|
+
exports.extractSvg = extractSvg;
|
|
6
|
+
const llmProviderRegistry_1 = require("./llmProviderRegistry");
|
|
7
|
+
/**
|
|
8
|
+
* `ai` and every `@ai-sdk/*`/`ai-sdk-ollama` provider package are `optionalDependencies` (see
|
|
9
|
+
* package.json), not plain `dependencies` - a user picks exactly one provider, so there's no reason to
|
|
10
|
+
* force every one of them to install, and an install that fails to fetch one it doesn't even use (a
|
|
11
|
+
* network hiccup, `npm install --omit=optional`, an unsupported platform) must not break this plugin
|
|
12
|
+
* for everyone else. That means every reference to one of these packages has to be a `require()` reached
|
|
13
|
+
* only when `callLlm` actually needs it - a top-level `import` would be resolved eagerly the moment this
|
|
14
|
+
* module loads, throwing before any config is even read. Types are still fully checked via
|
|
15
|
+
* `typeof import(...)` at each call site, which - unlike a value `import` - is erased entirely at
|
|
16
|
+
* compile time and leaves no runtime trace for `tsc` to eagerly require.
|
|
17
|
+
*/
|
|
18
|
+
function loadOptional(moduleName) {
|
|
19
|
+
try {
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
21
|
+
return require(moduleName);
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
throw new Error(`LLM provider support needs the optional dependency "${moduleName}", which isn't installed - run "npm install ${moduleName}" (${err.message})`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** Ollama's own default local listen address - see the `"ollama"` case in `resolveModel` below. */
|
|
28
|
+
const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434/v1";
|
|
29
|
+
/**
|
|
30
|
+
* Resolves `settings` to a Vercel AI SDK model handle. Checks `registerLlmProvider`'s registry
|
|
31
|
+
* (`./llmProviderRegistry.ts`) first, by exact `llmProvider` name, before falling through to this
|
|
32
|
+
* built-in switch - so a custom-registered provider can even *replace* one of the names below if it
|
|
33
|
+
* wants to. `"ollama"` uses the dedicated `ai-sdk-ollama` package (defaults its own base URL to Ollama's
|
|
34
|
+
* standard local address, so no config is needed beyond a model tag); `"local"` uses the generic
|
|
35
|
+
* `@ai-sdk/openai-compatible` provider for anything else OpenAI-compatible (LM Studio, vLLM, ...), which
|
|
36
|
+
* has no such universal default, so `llmBaseUrl` is required there.
|
|
37
|
+
*
|
|
38
|
+
* Returns `any` deliberately - this bridges several different concrete SDK provider packages' own
|
|
39
|
+
* model-handle types (each an implementation of `ai`'s `LanguageModel`, but not literally the same
|
|
40
|
+
* type reference) plus the registry's own `unknown`-typed `LlmModel`; `generateText` below still
|
|
41
|
+
* validates the value at the one place it's actually used.
|
|
42
|
+
*/
|
|
43
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
44
|
+
function resolveModel(settings) {
|
|
45
|
+
const model = settings.llmModel;
|
|
46
|
+
if (!model) {
|
|
47
|
+
throw new Error("no LLM model configured (llmModel)");
|
|
48
|
+
}
|
|
49
|
+
const provider = settings.llmProvider ?? "openai";
|
|
50
|
+
const registered = (0, llmProviderRegistry_1.getLlmProvider)(provider);
|
|
51
|
+
if (registered)
|
|
52
|
+
return registered(settings);
|
|
53
|
+
switch (provider) {
|
|
54
|
+
case "anthropic": {
|
|
55
|
+
const { createAnthropic } = loadOptional("@ai-sdk/anthropic");
|
|
56
|
+
return createAnthropic({ apiKey: settings.llmApiKey })(model);
|
|
57
|
+
}
|
|
58
|
+
case "google": {
|
|
59
|
+
const { createGoogleGenerativeAI } = loadOptional("@ai-sdk/google");
|
|
60
|
+
return createGoogleGenerativeAI({ apiKey: settings.llmApiKey })(model);
|
|
61
|
+
}
|
|
62
|
+
case "xai": {
|
|
63
|
+
const { createXai } = loadOptional("@ai-sdk/xai");
|
|
64
|
+
return createXai({ apiKey: settings.llmApiKey })(model);
|
|
65
|
+
}
|
|
66
|
+
case "deepseek": {
|
|
67
|
+
const { createDeepSeek } = loadOptional("@ai-sdk/deepseek");
|
|
68
|
+
return createDeepSeek({ apiKey: settings.llmApiKey })(model);
|
|
69
|
+
}
|
|
70
|
+
case "moonshotai": {
|
|
71
|
+
const { createMoonshotAI } = loadOptional("@ai-sdk/moonshotai");
|
|
72
|
+
return createMoonshotAI({ apiKey: settings.llmApiKey })(model);
|
|
73
|
+
}
|
|
74
|
+
case "ollama": {
|
|
75
|
+
const { createOllama } = loadOptional("ai-sdk-ollama");
|
|
76
|
+
return createOllama({ baseURL: settings.llmBaseUrl || DEFAULT_OLLAMA_BASE_URL, apiKey: settings.llmApiKey })(model);
|
|
77
|
+
}
|
|
78
|
+
case "local": {
|
|
79
|
+
if (!settings.llmBaseUrl) {
|
|
80
|
+
throw new Error('llmBaseUrl is required when llmProvider is "local"');
|
|
81
|
+
}
|
|
82
|
+
const { createOpenAICompatible } = loadOptional("@ai-sdk/openai-compatible");
|
|
83
|
+
return createOpenAICompatible({ name: "local", baseURL: settings.llmBaseUrl, apiKey: settings.llmApiKey })(model);
|
|
84
|
+
}
|
|
85
|
+
case "openai":
|
|
86
|
+
default: {
|
|
87
|
+
const { createOpenAI } = loadOptional("@ai-sdk/openai");
|
|
88
|
+
return createOpenAI({ apiKey: settings.llmApiKey })(model);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Calls the configured LLM gateway with `prompt`, returning its raw text response - not yet extracted/
|
|
94
|
+
* validated as SVG, see `extractSvg`. Wrapped in its own timeout (`AbortController`) since this is the
|
|
95
|
+
* one genuinely slow/unreliable network call in this plugin.
|
|
96
|
+
*/
|
|
97
|
+
async function callLlm(settings, prompt) {
|
|
98
|
+
const model = resolveModel(settings);
|
|
99
|
+
const { generateText } = loadOptional("ai");
|
|
100
|
+
const controller = new AbortController();
|
|
101
|
+
const timer = setTimeout(() => controller.abort(), (settings.llmTimeoutSeconds ?? 30) * 1000);
|
|
102
|
+
try {
|
|
103
|
+
const { text } = await generateText({ model, prompt, abortSignal: controller.signal });
|
|
104
|
+
return text;
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Strips everything outside the first `<svg`...last `</svg>` span - a chat model asked for "only raw
|
|
112
|
+
* SVG markup" still often wraps it in a markdown code fence or adds a sentence of commentary either
|
|
113
|
+
* side, despite the target-guidance fragment telling it not to.
|
|
114
|
+
*/
|
|
115
|
+
function extractSvg(raw) {
|
|
116
|
+
const start = raw.indexOf("<svg");
|
|
117
|
+
const end = raw.lastIndexOf("</svg>");
|
|
118
|
+
if (start === -1 || end === -1 || end < start) {
|
|
119
|
+
throw new Error('LLM response did not contain a "<svg>...</svg>" document');
|
|
120
|
+
}
|
|
121
|
+
return raw.slice(start, end + "</svg>".length);
|
|
122
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { PluginConfig } from "./config";
|
|
2
|
+
/**
|
|
3
|
+
* Settings a registered LLM provider factory needs - a subset of `PluginConfig`, shared with the
|
|
4
|
+
* built-in providers in `./llmGateway.ts`.
|
|
5
|
+
*/
|
|
6
|
+
export type LlmSettings = Pick<PluginConfig, "llmProvider" | "llmApiKey" | "llmModel" | "llmBaseUrl">;
|
|
7
|
+
/** Vercel AI SDK's own `LanguageModel` type, loosened to `unknown` here to avoid a hard dependency on `ai`'s types in this always-loaded module - see `./llmGateway.ts`'s `loadOptional`. */
|
|
8
|
+
export type LlmModel = unknown;
|
|
9
|
+
/** Builds a Vercel AI SDK model handle for one custom provider name - see `registerLlmProvider`. */
|
|
10
|
+
export type LlmProviderFactory = (settings: LlmSettings) => LlmModel;
|
|
11
|
+
/**
|
|
12
|
+
* Public extension point mirroring the core `signalk-einklabel-plugin`'s own `registerVendorDriver`/
|
|
13
|
+
* `registerTemplateProvider` pattern, one level down: this plugin already bundles support for several
|
|
14
|
+
* providers (`./llmGateway.ts`'s built-in switch: openai/anthropic/google/xai/deepseek/moonshotai/
|
|
15
|
+
* ollama/local) as `optionalDependencies`, but someone wanting a provider not in that list (a bespoke
|
|
16
|
+
* wrapper, a provider with no official `@ai-sdk/*` package yet) can register one of their own without
|
|
17
|
+
* forking this plugin - `resolveModel` in `./llmGateway.ts` checks this registry first, by `llmProvider`
|
|
18
|
+
* name, before falling through to the built-in switch. Call this from your own SignalK plugin's
|
|
19
|
+
* `start()` (or `esl-cli --require <module>`, loaded before parsing per the core plugin's own CLI
|
|
20
|
+
* extension timing - see its README). Declare this package as a regular npm `dependency` (not a
|
|
21
|
+
* `peerDependency` - see the core plugin's own `registerVendorDriver` doc comment for why) so npm
|
|
22
|
+
* resolves a real copy to register into.
|
|
23
|
+
*/
|
|
24
|
+
export declare function registerLlmProvider(name: string, factory: LlmProviderFactory): void;
|
|
25
|
+
export declare function getLlmProvider(name: string): LlmProviderFactory | undefined;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerLlmProvider = registerLlmProvider;
|
|
4
|
+
exports.getLlmProvider = getLlmProvider;
|
|
5
|
+
const providers = new Map();
|
|
6
|
+
/**
|
|
7
|
+
* Public extension point mirroring the core `signalk-einklabel-plugin`'s own `registerVendorDriver`/
|
|
8
|
+
* `registerTemplateProvider` pattern, one level down: this plugin already bundles support for several
|
|
9
|
+
* providers (`./llmGateway.ts`'s built-in switch: openai/anthropic/google/xai/deepseek/moonshotai/
|
|
10
|
+
* ollama/local) as `optionalDependencies`, but someone wanting a provider not in that list (a bespoke
|
|
11
|
+
* wrapper, a provider with no official `@ai-sdk/*` package yet) can register one of their own without
|
|
12
|
+
* forking this plugin - `resolveModel` in `./llmGateway.ts` checks this registry first, by `llmProvider`
|
|
13
|
+
* name, before falling through to the built-in switch. Call this from your own SignalK plugin's
|
|
14
|
+
* `start()` (or `esl-cli --require <module>`, loaded before parsing per the core plugin's own CLI
|
|
15
|
+
* extension timing - see its README). Declare this package as a regular npm `dependency` (not a
|
|
16
|
+
* `peerDependency` - see the core plugin's own `registerVendorDriver` doc comment for why) so npm
|
|
17
|
+
* resolves a real copy to register into.
|
|
18
|
+
*/
|
|
19
|
+
function registerLlmProvider(name, factory) {
|
|
20
|
+
providers.set(name, factory);
|
|
21
|
+
}
|
|
22
|
+
function getLlmProvider(name) {
|
|
23
|
+
return providers.get(name);
|
|
24
|
+
}
|
package/dist/plugin.d.ts
ADDED
package/dist/plugin.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createPlugin = createPlugin;
|
|
7
|
+
const signalk_einklabel_plugin_1 = __importDefault(require("@rhizomatics/signalk-einklabel-plugin"));
|
|
8
|
+
const config_1 = require("./config");
|
|
9
|
+
const templateProvider_1 = require("./templateProvider");
|
|
10
|
+
function createPlugin(app) {
|
|
11
|
+
// Updated by `start()` on every (re)start - `createTemplateProvider`'s functions all read through
|
|
12
|
+
// this closure rather than capturing one fixed config object, so a saved config change takes effect
|
|
13
|
+
// on the *next* repaint without needing the core plugin (which holds the actual repaint schedule) to
|
|
14
|
+
// know or care that anything changed on this side.
|
|
15
|
+
let currentConfig = (0, config_1.defaultConfig)();
|
|
16
|
+
signalk_einklabel_plugin_1.default.registerTemplateProvider((0, templateProvider_1.createTemplateProvider)(() => currentConfig));
|
|
17
|
+
return {
|
|
18
|
+
id: "signalk-einklabel-genai-plugin",
|
|
19
|
+
name: "eInk Label GenAI Rendering",
|
|
20
|
+
description: "Contributes LLM-generated prompts as an alternative to hand-authored SVG templates for signalk-einklabel-plugin",
|
|
21
|
+
schema: () => ({
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
llmProvider: {
|
|
25
|
+
type: "string",
|
|
26
|
+
title: "LLM provider",
|
|
27
|
+
description: "Which LLM gateway to call - each (other than local/ollama) needs its own optional npm package installed, e.g. " +
|
|
28
|
+
'`npm install ai @ai-sdk/anthropic` (see README). "Ollama" defaults to a local Ollama server with no further ' +
|
|
29
|
+
'config; "Local" is any other OpenAI-compatible endpoint (e.g. LM Studio) and needs "LLM base URL" below.',
|
|
30
|
+
enum: ["openai", "anthropic", "google", "xai", "deepseek", "moonshotai", "ollama", "local"],
|
|
31
|
+
default: "openai",
|
|
32
|
+
},
|
|
33
|
+
llmApiKey: {
|
|
34
|
+
type: "string",
|
|
35
|
+
title: "LLM API key",
|
|
36
|
+
description: 'Not required for "ollama"/"local" unless the server itself checks one.',
|
|
37
|
+
},
|
|
38
|
+
llmModel: {
|
|
39
|
+
type: "string",
|
|
40
|
+
title: "LLM model",
|
|
41
|
+
description: 'Provider-specific model id/name, e.g. "gpt-4o", "claude-sonnet-4-5", "gemini-2.5-flash", "grok-4", or an Ollama/local model tag.',
|
|
42
|
+
},
|
|
43
|
+
llmBaseUrl: {
|
|
44
|
+
type: "string",
|
|
45
|
+
title: 'LLM base URL (required if LLM provider is "local")',
|
|
46
|
+
description: 'An OpenAI-compatible endpoint. Optional for "ollama" (defaults to "http://localhost:11434/v1") - set this only if Ollama runs elsewhere.',
|
|
47
|
+
},
|
|
48
|
+
llmTimeoutSeconds: {
|
|
49
|
+
type: "number",
|
|
50
|
+
title: "LLM call timeout (seconds)",
|
|
51
|
+
description: "How long to wait for the LLM's response before giving up on an attempt.",
|
|
52
|
+
minimum: 1,
|
|
53
|
+
default: (0, config_1.defaultConfig)().llmTimeoutSeconds,
|
|
54
|
+
},
|
|
55
|
+
llmRetries: {
|
|
56
|
+
type: "number",
|
|
57
|
+
title: "LLM call retries",
|
|
58
|
+
description: "How many times to attempt the LLM call (including the first try) before giving up - the core plugin then shows " +
|
|
59
|
+
"its own generic fallback warning instead of leaving stale content on screen.",
|
|
60
|
+
minimum: 1,
|
|
61
|
+
default: (0, config_1.defaultConfig)().llmRetries,
|
|
62
|
+
},
|
|
63
|
+
promptsDir: {
|
|
64
|
+
type: "string",
|
|
65
|
+
title: "Prompts directory",
|
|
66
|
+
description: "Relative path from ~/.signalk, or an absolute path. Leave empty for the default. Holds .md prompt files - a prompt " +
|
|
67
|
+
"here with the same name as a bundled one takes priority. Prompts show up in the core plugin's own Template " +
|
|
68
|
+
'picker, suffixed "(GenAI)".',
|
|
69
|
+
default: (0, config_1.defaultConfig)().promptsDir,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
}),
|
|
73
|
+
uiSchema: () => ({ llmApiKey: { "ui:widget": "password" } }),
|
|
74
|
+
start(config) {
|
|
75
|
+
currentConfig = { ...(0, config_1.defaultConfig)(), ...config };
|
|
76
|
+
app.debug(`eInk Label GenAI plugin started (llmProvider=${currentConfig.llmProvider ?? "openai"})`);
|
|
77
|
+
},
|
|
78
|
+
stop() { },
|
|
79
|
+
};
|
|
80
|
+
}
|
package/dist/retry.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retries `fn` up to `attempts` times (including the first try), returning on the first success -
|
|
3
|
+
* mirrors the core plugin's own `withRetries` (`src/devices/bleDiscovery.ts`), duplicated here rather
|
|
4
|
+
* than imported since that one isn't part of the core plugin's public `esl` export surface.
|
|
5
|
+
*/
|
|
6
|
+
export declare function withRetries<T>(attempts: number, fn: (attempt: number) => Promise<T>): Promise<T>;
|
package/dist/retry.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.withRetries = withRetries;
|
|
4
|
+
/**
|
|
5
|
+
* Retries `fn` up to `attempts` times (including the first try), returning on the first success -
|
|
6
|
+
* mirrors the core plugin's own `withRetries` (`src/devices/bleDiscovery.ts`), duplicated here rather
|
|
7
|
+
* than imported since that one isn't part of the core plugin's public `esl` export surface.
|
|
8
|
+
*/
|
|
9
|
+
async function withRetries(attempts, fn) {
|
|
10
|
+
let lastErr;
|
|
11
|
+
for (let attempt = 1; attempt <= Math.max(1, attempts); attempt++) {
|
|
12
|
+
try {
|
|
13
|
+
return await fn(attempt);
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
lastErr = err;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
throw lastErr;
|
|
20
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import esl from "@rhizomatics/signalk-einklabel-plugin";
|
|
2
|
+
import { PluginConfig } from "./config";
|
|
3
|
+
/** Appended to every prompt name this plugin offers in the core plugin's "Template" dropdown. */
|
|
4
|
+
export declare const SUFFIX = "(GenAI)";
|
|
5
|
+
/**
|
|
6
|
+
* The `esl.TemplateProvider` this plugin registers with the core plugin (`esl.registerTemplateProvider`,
|
|
7
|
+
* called from `./plugin.ts`'s `start()`). `listTemplates`/`describeBindings`/`render` all resolve the
|
|
8
|
+
* current `promptsDir` fresh each time, rather than once at startup, so adding/editing a `.md` prompt
|
|
9
|
+
* file takes effect without a plugin restart - same as the core plugin's own template directory.
|
|
10
|
+
*/
|
|
11
|
+
export declare function createTemplateProvider(getConfig: () => PluginConfig): esl.TemplateProvider;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.SUFFIX = void 0;
|
|
7
|
+
exports.createTemplateProvider = createTemplateProvider;
|
|
8
|
+
const fs_1 = require("fs");
|
|
9
|
+
const path_1 = require("path");
|
|
10
|
+
const signalk_einklabel_plugin_1 = __importDefault(require("@rhizomatics/signalk-einklabel-plugin"));
|
|
11
|
+
const retry_1 = require("./retry");
|
|
12
|
+
const config_1 = require("./config");
|
|
13
|
+
const llmGateway_1 = require("./llmGateway");
|
|
14
|
+
/** Appended to every prompt name this plugin offers in the core plugin's "Template" dropdown. */
|
|
15
|
+
exports.SUFFIX = "(GenAI)";
|
|
16
|
+
const GUIDANCE_NAME = (0, path_1.join)(".assets", "target_guidance.md");
|
|
17
|
+
/** Strips ` (GenAI)` back off a dropdown entry to get the bare prompt file name, e.g. `"forecast (GenAI)"` -> `"forecast.md"`. */
|
|
18
|
+
function promptNameFor(templateName) {
|
|
19
|
+
return templateName.slice(0, templateName.length - ` ${exports.SUFFIX}`.length);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The `esl.TemplateProvider` this plugin registers with the core plugin (`esl.registerTemplateProvider`,
|
|
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) {
|
|
28
|
+
function promptsDir() {
|
|
29
|
+
return (0, config_1.resolvePromptsDir)(getConfig().promptsDir);
|
|
30
|
+
}
|
|
31
|
+
function promptPathFor(templateName) {
|
|
32
|
+
const dir = promptsDir();
|
|
33
|
+
return {
|
|
34
|
+
promptPath: (0, config_1.resolvePromptPath)(dir, promptNameFor(templateName)),
|
|
35
|
+
guidancePath: (0, config_1.resolvePromptPath)(dir, GUIDANCE_NAME),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
suffix: exports.SUFFIX,
|
|
40
|
+
listTemplates() {
|
|
41
|
+
return (0, config_1.promptNameOptions)(promptsDir()).map((name) => `${name} ${exports.SUFFIX}`);
|
|
42
|
+
},
|
|
43
|
+
describeBindings(templateName) {
|
|
44
|
+
const { promptPath, guidancePath } = promptPathFor(templateName);
|
|
45
|
+
const promptText = (0, fs_1.readFileSync)(promptPath, "utf-8");
|
|
46
|
+
const guidanceText = (0, fs_1.readFileSync)(guidancePath, "utf-8");
|
|
47
|
+
return signalk_einklabel_plugin_1.default.findBindingsInText(promptText, guidanceText);
|
|
48
|
+
},
|
|
49
|
+
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
|
+
const config = getConfig();
|
|
57
|
+
const llmSettings = {
|
|
58
|
+
llmProvider: config.llmProvider,
|
|
59
|
+
llmApiKey: config.llmApiKey,
|
|
60
|
+
llmModel: config.llmModel,
|
|
61
|
+
llmBaseUrl: config.llmBaseUrl,
|
|
62
|
+
llmTimeoutSeconds: config.llmTimeoutSeconds,
|
|
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);
|
|
66
|
+
const renderer = new signalk_einklabel_plugin_1.default.Renderer();
|
|
67
|
+
// `esl.Renderer.render()` reads its template from a file path, not a string - the LLM's SVG is
|
|
68
|
+
// written out as one so the exact same rasterization path a bundled/local template gets is
|
|
69
|
+
// reused unchanged, rather than needing its own copy of that logic.
|
|
70
|
+
const cacheDir = (0, path_1.join)(promptsDir(), ".genai-cache");
|
|
71
|
+
(0, fs_1.mkdirSync)(cacheDir, { recursive: true });
|
|
72
|
+
const cachePath = (0, path_1.join)(cacheDir, `${Buffer.from(request.templateName).toString("hex")}.svg`);
|
|
73
|
+
(0, fs_1.writeFileSync)(cachePath, svg);
|
|
74
|
+
return renderer.render(cachePath, request.context, request.width, request.height);
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@rhizomatics/signalk-einklabel-genai-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "GenAI prompt generated labels for the eInk Electronic Shelf Label plugin. Contributes prompts as an alternative to hand-authored SVG templates.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"eink",
|
|
7
|
+
"esl",
|
|
8
|
+
"genai",
|
|
9
|
+
"llm",
|
|
10
|
+
"signalk-category-hardware",
|
|
11
|
+
"signalk-category-instruments",
|
|
12
|
+
"signalk-node-server-plugin"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/rhizomatics/signalk-einklabel-genai-plugin",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/rhizomatics/signalk-einklabel-genai-plugin/issues"
|
|
17
|
+
},
|
|
18
|
+
"license": "APACHE-2.0",
|
|
19
|
+
"author": "jey burrows",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/rhizomatics/signalk-einklabel-genai-plugin.git"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"prompts",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"main": "dist/index.js",
|
|
30
|
+
"types": "dist/index.d.ts",
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc",
|
|
33
|
+
"watch": "tsc --watch",
|
|
34
|
+
"test": "node scripts/run-tests.js",
|
|
35
|
+
"coverage": "node scripts/run-tests.js --coverage",
|
|
36
|
+
"prepublishOnly": "npm run build",
|
|
37
|
+
"fmt": "oxfmt",
|
|
38
|
+
"fmt:check": "oxfmt --check",
|
|
39
|
+
"lint": "oxlint"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@rhizomatics/signalk-einklabel-plugin": "file:../signalk-einklabel-plugin"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@signalk/server-api": "^2.28.0",
|
|
46
|
+
"@types/node": "^20.14.0",
|
|
47
|
+
"commander": "^12.1.0",
|
|
48
|
+
"oxfmt": "^0.59.0",
|
|
49
|
+
"oxlint": "^1.74.0",
|
|
50
|
+
"oxlint-tsgolint": "^7.0.0",
|
|
51
|
+
"ts-node": "^10.9.2",
|
|
52
|
+
"typescript": "^5.5.0"
|
|
53
|
+
},
|
|
54
|
+
"optionalDependencies": {
|
|
55
|
+
"@ai-sdk/anthropic": "^4.0.21",
|
|
56
|
+
"@ai-sdk/deepseek": "^3.0.13",
|
|
57
|
+
"@ai-sdk/google": "^4.0.24",
|
|
58
|
+
"@ai-sdk/moonshotai": "^3.0.17",
|
|
59
|
+
"@ai-sdk/openai": "^4.0.20",
|
|
60
|
+
"@ai-sdk/openai-compatible": "^3.0.14",
|
|
61
|
+
"@ai-sdk/xai": "^4.0.18",
|
|
62
|
+
"ai": "^7.0.37",
|
|
63
|
+
"ai-sdk-ollama": "^4.1.0"
|
|
64
|
+
},
|
|
65
|
+
"engines": {
|
|
66
|
+
"node": ">=18"
|
|
67
|
+
},
|
|
68
|
+
"signalk": {
|
|
69
|
+
"displayName": "eInk Label GenAI Rendering",
|
|
70
|
+
"requires": [
|
|
71
|
+
"@rhizomatics/signalk-einklabel-plugin"
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +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}.
|
|
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.
|
|
4
|
+
|
|
5
|
+
{source=label,path=description}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Summarize the maritime weather outlook for the next 24 hours, appropriate for the sailor of a {design.length.overall}m {design.aisShipType.name} craft positioned at latitude {navigation.position.latitude}, longitude {navigation.position.longitude}
|