@krmxd/onegpt 0.0.0-beta

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/src/web.js ADDED
@@ -0,0 +1,270 @@
1
+ // Token dashboard for oGPT - the Node twin of ogpt/web (Flask build).
2
+ // Same endpoints, same single-file UI, zero npm dependencies: served with
3
+ // the plain http module and wired into the SAME agent singleton the
4
+ // terminal uses, so dashboard and TUI share one brain and one counter.
5
+ const fs = require("fs");
6
+ const os = require("os");
7
+ const path = require("path");
8
+ const http = require("http");
9
+ const { getConfig } = require("./config");
10
+
11
+ const STARTED_AT = Date.now();
12
+ const runs = []; // per-run metrics, capped like Python's deque(maxlen=300)
13
+ let server = null;
14
+ let dashUrl = null;
15
+
16
+ function recordRun(stats) {
17
+ stats = stats || {};
18
+ runs.push({
19
+ ts: Date.now() / 1000,
20
+ tokens: stats.tokens || 0,
21
+ tps: stats.tps || 0,
22
+ ttft: stats.ttft,
23
+ duration: stats.duration,
24
+ promptTokens: stats.promptTokens || 0,
25
+ });
26
+ if (runs.length > 300) runs.splice(0, runs.length - 300);
27
+ }
28
+
29
+ function runsTail(n = 80) {
30
+ return runs.slice(-n);
31
+ }
32
+
33
+ function shortCwd() {
34
+ try {
35
+ const cwd = process.cwd();
36
+ const home = os.homedir();
37
+ return cwd.startsWith(home) ? "~" + cwd.slice(home.length) : cwd;
38
+ } catch {
39
+ return "";
40
+ }
41
+ }
42
+
43
+ function sessionsDir() {
44
+ return path.join(os.homedir(), ".config", "ogpt", "sessions");
45
+ }
46
+
47
+ // Simple async mutex so web requests never interleave AI turns with the TUI.
48
+ let chain = Promise.resolve();
49
+ function withLock(fn) {
50
+ const p = chain.then(fn, fn);
51
+ chain = p.catch(() => {});
52
+ return p;
53
+ }
54
+
55
+ function json(res, code, obj) {
56
+ const body = JSON.stringify(obj);
57
+ res.writeHead(code, { "Content-Type": "application/json" });
58
+ res.end(body);
59
+ }
60
+
61
+ function readBody(req) {
62
+ return new Promise((resolve) => {
63
+ let data = "";
64
+ req.on("data", (c) => {
65
+ data += c;
66
+ if (data.length > 1e6) req.destroy();
67
+ });
68
+ req.on("end", () => resolve(data));
69
+ req.on("error", () => resolve(""));
70
+ });
71
+ }
72
+
73
+ function historyView(agent) {
74
+ const out = [];
75
+ for (const m of agent.history || []) {
76
+ if (m.role === "tool" || m.role === "system") continue;
77
+ const content = String(m.content || "").trim();
78
+ if (!content || content.startsWith("[Conversation summary]")) continue;
79
+ if (m.toolCalls && m.role === "assistant") continue;
80
+ out.push({ role: m.role, ts: m.ts || null, content: content.slice(0, 4000) });
81
+ }
82
+ return out.slice(-60);
83
+ }
84
+
85
+ // Heuristic chunk classifier matching ogpt.ui.stream.classify_chunk.
86
+ function classifyChunk(chunk) {
87
+ if (typeof chunk !== "string") return ["text", chunk];
88
+ const s = chunk.trimStart();
89
+ if (s.startsWith("[Tool:")) return ["tool", chunk.trim()];
90
+ if (/^(⚠|\[error\]|\[Engine)/i.test(s)) return ["error", chunk];
91
+ return ["text", chunk];
92
+ }
93
+
94
+ async function chatOnce(cli, msg) {
95
+ let reply = "";
96
+ for await (const c of cli.agent.runStream(msg)) {
97
+ const [kind, payload] = classifyChunk(c);
98
+ if (kind === "text") reply += c;
99
+ }
100
+ return reply.trim();
101
+ }
102
+
103
+ function createDashboard(cli) {
104
+ return http.createServer(async (req, res) => {
105
+ const url = new URL(req.url, "http://localhost");
106
+ const cfg = cli.cfg || getConfig();
107
+ const agent = () => cli.agent;
108
+ const name = cfg.displayName();
109
+ try {
110
+ if (url.pathname === "/api/health") {
111
+ return json(res, 200, {
112
+ ok: true,
113
+ app: "OGPT",
114
+ version: "1.1.0",
115
+ model: name,
116
+ provider: "Ollama",
117
+ uptime_s: Math.round((Date.now() - STARTED_AT) / 100) / 10,
118
+ });
119
+ }
120
+
121
+ if (url.pathname === "/api/stats") {
122
+ const a = agent();
123
+ const usage = (a && a.usage) || { prompt: 0, completion: 0, total: 0 };
124
+ const lastRun = (a && a.lastRun) || {};
125
+ const tpsVals = runs.filter((r) => r.tps).map((r) => r.tps);
126
+ let session = null;
127
+ let sessionsCount = 0;
128
+ try {
129
+ sessionsCount = fs.readdirSync(sessionsDir()).filter((f) => f.endsWith(".meta.json")).length;
130
+ if (cli.currentSession) session = cli.currentSession;
131
+ else if (cli._sessionId) session = cli._sessionId;
132
+ } catch {}
133
+ let toolsCount = 0;
134
+ try {
135
+ toolsCount = cli.tools.listTools().length;
136
+ } catch {}
137
+ let modelsInstalled = 0;
138
+ try {
139
+ modelsInstalled = (await require("./catalog").listModels()).length;
140
+ } catch {}
141
+ return json(res, 200, {
142
+ ok: true,
143
+ version: "1.1.0",
144
+ model: name,
145
+ model_id: name,
146
+ provider: "Ollama",
147
+ cwd: shortCwd(),
148
+ uptime_s: Math.round((Date.now() - STARTED_AT) / 100) / 10,
149
+ session,
150
+ messages: a.history.filter((m) => !["tool", "system"].includes(m.role)).length,
151
+ tokens: { prompt: usage.prompt || 0, completion: usage.completion || 0, total: usage.total || 0 },
152
+ last_run: lastRun,
153
+ runs: runsTail(80),
154
+ runs_count: runs.length,
155
+ avg_tps: tpsVals.length ? Math.round((tpsVals.reduce((a2, b) => a2 + b, 0) / tpsVals.length) * 100) / 100 : null,
156
+ best_tps: tpsVals.length ? Math.max(...tpsVals) : null,
157
+ tools_count: toolsCount,
158
+ sessions_count: sessionsCount,
159
+ models_installed: modelsInstalled,
160
+ });
161
+ }
162
+
163
+ if (url.pathname === "/api/history") {
164
+ return json(res, 200, historyView(agent()));
165
+ }
166
+
167
+ if (url.pathname === "/api/chat" && req.method === "POST") {
168
+ const body = JSON.parse((await readBody(req)) || "{}");
169
+ const msg = String(body.message || "").trim();
170
+ if (!msg) return json(res, 400, { error: "message required" });
171
+ const reply = await withLock(() => chatOnce(cli, msg));
172
+ return json(res, 200, { reply, stats: agent().lastRun || {} });
173
+ }
174
+
175
+ if (url.pathname === "/api/chat/stream") {
176
+ const msg = (url.searchParams.get("msg") || "").trim();
177
+ if (!msg) return json(res, 400, { error: "msg required" });
178
+ res.writeHead(200, {
179
+ "Content-Type": "text/event-stream",
180
+ "Cache-Control": "no-cache",
181
+ "X-Accel-Buffering": "no",
182
+ });
183
+ const sse = (o) => res.write(`data: ${JSON.stringify(o)}\n\n`);
184
+ await withLock(async () => {
185
+ try {
186
+ for await (const c of agent().runStream(msg)) {
187
+ const [kind, payload] = classifyChunk(c);
188
+ if (kind === "tool") sse({ tool: payload });
189
+ else if (kind === "error") sse({ error: payload });
190
+ else sse({ t: c });
191
+ }
192
+ } catch (e) {
193
+ sse({ error: e.message });
194
+ }
195
+ });
196
+ sse({ done: true, stats: agent().lastRun || {} });
197
+ return res.end();
198
+ }
199
+
200
+ if (url.pathname === "/api/sessions") {
201
+ try {
202
+ const dir = sessionsDir();
203
+ const rows = fs.readdirSync(dir)
204
+ .filter((f) => f.endsWith(".meta.json"))
205
+ .map((f) => {
206
+ try {
207
+ const m = JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
208
+ return { id: m.id || f.replace(".meta.json", ""), title: m.title || "",
209
+ msgs: m.msgs ?? 0, updated: m.updated || 0 };
210
+ } catch { return null; }
211
+ })
212
+ .filter(Boolean)
213
+ .sort((a, b) => a.updated - b.updated)
214
+ .slice(-25);
215
+ return json(res, 200, rows);
216
+ } catch {
217
+ return json(res, 200, []);
218
+ }
219
+ }
220
+
221
+ if (url.pathname === "/api/tools") {
222
+ try {
223
+ return json(res, 200, cli.tools.listTools().map((t) => t.name));
224
+ } catch {
225
+ return json(res, 200, []);
226
+ }
227
+ }
228
+
229
+ if (url.pathname === "/" || url.pathname === "/index.html") {
230
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
231
+ return res.end(require("./static"));
232
+ }
233
+
234
+ json(res, 404, { error: "not found" });
235
+ } catch (e) {
236
+ json(res, 500, { error: e.message });
237
+ }
238
+ });
239
+ }
240
+
241
+ function listenOn(srv, host, port) {
242
+ return new Promise((resolve) => {
243
+ const onError = () => resolve(false);
244
+ srv.once("error", onError);
245
+ srv.listen(port, host, () => {
246
+ srv.removeListener("error", onError);
247
+ resolve(true);
248
+ });
249
+ });
250
+ }
251
+
252
+ async function startDashboard(cli) {
253
+ if (dashUrl) return dashUrl;
254
+ const cfg = cli.cfg || getConfig();
255
+ if (!cfg.get("web.enabled", true)) return null;
256
+ const host = String(cfg.get("web.host", "127.0.0.1"));
257
+ const basePort = parseInt(cfg.get("web.port", 8756), 10) || 8756;
258
+ const srv = createDashboard(cli);
259
+ for (let p = basePort; p < basePort + 12; p++) {
260
+ if (await listenOn(srv, host, p)) {
261
+ server = srv;
262
+ dashUrl = `http://${host}:${p}`;
263
+ return dashUrl;
264
+ }
265
+ srv.removeAllListeners("error");
266
+ }
267
+ return null;
268
+ }
269
+
270
+ module.exports = { startDashboard, recordRun, runsTail };