signalk-ai-bridge 0.1.0-beta.1

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/README.md ADDED
@@ -0,0 +1,142 @@
1
+ # signalk-ai-bridge
2
+
3
+ `signalk-ai-bridge` is a Signal K plugin that adds an `Ask AI` panel to the Signal K web UI.
4
+
5
+ It lets you send selected Signal K vessel data to a local Ollama model such as Gemma, then read the response directly in the browser.
6
+
7
+ ## Experimental Plugin
8
+
9
+ This is an experimental study plugin.
10
+
11
+ It is intended for testing, evaluation, and local experimentation with AI-assisted vessel summaries inside Signal K. It should not be treated as a safety-critical navigation system, an authoritative decision-maker, or a production-hardened marine control feature.
12
+
13
+ ## What It Is
14
+
15
+ This plugin is a bridge between:
16
+
17
+ - Signal K vessel data
18
+ - a local Ollama AI model
19
+ - a simple web UI inside Signal K
20
+
21
+ It is meant for local, operator-facing use. You choose which Signal K paths are shared with the AI, write a question in plain language, and the plugin sends that question plus the selected vessel context to Ollama.
22
+
23
+ ## What It Does
24
+
25
+ With this plugin you can:
26
+
27
+ - ask for a vessel-state summary in plain language
28
+ - send selected Signal K paths to AI instead of the full data tree
29
+ - review the AI response in a readable panel
30
+ - see a history of previous AI requests
31
+ - inspect the actual request that was sent to the model
32
+ - check whether Ollama and the configured model are available
33
+
34
+ ## What You Need
35
+
36
+ - a running Signal K server
37
+ - this plugin installed in Signal K
38
+ - a running Ollama server
39
+ - a locally available Ollama model, for example `gemma4:e2b`
40
+
41
+ ## Quick Start
42
+
43
+ 1. Start Ollama.
44
+ 2. Make sure the model you want to use is available.
45
+ 3. Open the plugin configuration in Signal K.
46
+ 4. Set the Ollama URL and model name.
47
+ 5. Choose which Signal K paths should be sent to AI.
48
+ 6. Open the plugin web UI and press `Ask AI`.
49
+
50
+ ## Ollama With Docker Compose
51
+
52
+ If you do not already have Ollama running, you can use the included compose file:
53
+
54
+ [`docker-compose.gemma.yml`](https://github.com/KEGustafsson/signalk-ai-bridge/blob/main/docker-compose.gemma.yml)
55
+
56
+ Start it with:
57
+
58
+ ```bash
59
+ docker compose -f docker-compose.gemma.yml up -d
60
+ ```
61
+
62
+ This compose setup already pulls `gemma4:e2b` during startup, so you do not need to run a separate `ollama pull` command.
63
+
64
+ If Signal K runs on the host, the default Ollama URL `http://localhost:11434` is usually correct.
65
+
66
+ If Signal K runs in another container, use an address reachable from that container, for example `http://ollama:11434` on a shared Docker network.
67
+
68
+ ## Normal Use
69
+
70
+ In the web UI you will see:
71
+
72
+ - `Signal K`: login state and vessel self ID
73
+ - `Ollama / Gemma`: backend URL, model, AI status, and timeout
74
+ - `AI Path Selection`: which Signal K paths are currently sent to AI
75
+ - `AI Response`: the latest answer from the model
76
+ - `Ask AI History`: previous prompts and results
77
+
78
+ If AI is unavailable, the web UI also shows a help link that opens the Ollama setup instructions.
79
+
80
+ ## Important Plugin Settings
81
+
82
+ These are the settings most users will care about:
83
+
84
+ - `baseUrl`
85
+ Ollama server URL. Default: `http://localhost:11434`
86
+
87
+ - `model`
88
+ Ollama model name. Example: `gemma4:e2b`
89
+
90
+ - `aiDataPaths`
91
+ The Signal K self paths that will be sent to AI. You can use exact paths like `navigation.position` and simple wildcards like `navigation.*`
92
+
93
+ - `requestTimeoutMs`
94
+ How long the plugin waits for Ollama. Set `0` to disable the timeout
95
+
96
+ - `systemPrompt`
97
+ Extra instructions sent to the model before your question
98
+
99
+ - `temperature`
100
+ Lower values are more stable and literal. Higher values are more varied
101
+
102
+ - `topP`
103
+ Additional output randomness control
104
+
105
+ - `maxTokens`
106
+ The output/context budget forwarded to Ollama
107
+
108
+ ## Notes About Model Names
109
+
110
+ The plugin defaults to the Gemma 4 family.
111
+
112
+ If you configure `gemma4` but Ollama only has a tagged variant installed, such as `gemma4:e2b`, the plugin will try to resolve and use the installed tagged model automatically.
113
+
114
+ If you already know the exact installed model name, configuring that exact name is the clearest option.
115
+
116
+ ## Development
117
+
118
+ For local development:
119
+
120
+ ```bash
121
+ npm install
122
+ npm run dev
123
+ ```
124
+
125
+ Useful checks:
126
+
127
+ ```bash
128
+ npm run test
129
+ npm run check
130
+ ```
131
+
132
+ To remove generated build output:
133
+
134
+ ```bash
135
+ npm run clean
136
+ ```
137
+
138
+ To build the packaged web UI:
139
+
140
+ ```bash
141
+ npm run build
142
+ ```
package/index.cjs ADDED
@@ -0,0 +1,239 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ DEFAULT_AI_BASE_URL,
5
+ DEFAULT_AI_MODEL,
6
+ DEFAULT_SYSTEM_PROMPT,
7
+ getAiAvailability,
8
+ normalizeAiConfig,
9
+ queryAiModel,
10
+ readJsonBody
11
+ } = require('./lib/ai-service.cjs');
12
+ const { createBridgeService } = require('./lib/bridge-service.cjs');
13
+
14
+ module.exports = function createPlugin(app, dependencies = {}) {
15
+ let pluginOptions = {};
16
+ let routesRegistered = false;
17
+ const bridgeService = createBridgeService(app, dependencies);
18
+
19
+ function normalizeServerConfig(options = {}) {
20
+ const aiDataPaths = Array.isArray(options.aiDataPaths)
21
+ ? options.aiDataPaths.map((item) => String(item || '').trim()).filter(Boolean)
22
+ : [];
23
+
24
+ return {
25
+ aiDataPaths
26
+ };
27
+ }
28
+
29
+ const getConfig = () => ({
30
+ ...normalizeAiConfig(pluginOptions),
31
+ ...normalizeServerConfig(pluginOptions)
32
+ });
33
+
34
+ const schema = () => ({
35
+ type: 'object',
36
+ properties: {
37
+ enabled: {
38
+ type: 'boolean',
39
+ title: 'Enable AI pipeline',
40
+ default: true
41
+ },
42
+ baseUrl: {
43
+ type: 'string',
44
+ title: 'Ollama host',
45
+ description:
46
+ 'Ollama host URL. Leave blank to use AI_MODEL_URL or the default local Ollama server.',
47
+ default: DEFAULT_AI_BASE_URL
48
+ },
49
+ model: {
50
+ type: 'string',
51
+ title: 'AI model',
52
+ description: 'Ollama model name to send in chat requests.',
53
+ default: DEFAULT_AI_MODEL
54
+ },
55
+ systemPrompt: {
56
+ type: 'string',
57
+ title: 'System prompt',
58
+ description: 'Passed as a native Ollama system message before the operator request.',
59
+ default: DEFAULT_SYSTEM_PROMPT
60
+ },
61
+ requestTimeoutMs: {
62
+ type: 'integer',
63
+ title: 'Request timeout (ms)',
64
+ description: 'How long to wait for Ollama before failing. Set to 0 to disable the timeout.',
65
+ default: 120000,
66
+ minimum: 0,
67
+ maximum: 300000
68
+ },
69
+ temperature: {
70
+ type: 'number',
71
+ title: 'Temperature',
72
+ default: 0.2,
73
+ minimum: 0,
74
+ maximum: 2
75
+ },
76
+ topP: {
77
+ type: 'number',
78
+ title: 'Top-p',
79
+ default: 0.95,
80
+ minimum: 0,
81
+ maximum: 1
82
+ },
83
+ maxTokens: {
84
+ type: 'integer',
85
+ title: 'Max output tokens',
86
+ default: 131072,
87
+ minimum: 64,
88
+ maximum: 131072
89
+ },
90
+ aiDataPaths: {
91
+ type: 'array',
92
+ title: 'AI data paths',
93
+ description:
94
+ 'Signal K self paths to collect and send to AI. Exact paths and simple wildcards ending in .* are supported. You can type your own paths, for example navigation.position, navigation.*, environment.wind.speedApparent, or notifications.',
95
+ uniqueItems: true,
96
+ default: [
97
+ 'navigation.position',
98
+ 'navigation.speedOverGround',
99
+ 'navigation.courseOverGroundTrue',
100
+ 'notifications'
101
+ ],
102
+ items: {
103
+ type: 'string',
104
+ title: 'Signal K path'
105
+ }
106
+ }
107
+ }
108
+ });
109
+
110
+ const statusHandler = async (req, res) => {
111
+ try {
112
+ const config = getConfig();
113
+ const availability = await getAiAvailability(config, dependencies);
114
+ res.status(200).json({
115
+ enabled: config.enabled,
116
+ baseUrl: config.baseUrl,
117
+ model: config.model,
118
+ requestTimeoutMs: config.requestTimeoutMs,
119
+ maxTokens: config.maxTokens,
120
+ aiDataPaths: config.aiDataPaths,
121
+ signalKSelfId: typeof app.selfId === 'string' ? app.selfId : undefined,
122
+ aiAvailable: availability.available,
123
+ ollamaReachable: availability.backendReachable,
124
+ modelAvailable: availability.modelAvailable,
125
+ resolvedModel: availability.resolvedModel,
126
+ availabilityMessage: availability.message
127
+ });
128
+ } catch (error) {
129
+ res.status(500).json({
130
+ error: {
131
+ code: 'unknown',
132
+ message: error instanceof Error ? error.message : 'Unknown AI status failure.'
133
+ }
134
+ });
135
+ }
136
+ };
137
+
138
+ const queryHandler = async (req, res) => {
139
+ try {
140
+ const config = getConfig();
141
+ const body = await readJsonBody(req);
142
+ const payload = await bridgeService.buildAiPayload(body, config);
143
+ const result = await queryAiModel(payload, config, dependencies);
144
+ res.status(200).json(result);
145
+ } catch (error) {
146
+ const statusCode =
147
+ typeof error === 'object' &&
148
+ error !== null &&
149
+ 'statusCode' in error &&
150
+ typeof error.statusCode === 'number'
151
+ ? error.statusCode
152
+ : error && error.code === 'unauthorized'
153
+ ? 401
154
+ : error && error.code === 'validation-failed'
155
+ ? 400
156
+ : error && error.code === 'disabled'
157
+ ? 503
158
+ : error && error.code === 'timeout'
159
+ ? 504
160
+ : 502;
161
+
162
+ res.status(statusCode).json({
163
+ error: {
164
+ code:
165
+ typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string'
166
+ ? error.code
167
+ : 'unknown',
168
+ message: error instanceof Error ? error.message : 'Unknown AI backend failure.'
169
+ }
170
+ });
171
+ }
172
+ };
173
+
174
+ const bridgeExecuteHandler = async (req, res) => {
175
+ try {
176
+ const config = getConfig();
177
+ const body = await readJsonBody(req);
178
+ const result = await bridgeService.executeTool(body, config);
179
+ res.status(200).json(result);
180
+ } catch (error) {
181
+ const statusCode =
182
+ typeof error === 'object' &&
183
+ error !== null &&
184
+ 'statusCode' in error &&
185
+ typeof error.statusCode === 'number'
186
+ ? error.statusCode
187
+ : error && error.code === 'unauthorized'
188
+ ? 401
189
+ : error && error.code === 'validation-failed'
190
+ ? 400
191
+ : 500;
192
+
193
+ res.status(statusCode).json({
194
+ error: {
195
+ code:
196
+ typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string'
197
+ ? error.code
198
+ : 'unknown',
199
+ message: error instanceof Error ? error.message : 'Unknown bridge failure.'
200
+ }
201
+ });
202
+ }
203
+ };
204
+
205
+ return {
206
+ id: 'signalk-ai-bridge',
207
+ name: 'AI Bridge',
208
+ description: 'Signal K Ask AI plugin with embedded web UI for Ollama and Gemma.',
209
+ schema,
210
+ start: (options = {}) => {
211
+ pluginOptions = options;
212
+ bridgeService.reset();
213
+ const config = getConfig();
214
+ if (typeof app.setPluginStatus === 'function') {
215
+ app.setPluginStatus(
216
+ config.enabled
217
+ ? `AI Bridge ready: ${config.model} via ${config.baseUrl}`
218
+ : 'AI Bridge webapp assets available. AI pipeline disabled.'
219
+ );
220
+ }
221
+ },
222
+ registerWithRouter: (router) => {
223
+ if (routesRegistered) {
224
+ return;
225
+ }
226
+ router.get('/ai/status', statusHandler);
227
+ router.post('/ai/query', queryHandler);
228
+ router.post('/bridge/execute', bridgeExecuteHandler);
229
+ routesRegistered = true;
230
+ },
231
+ stop: () => {
232
+ if (typeof app.setPluginStatus === 'function') {
233
+ app.setPluginStatus('AI Bridge stopped.');
234
+ }
235
+ bridgeService.reset();
236
+ pluginOptions = {};
237
+ }
238
+ };
239
+ };