@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/.env.example +48 -0
- package/API.md +1256 -0
- package/README.md +138 -0
- package/bin/cli.mjs +437 -0
- package/cron-sync.mjs +9 -0
- package/db.mjs +378 -0
- package/docs/project-manager-chat.md +202 -0
- package/kanban-mcp-server.mjs +377 -0
- package/kanban.mjs +127 -0
- package/lib/paths.mjs +136 -0
- package/llm.mjs +307 -0
- package/package.json +52 -0
- package/public/index.html +4747 -0
- package/repo-grounding.mjs +417 -0
- package/server.mjs +8607 -0
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
|
+
}
|