@gzmagyari/kanbanboard 1.0.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/llm.mjs ADDED
@@ -0,0 +1,307 @@
1
+ import { nanoid } from 'nanoid';
2
+ import { request as httpRequest } from 'node:http';
3
+ import { request as httpsRequest } from 'node:https';
4
+ import { URL } from 'node:url';
5
+ import { db } from './db.mjs';
6
+
7
+ function nowMs() {
8
+ return Date.now();
9
+ }
10
+
11
+ function parseEnabled(val) {
12
+ if (val === undefined || val === null) return false;
13
+ const s = String(val).trim().toLowerCase();
14
+ return s === '1' || s === 'true' || s === 'yes' || s === 'on';
15
+ }
16
+
17
+ export function getLLMConfig() {
18
+ // This is an OpenAI-compatible chat completions client.
19
+ // Works with LiteLLM proxy, OpenRouter direct, etc.
20
+ const enabled = parseEnabled(process.env.LLM_ENABLED);
21
+ const base_url =
22
+ (process.env.LLM_BASE_URL || process.env.OPENAI_BASE_URL || process.env.OPENROUTER_BASE_URL || '').trim();
23
+ const api_key =
24
+ (process.env.LLM_API_KEY || process.env.OPENAI_API_KEY || process.env.OPENROUTER_API_KEY || '').trim();
25
+ const model = (process.env.LLM_MODEL || 'openrouter/google/gemini-3-pro-preview').trim();
26
+ const timeout_ms = Number(process.env.LLM_TIMEOUT_MS || 45_000);
27
+ const temperature = process.env.LLM_TEMPERATURE !== undefined ? Number(process.env.LLM_TEMPERATURE) : 0;
28
+
29
+ return {
30
+ enabled,
31
+ base_url,
32
+ api_key,
33
+ model,
34
+ timeout_ms: Number.isFinite(timeout_ms) ? timeout_ms : 45_000,
35
+ temperature: Number.isFinite(temperature) ? temperature : 0
36
+ };
37
+ }
38
+
39
+ export function isLLMEnabled() {
40
+ const cfg = getLLMConfig();
41
+ return !!(cfg.enabled && cfg.base_url && cfg.model);
42
+ }
43
+
44
+ function urlCandidates(baseUrl) {
45
+ const b = String(baseUrl || '').trim().replace(/\/+$/, '');
46
+ if (!b) return [];
47
+
48
+ // If the user already provided the full completions endpoint, use as-is
49
+ if (b.endsWith('/chat/completions')) {
50
+ return [b];
51
+ }
52
+
53
+ // Many OpenAI-compatible services expose:
54
+ // - <base>/chat/completions (LiteLLM proxy when base_url has no /v1)
55
+ // - <base>/v1/chat/completions (some servers)
56
+ // If base_url ends with /v1, prefer /chat/completions.
57
+ if (b.endsWith('/v1')) {
58
+ return [`${b}/chat/completions`];
59
+ }
60
+
61
+ return [`${b}/chat/completions`, `${b}/v1/chat/completions`];
62
+ }
63
+
64
+ function postJson(url, body, headers = {}, timeoutMs = 45_000) {
65
+ const u = new URL(url);
66
+ const isHttps = u.protocol === 'https:';
67
+ const reqFn = isHttps ? httpsRequest : httpRequest;
68
+
69
+ const payload = JSON.stringify(body);
70
+ const finalHeaders = {
71
+ 'content-type': 'application/json',
72
+ 'content-length': Buffer.byteLength(payload),
73
+ ...headers
74
+ };
75
+
76
+ // Remove undefined headers (node will throw in some versions)
77
+ for (const k of Object.keys(finalHeaders)) {
78
+ if (finalHeaders[k] === undefined) delete finalHeaders[k];
79
+ }
80
+
81
+ return new Promise((resolve, reject) => {
82
+ const req = reqFn(
83
+ {
84
+ protocol: u.protocol,
85
+ hostname: u.hostname,
86
+ port: u.port || (isHttps ? 443 : 80),
87
+ path: `${u.pathname}${u.search}`,
88
+ method: 'POST',
89
+ headers: finalHeaders
90
+ },
91
+ (res) => {
92
+ let text = '';
93
+ res.setEncoding('utf8');
94
+ res.on('data', (chunk) => (text += chunk));
95
+ res.on('end', () => resolve({ status: res.statusCode || 0, text, headers: res.headers }));
96
+ }
97
+ );
98
+
99
+ req.setTimeout(timeoutMs, () => {
100
+ req.destroy(new Error(`LLM request timeout after ${timeoutMs}ms`));
101
+ });
102
+
103
+ req.on('error', reject);
104
+ req.write(payload);
105
+ req.end();
106
+ });
107
+ }
108
+
109
+ function safeJsonParse(text) {
110
+ try {
111
+ return JSON.parse(text);
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * OpenAI-compatible chat completion runner, with DB logging (llm_runs).
119
+ *
120
+ * @param {object} args
121
+ * @param {string} args.op
122
+ * @param {string|null} args.projectId
123
+ * @param {string|null} args.entityType
124
+ * @param {string|null} args.entityId
125
+ * @param {Array} args.messages
126
+ * @param {string=} args.model
127
+ * @param {number=} args.temperature
128
+ * @returns {Promise<{run_id: string, url: string, data: any, content: string}>}
129
+ */
130
+ export async function llmChat(args) {
131
+ const cfg = getLLMConfig();
132
+ if (!isLLMEnabled()) {
133
+ const err = new Error('LLM is not enabled/configured (set LLM_ENABLED=1 and LLM_BASE_URL)');
134
+ err.statusCode = 400;
135
+ throw err;
136
+ }
137
+
138
+ const model = (args.model || cfg.model).trim();
139
+ const temperature = args.temperature !== undefined ? args.temperature : cfg.temperature;
140
+
141
+ const requestBody = {
142
+ model,
143
+ messages: args.messages || [],
144
+ temperature: Number.isFinite(temperature) ? temperature : 0,
145
+ response_format: args.response_format || { type: 'json_object' }
146
+ };
147
+
148
+ const run_id = nanoid(12);
149
+ const created_at = nowMs();
150
+ const logBody = parseEnabled(process.env.LLM_LOG_BODY ?? '1');
151
+
152
+ const reqLog = logBody ? JSON.stringify(requestBody) : JSON.stringify({ model, message_count: (requestBody.messages || []).length, note: 'body omitted (LLM_LOG_BODY=0)' });
153
+
154
+ db.prepare(`
155
+ INSERT INTO llm_runs (id, op, project_id, entity_type, entity_id, model, request_json, response_json, error, created_at)
156
+ VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?)
157
+ `).run(
158
+ run_id,
159
+ String(args.op || 'unknown'),
160
+ args.projectId ?? null,
161
+ args.entityType ?? null,
162
+ args.entityId ?? null,
163
+ model,
164
+ reqLog,
165
+ created_at
166
+ );
167
+
168
+ const authHeader = cfg.api_key ? { Authorization: `Bearer ${cfg.api_key}` } : {};
169
+ const candidates = urlCandidates(cfg.base_url);
170
+ let lastErr = null;
171
+
172
+ for (const url of candidates) {
173
+ try {
174
+ const { status, text } = await postJson(url, requestBody, authHeader, cfg.timeout_ms);
175
+
176
+ // Retry alternative path if endpoint is missing
177
+ if (status === 404 || status === 405) {
178
+ lastErr = new Error(`LLM endpoint not found (${status}) at ${url}`);
179
+ continue;
180
+ }
181
+
182
+ if (status < 200 || status >= 300) {
183
+ lastErr = new Error(`LLM HTTP ${status}: ${String(text || '').slice(0, 600)}`);
184
+ const errLog = logBody ? JSON.stringify({ url, status, text }) : JSON.stringify({ url, status, note: 'body omitted' });
185
+ db.prepare(`UPDATE llm_runs SET response_json = ?, error = ? WHERE id = ?`)
186
+ .run(errLog, lastErr.message, run_id);
187
+ throw lastErr;
188
+ }
189
+
190
+ const parsed = safeJsonParse(text) ?? { raw_text: text };
191
+ const content = parsed?.choices?.[0]?.message?.content ?? '';
192
+
193
+ const resLog = logBody ? JSON.stringify({ url, status, response: parsed }) : JSON.stringify({ url, status, content_length: content.length, note: 'body omitted' });
194
+ db.prepare(`UPDATE llm_runs SET response_json = ?, error = NULL WHERE id = ?`)
195
+ .run(resLog, run_id);
196
+
197
+ return { run_id, url, data: parsed, content };
198
+ } catch (e) {
199
+ lastErr = e;
200
+ // Try next candidate URL if available
201
+ }
202
+ }
203
+
204
+ const msg = String(lastErr?.message || lastErr || 'LLM request failed');
205
+ db.prepare(`UPDATE llm_runs SET error = ? WHERE id = ?`).run(msg, run_id);
206
+ throw lastErr || new Error(msg);
207
+ }
208
+
209
+ /**
210
+ * OpenAI-compatible chat completion with tool/function-calling support.
211
+ * Unlike llmChat(), this does NOT force response_format: json_object,
212
+ * and it accepts optional `tools` and `tool_choice` parameters.
213
+ *
214
+ * @param {object} args
215
+ * @param {string} args.op
216
+ * @param {string|null} args.projectId
217
+ * @param {string|null} args.entityType
218
+ * @param {string|null} args.entityId
219
+ * @param {Array} args.messages
220
+ * @param {Array=} args.tools - OpenAI tool definitions
221
+ * @param {string=} args.tool_choice - 'auto' | 'none' | specific
222
+ * @param {string=} args.model
223
+ * @param {number=} args.temperature
224
+ * @returns {Promise<{run_id: string, url: string, data: any, content: string, tool_calls: Array}>}
225
+ */
226
+ export async function llmChatWithTools(args) {
227
+ const cfg = getLLMConfig();
228
+ if (!isLLMEnabled()) {
229
+ const err = new Error('LLM is not enabled/configured (set LLM_ENABLED=1 and LLM_BASE_URL)');
230
+ err.statusCode = 400;
231
+ throw err;
232
+ }
233
+
234
+ const model = (args.model || cfg.model).trim();
235
+ const temperature = args.temperature !== undefined ? args.temperature : cfg.temperature;
236
+
237
+ const requestBody = {
238
+ model,
239
+ messages: args.messages || [],
240
+ temperature: Number.isFinite(temperature) ? temperature : 0
241
+ };
242
+
243
+ if (args.tools && args.tools.length > 0) {
244
+ requestBody.tools = args.tools;
245
+ requestBody.tool_choice = args.tool_choice || 'auto';
246
+ }
247
+
248
+ const run_id = nanoid(12);
249
+ const created_at = nowMs();
250
+ const logBody = parseEnabled(process.env.LLM_LOG_BODY ?? '1');
251
+
252
+ const reqLog = logBody ? JSON.stringify(requestBody) : JSON.stringify({ model, message_count: (requestBody.messages || []).length, note: 'body omitted (LLM_LOG_BODY=0)' });
253
+
254
+ db.prepare(`
255
+ INSERT INTO llm_runs (id, op, project_id, entity_type, entity_id, model, request_json, response_json, error, created_at)
256
+ VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?)
257
+ `).run(
258
+ run_id,
259
+ String(args.op || 'unknown'),
260
+ args.projectId ?? null,
261
+ args.entityType ?? null,
262
+ args.entityId ?? null,
263
+ model,
264
+ reqLog,
265
+ created_at
266
+ );
267
+
268
+ const authHeader = cfg.api_key ? { Authorization: `Bearer ${cfg.api_key}` } : {};
269
+ const candidates = urlCandidates(cfg.base_url);
270
+ let lastErr = null;
271
+
272
+ for (const url of candidates) {
273
+ try {
274
+ const { status, text } = await postJson(url, requestBody, authHeader, cfg.timeout_ms);
275
+
276
+ if (status === 404 || status === 405) {
277
+ lastErr = new Error(`LLM endpoint not found (${status}) at ${url}`);
278
+ continue;
279
+ }
280
+
281
+ if (status < 200 || status >= 300) {
282
+ lastErr = new Error(`LLM HTTP ${status}: ${String(text || '').slice(0, 600)}`);
283
+ const errLog = logBody ? JSON.stringify({ url, status, text }) : JSON.stringify({ url, status, note: 'body omitted' });
284
+ db.prepare(`UPDATE llm_runs SET response_json = ?, error = ? WHERE id = ?`)
285
+ .run(errLog, lastErr.message, run_id);
286
+ throw lastErr;
287
+ }
288
+
289
+ const parsed = safeJsonParse(text) ?? { raw_text: text };
290
+ const message = parsed?.choices?.[0]?.message ?? {};
291
+ const content = message.content ?? '';
292
+ const tool_calls = message.tool_calls ?? [];
293
+
294
+ const resLog = logBody ? JSON.stringify({ url, status, response: parsed }) : JSON.stringify({ url, status, content_length: content.length, tool_calls_count: tool_calls.length, note: 'body omitted' });
295
+ db.prepare(`UPDATE llm_runs SET response_json = ?, error = NULL WHERE id = ?`)
296
+ .run(resLog, run_id);
297
+
298
+ return { run_id, url, data: parsed, content, tool_calls };
299
+ } catch (e) {
300
+ lastErr = e;
301
+ }
302
+ }
303
+
304
+ const msg = String(lastErr?.message || lastErr || 'LLM request failed');
305
+ db.prepare(`UPDATE llm_runs SET error = ? WHERE id = ?`).run(msg, run_id);
306
+ throw lastErr || new Error(msg);
307
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@gzmagyari/kanbanboard",
3
+ "version": "1.0.0",
4
+ "description": "AI-powered Kanban dashboard with LLM integration, Claude Code agents, and MCP server support",
5
+ "type": "module",
6
+ "bin": {
7
+ "kanbanboard": "./bin/cli.mjs"
8
+ },
9
+ "scripts": {
10
+ "start": "node server.mjs",
11
+ "dev": "node server.mjs"
12
+ },
13
+ "files": [
14
+ "bin/",
15
+ "lib/",
16
+ "public/",
17
+ "docs/",
18
+ "server.mjs",
19
+ "db.mjs",
20
+ "llm.mjs",
21
+ "repo-grounding.mjs",
22
+ "kanban-mcp-server.mjs",
23
+ "kanban.mjs",
24
+ "cron-sync.mjs",
25
+ ".env.example",
26
+ "API.md",
27
+ "README.md"
28
+ ],
29
+ "keywords": [
30
+ "kanban",
31
+ "dashboard",
32
+ "project-management",
33
+ "ai",
34
+ "llm",
35
+ "claude",
36
+ "mcp",
37
+ "sqlite",
38
+ "express",
39
+ "vuetify"
40
+ ],
41
+ "license": "MIT",
42
+ "engines": {
43
+ "node": ">=18.0.0"
44
+ },
45
+ "dependencies": {
46
+ "better-sqlite3": "^11.7.2",
47
+ "dotenv": "^17.2.4",
48
+ "express": "^4.19.2",
49
+ "morgan": "^1.10.0",
50
+ "nanoid": "^5.0.7"
51
+ }
52
+ }