@sean.holung/minicode 0.2.3 → 0.2.4
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 +13 -8
- package/dist/src/cli/args.js +31 -0
- package/dist/src/index.js +5 -0
- package/dist/src/serve/agent-bridge.js +233 -0
- package/dist/src/serve/openai-compat.js +144 -0
- package/dist/src/serve/server.js +251 -0
- package/dist/src/serve/types.js +2 -0
- package/dist/src/serve/websocket.js +28 -0
- package/dist/src/web/app.js +350 -0
- package/dist/src/web/index.html +49 -0
- package/dist/src/web/style.css +422 -0
- package/dist/tests/cli-args.test.js +4 -2
- package/dist/tests/serve.integration.test.js +534 -0
- package/node_modules/@minicode/agent-sdk/dist/src/model/client.d.ts.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/src/model/client.js +2 -0
- package/node_modules/@minicode/agent-sdk/dist/src/model/client.js.map +1 -1
- package/node_modules/@minicode/agent-sdk/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -3
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { AgentBridge } from "./agent-bridge.js";
|
|
6
|
+
import { createWebSocketServer } from "./websocket.js";
|
|
7
|
+
import { handleChatCompletions, handleModels } from "./openai-compat.js";
|
|
8
|
+
import { formatConfigForDisplay } from "../agent/config.js";
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
// Resolve web dir: works in both dev (src/serve/) and dist (dist/src/serve/)
|
|
11
|
+
const webDir = __dirname.includes(`${path.sep}dist${path.sep}`)
|
|
12
|
+
? path.resolve(__dirname, "../../src/web")
|
|
13
|
+
: path.resolve(__dirname, "../web");
|
|
14
|
+
const MIME_TYPES = {
|
|
15
|
+
".html": "text/html",
|
|
16
|
+
".css": "text/css",
|
|
17
|
+
".js": "application/javascript",
|
|
18
|
+
".json": "application/json",
|
|
19
|
+
};
|
|
20
|
+
function sendJson(res, status, body) {
|
|
21
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
22
|
+
res.end(JSON.stringify(body));
|
|
23
|
+
}
|
|
24
|
+
function readBody(req) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
const chunks = [];
|
|
27
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
28
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
|
|
29
|
+
req.on("error", reject);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
async function serveStatic(res, urlPath) {
|
|
33
|
+
const fileName = urlPath === "/" ? "index.html" : urlPath.slice(1);
|
|
34
|
+
const filePath = path.join(webDir, fileName);
|
|
35
|
+
// Prevent path traversal
|
|
36
|
+
if (!filePath.startsWith(webDir)) {
|
|
37
|
+
res.writeHead(403);
|
|
38
|
+
res.end("Forbidden");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const content = await readFile(filePath);
|
|
43
|
+
const ext = path.extname(filePath);
|
|
44
|
+
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
|
45
|
+
res.writeHead(200, { "Content-Type": contentType });
|
|
46
|
+
res.end(content);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
res.writeHead(404);
|
|
50
|
+
res.end("Not Found");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Create the HTTP request handler. Exported for testing. */
|
|
54
|
+
export function createRequestHandler(bridge) {
|
|
55
|
+
const config = bridge.getConfig();
|
|
56
|
+
return (req, res) => {
|
|
57
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
58
|
+
const method = req.method ?? "GET";
|
|
59
|
+
const pathname = url.pathname;
|
|
60
|
+
const handle = async () => {
|
|
61
|
+
// OpenAI-compatible routes
|
|
62
|
+
if (pathname === "/v1/models" && method === "GET") {
|
|
63
|
+
handleModels(req, res);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (pathname === "/v1/chat/completions" && method === "POST") {
|
|
67
|
+
await handleChatCompletions(req, res, bridge);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// Minicode REST API
|
|
71
|
+
if (pathname === "/api/status" && method === "GET") {
|
|
72
|
+
sendJson(res, 200, {
|
|
73
|
+
status: bridge.isBusy() ? "busy" : "ready",
|
|
74
|
+
workspace: config.workspaceRoot,
|
|
75
|
+
model: config.model,
|
|
76
|
+
provider: config.modelProvider,
|
|
77
|
+
});
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (pathname === "/api/config" && method === "GET") {
|
|
81
|
+
sendJson(res, 200, { config: formatConfigForDisplay(config) });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (pathname === "/api/sessions" && method === "GET") {
|
|
85
|
+
const sessions = await bridge.listSess();
|
|
86
|
+
sendJson(res, 200, { sessions });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (pathname === "/api/sessions/save" && method === "POST") {
|
|
90
|
+
const body = JSON.parse(await readBody(req));
|
|
91
|
+
const meta = await bridge.saveSess(body.label);
|
|
92
|
+
sendJson(res, 200, meta);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (pathname === "/api/sessions/load" && method === "POST") {
|
|
96
|
+
const body = JSON.parse(await readBody(req));
|
|
97
|
+
const result = await bridge.loadSess(body.label);
|
|
98
|
+
if (!result) {
|
|
99
|
+
sendJson(res, 404, { error: "Session not found" });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
sendJson(res, 200, { label: result.label });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
// ── Graph / Index API ──
|
|
106
|
+
if (pathname === "/api/symbols" && method === "GET") {
|
|
107
|
+
if (!bridge.hasIndex()) {
|
|
108
|
+
sendJson(res, 404, { error: "No project index available" });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
sendJson(res, 200, { symbols: bridge.getSymbols() });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (pathname.startsWith("/api/symbols/") && pathname.endsWith("/dependencies") && method === "GET") {
|
|
115
|
+
const name = decodeURIComponent(pathname.slice("/api/symbols/".length, -"/dependencies".length));
|
|
116
|
+
const depthParam = url.searchParams.get("depth");
|
|
117
|
+
const depth = depthParam ? Number(depthParam) : undefined;
|
|
118
|
+
const result = bridge.getDependencies(name, depth);
|
|
119
|
+
if (!result) {
|
|
120
|
+
sendJson(res, 404, { error: `Symbol "${name}" not found` });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
sendJson(res, 200, { symbol: name, dependencies: result });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (pathname.startsWith("/api/symbols/") && pathname.endsWith("/references") && method === "GET") {
|
|
127
|
+
const name = decodeURIComponent(pathname.slice("/api/symbols/".length, -"/references".length));
|
|
128
|
+
const result = bridge.getReferences(name);
|
|
129
|
+
if (!result) {
|
|
130
|
+
sendJson(res, 404, { error: `Symbol "${name}" not found` });
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
sendJson(res, 200, { symbol: name, references: result });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (pathname === "/api/code-map" && method === "GET") {
|
|
137
|
+
const budgetParam = url.searchParams.get("budget");
|
|
138
|
+
const budget = budgetParam ? Number(budgetParam) : undefined;
|
|
139
|
+
const result = bridge.getCodeMap(budget);
|
|
140
|
+
if (!result) {
|
|
141
|
+
sendJson(res, 404, { error: "No project index available" });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
sendJson(res, 200, result);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (pathname === "/api/graph" && method === "GET") {
|
|
148
|
+
const result = bridge.getGraph();
|
|
149
|
+
if (!result) {
|
|
150
|
+
sendJson(res, 404, { error: "No project index available" });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
sendJson(res, 200, result);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (pathname === "/api/focus" && method === "GET") {
|
|
157
|
+
sendJson(res, 200, { pinned: bridge.getPinnedSymbols() });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (pathname === "/api/focus" && method === "POST") {
|
|
161
|
+
const body = JSON.parse(await readBody(req));
|
|
162
|
+
if (!body.symbol || !body.action) {
|
|
163
|
+
sendJson(res, 400, { error: "action and symbol are required" });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (body.action === "pin") {
|
|
167
|
+
const ok = bridge.pinSymbol(body.symbol);
|
|
168
|
+
if (!ok) {
|
|
169
|
+
sendJson(res, 404, { error: `Symbol "${body.symbol}" not found` });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
sendJson(res, 200, { pinned: bridge.getPinnedSymbols() });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (body.action === "unpin") {
|
|
176
|
+
bridge.unpinSymbol(body.symbol);
|
|
177
|
+
sendJson(res, 200, { pinned: bridge.getPinnedSymbols() });
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
sendJson(res, 400, { error: `Unknown action "${body.action}". Use "pin" or "unpin".` });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (pathname === "/api/chat" && method === "POST") {
|
|
184
|
+
const body = JSON.parse(await readBody(req));
|
|
185
|
+
if (!body.message) {
|
|
186
|
+
sendJson(res, 400, { error: "message is required" });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (bridge.isBusy()) {
|
|
190
|
+
sendJson(res, 429, { error: "Agent is busy" });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
try {
|
|
194
|
+
const result = await bridge.runTurn(body.message);
|
|
195
|
+
sendJson(res, 200, { text: result.text, usage: result.usage });
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
199
|
+
sendJson(res, 500, { error: msg });
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
// Static files
|
|
204
|
+
await serveStatic(res, pathname);
|
|
205
|
+
};
|
|
206
|
+
handle().catch((error) => {
|
|
207
|
+
const msg = error instanceof Error ? error.message : "Unknown error";
|
|
208
|
+
sendJson(res, 500, { error: msg });
|
|
209
|
+
});
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
export async function runServe(verbose, port) {
|
|
213
|
+
console.log("Initializing agent...");
|
|
214
|
+
// Set up broadcast plumbing
|
|
215
|
+
let broadcastFn = () => { };
|
|
216
|
+
const bridge = new AgentBridge((msg) => broadcastFn(msg), verbose);
|
|
217
|
+
await bridge.init();
|
|
218
|
+
const config = bridge.getConfig();
|
|
219
|
+
const handler = createRequestHandler(bridge);
|
|
220
|
+
const server = createServer(handler);
|
|
221
|
+
// WebSocket server — captures the real broadcast function
|
|
222
|
+
const wss = createWebSocketServer(server, bridge);
|
|
223
|
+
// Wire up the broadcast: WS clients receive all agent events
|
|
224
|
+
const { WebSocket } = await import("ws");
|
|
225
|
+
broadcastFn = (msg) => {
|
|
226
|
+
const data = JSON.stringify(msg);
|
|
227
|
+
for (const client of wss.clients) {
|
|
228
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
229
|
+
client.send(data);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
// Graceful shutdown
|
|
234
|
+
process.on("SIGINT", () => {
|
|
235
|
+
console.log("\nShutting down...");
|
|
236
|
+
wss.close();
|
|
237
|
+
server.close(() => {
|
|
238
|
+
process.exit(0);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
server.listen(port, "127.0.0.1", () => {
|
|
242
|
+
console.log(`\nminicode serve`);
|
|
243
|
+
console.log(` Workspace: ${config.workspaceRoot}`);
|
|
244
|
+
console.log(` Model: ${config.model} (${config.modelProvider})`);
|
|
245
|
+
console.log(` Web UI: http://localhost:${port}`);
|
|
246
|
+
console.log(` OpenAI: http://localhost:${port}/v1`);
|
|
247
|
+
console.log(`\nPress Ctrl+C to stop.\n`);
|
|
248
|
+
});
|
|
249
|
+
// Keep alive
|
|
250
|
+
await new Promise(() => { });
|
|
251
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { WebSocketServer } from "ws";
|
|
2
|
+
export function createWebSocketServer(httpServer, bridge) {
|
|
3
|
+
const wss = new WebSocketServer({ server: httpServer });
|
|
4
|
+
wss.on("connection", (ws) => {
|
|
5
|
+
ws.on("message", (raw) => {
|
|
6
|
+
let msg;
|
|
7
|
+
try {
|
|
8
|
+
msg = JSON.parse(String(raw));
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
if (msg.type === "chat") {
|
|
14
|
+
if (bridge.isBusy()) {
|
|
15
|
+
ws.send(JSON.stringify({ type: "busy" }));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
bridge.runTurn(msg.message).catch(() => {
|
|
19
|
+
// errors already broadcast via agent-bridge
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
else if (msg.type === "cancel") {
|
|
23
|
+
bridge.cancel();
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
return wss;
|
|
28
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
const messagesEl = document.getElementById("messages");
|
|
2
|
+
const chatForm = document.getElementById("chat-form");
|
|
3
|
+
const chatInput = document.getElementById("chat-input");
|
|
4
|
+
const sendBtn = document.getElementById("send-btn");
|
|
5
|
+
const cancelBtn = document.getElementById("cancel-btn");
|
|
6
|
+
const statusBadge = document.getElementById("status-badge");
|
|
7
|
+
const modelInfo = document.getElementById("model-info");
|
|
8
|
+
const sessionBtn = document.getElementById("session-btn");
|
|
9
|
+
const sessionDropdown = document.getElementById("session-dropdown");
|
|
10
|
+
const sessionList = document.getElementById("session-list");
|
|
11
|
+
const saveBtn = document.getElementById("save-btn");
|
|
12
|
+
const saveLabelInput = document.getElementById("save-label");
|
|
13
|
+
|
|
14
|
+
let ws;
|
|
15
|
+
let currentAssistantEl = null;
|
|
16
|
+
let assistantText = "";
|
|
17
|
+
|
|
18
|
+
// Max chars to show in expanded tool result
|
|
19
|
+
const TOOL_RESULT_MAX = 500;
|
|
20
|
+
|
|
21
|
+
function connect() {
|
|
22
|
+
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
|
23
|
+
ws = new WebSocket(`${protocol}//${location.host}`);
|
|
24
|
+
|
|
25
|
+
ws.onopen = () => {
|
|
26
|
+
setStatus("ready");
|
|
27
|
+
fetchStatus();
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
ws.onclose = () => {
|
|
31
|
+
setStatus("error");
|
|
32
|
+
setTimeout(connect, 2000);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
ws.onmessage = (event) => {
|
|
36
|
+
const msg = JSON.parse(event.data);
|
|
37
|
+
handleServerMessage(msg);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function setStatus(state) {
|
|
42
|
+
statusBadge.textContent = state;
|
|
43
|
+
statusBadge.className = `badge ${state}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function setBusy(busy) {
|
|
47
|
+
sendBtn.disabled = busy;
|
|
48
|
+
sendBtn.classList.toggle("hidden", busy);
|
|
49
|
+
cancelBtn.classList.toggle("hidden", !busy);
|
|
50
|
+
if (busy) {
|
|
51
|
+
setStatus("busy");
|
|
52
|
+
} else {
|
|
53
|
+
setStatus("ready");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function fetchStatus() {
|
|
58
|
+
try {
|
|
59
|
+
const res = await fetch("/api/status");
|
|
60
|
+
const data = await res.json();
|
|
61
|
+
modelInfo.textContent = `${data.model} · ${data.provider}`;
|
|
62
|
+
} catch {
|
|
63
|
+
// ignore
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function handleServerMessage(msg) {
|
|
68
|
+
switch (msg.type) {
|
|
69
|
+
case "turn_start":
|
|
70
|
+
assistantText = "";
|
|
71
|
+
currentAssistantEl = addMessage("", "assistant");
|
|
72
|
+
currentAssistantEl.classList.add("streaming-cursor");
|
|
73
|
+
setBusy(true);
|
|
74
|
+
break;
|
|
75
|
+
|
|
76
|
+
case "streaming_chunk":
|
|
77
|
+
assistantText += msg.content;
|
|
78
|
+
if (currentAssistantEl) {
|
|
79
|
+
currentAssistantEl.textContent = assistantText;
|
|
80
|
+
scrollToBottom();
|
|
81
|
+
}
|
|
82
|
+
break;
|
|
83
|
+
|
|
84
|
+
case "thinking":
|
|
85
|
+
addMessage(msg.content, "thinking");
|
|
86
|
+
break;
|
|
87
|
+
|
|
88
|
+
case "step":
|
|
89
|
+
// Intentionally not shown — tool calls provide enough context
|
|
90
|
+
break;
|
|
91
|
+
|
|
92
|
+
case "tool_call_start":
|
|
93
|
+
addToolCall(msg.name, msg.input);
|
|
94
|
+
break;
|
|
95
|
+
|
|
96
|
+
case "tool_call_end":
|
|
97
|
+
finalizeToolCall(msg.name, msg.result, msg.elapsedMs);
|
|
98
|
+
break;
|
|
99
|
+
|
|
100
|
+
case "turn_end":
|
|
101
|
+
if (currentAssistantEl) {
|
|
102
|
+
currentAssistantEl.classList.remove("streaming-cursor");
|
|
103
|
+
if (!assistantText && msg.text) {
|
|
104
|
+
currentAssistantEl.textContent = msg.text;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
currentAssistantEl = null;
|
|
108
|
+
assistantText = "";
|
|
109
|
+
setBusy(false);
|
|
110
|
+
if (msg.usage) {
|
|
111
|
+
addUsageInfo(msg.usage);
|
|
112
|
+
}
|
|
113
|
+
break;
|
|
114
|
+
|
|
115
|
+
case "error":
|
|
116
|
+
addMessage(`Error: ${msg.message}`, "error");
|
|
117
|
+
if (currentAssistantEl) {
|
|
118
|
+
currentAssistantEl.classList.remove("streaming-cursor");
|
|
119
|
+
}
|
|
120
|
+
currentAssistantEl = null;
|
|
121
|
+
assistantText = "";
|
|
122
|
+
setBusy(false);
|
|
123
|
+
break;
|
|
124
|
+
|
|
125
|
+
case "busy":
|
|
126
|
+
addMessage("Agent is busy. Please wait for the current turn to finish.", "error");
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function addMessage(text, type) {
|
|
132
|
+
const el = document.createElement("div");
|
|
133
|
+
el.className = `message ${type}`;
|
|
134
|
+
el.textContent = text;
|
|
135
|
+
messagesEl.appendChild(el);
|
|
136
|
+
scrollToBottom();
|
|
137
|
+
return el;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Extract the most meaningful short arg from tool input.
|
|
142
|
+
* e.g. for read_file → the path, for search → the query, for run_command → the command.
|
|
143
|
+
*/
|
|
144
|
+
function summarizeToolInput(name, input) {
|
|
145
|
+
// Priority keys by tool type
|
|
146
|
+
const key =
|
|
147
|
+
input.path ?? input.file_path ?? input.command ?? input.query ??
|
|
148
|
+
input.pattern ?? input.name ?? input.old_string;
|
|
149
|
+
|
|
150
|
+
if (typeof key === "string") {
|
|
151
|
+
return key.length > 60 ? key.slice(0, 57) + "..." : key;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Fallback: first string value, truncated
|
|
155
|
+
for (const v of Object.values(input)) {
|
|
156
|
+
if (typeof v === "string" && v.length > 0) {
|
|
157
|
+
return v.length > 60 ? v.slice(0, 57) + "..." : v;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return "";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function getOrCreateToolGroup() {
|
|
164
|
+
const last = messagesEl.lastElementChild;
|
|
165
|
+
if (last && last.classList.contains("tool-group")) {
|
|
166
|
+
return last;
|
|
167
|
+
}
|
|
168
|
+
const group = document.createElement("div");
|
|
169
|
+
group.className = "tool-group";
|
|
170
|
+
messagesEl.appendChild(group);
|
|
171
|
+
return group;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function addToolCall(name, input) {
|
|
175
|
+
const group = getOrCreateToolGroup();
|
|
176
|
+
|
|
177
|
+
const el = document.createElement("div");
|
|
178
|
+
el.className = "tool-call";
|
|
179
|
+
el.dataset.toolName = name;
|
|
180
|
+
|
|
181
|
+
const summary = summarizeToolInput(name, input);
|
|
182
|
+
const summaryHtml = summary ? ` <span class="tool-arg">${escapeHtml(summary)}</span>` : "";
|
|
183
|
+
|
|
184
|
+
el.innerHTML =
|
|
185
|
+
`<span class="tool-header">` +
|
|
186
|
+
`<span class="tool-name">${escapeHtml(name)}</span>${summaryHtml}` +
|
|
187
|
+
`<span class="tool-time"></span>` +
|
|
188
|
+
`</span>` +
|
|
189
|
+
`<div class="tool-result"></div>`;
|
|
190
|
+
|
|
191
|
+
el.addEventListener("click", () => el.classList.toggle("expanded"));
|
|
192
|
+
group.appendChild(el);
|
|
193
|
+
scrollToBottom();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function finalizeToolCall(name, result, elapsedMs) {
|
|
197
|
+
const toolEls = messagesEl.querySelectorAll(`.tool-call[data-tool-name="${name}"]`);
|
|
198
|
+
const el = toolEls[toolEls.length - 1];
|
|
199
|
+
if (!el) return;
|
|
200
|
+
|
|
201
|
+
const timeEl = el.querySelector(".tool-time");
|
|
202
|
+
if (timeEl) {
|
|
203
|
+
timeEl.textContent = `${elapsedMs}ms`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const resultEl = el.querySelector(".tool-result");
|
|
207
|
+
if (resultEl && result) {
|
|
208
|
+
const truncated = result.length > TOOL_RESULT_MAX
|
|
209
|
+
? result.slice(0, TOOL_RESULT_MAX) + `\n\n... (${result.length - TOOL_RESULT_MAX} more chars)`
|
|
210
|
+
: result;
|
|
211
|
+
resultEl.textContent = truncated;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function addUsageInfo(usage) {
|
|
216
|
+
const el = document.createElement("div");
|
|
217
|
+
el.className = "usage-info";
|
|
218
|
+
el.textContent = `${usage.inputTokens} in / ${usage.outputTokens} out`;
|
|
219
|
+
messagesEl.appendChild(el);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function scrollToBottom() {
|
|
223
|
+
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function escapeHtml(str) {
|
|
227
|
+
const div = document.createElement("div");
|
|
228
|
+
div.textContent = str;
|
|
229
|
+
return div.innerHTML;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Form handling
|
|
233
|
+
chatForm.addEventListener("submit", (e) => {
|
|
234
|
+
e.preventDefault();
|
|
235
|
+
const message = chatInput.value.trim();
|
|
236
|
+
if (!message) return;
|
|
237
|
+
|
|
238
|
+
addMessage(message, "user");
|
|
239
|
+
ws.send(JSON.stringify({ type: "chat", message }));
|
|
240
|
+
chatInput.value = "";
|
|
241
|
+
chatInput.style.height = "auto";
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
cancelBtn.addEventListener("click", () => {
|
|
245
|
+
ws.send(JSON.stringify({ type: "cancel" }));
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Auto-resize textarea
|
|
249
|
+
chatInput.addEventListener("input", () => {
|
|
250
|
+
chatInput.style.height = "auto";
|
|
251
|
+
chatInput.style.height = Math.min(chatInput.scrollHeight, 150) + "px";
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// Submit on Enter (Shift+Enter for newline)
|
|
255
|
+
chatInput.addEventListener("keydown", (e) => {
|
|
256
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
257
|
+
e.preventDefault();
|
|
258
|
+
chatForm.dispatchEvent(new Event("submit"));
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// ── Session management ──
|
|
263
|
+
|
|
264
|
+
sessionBtn.addEventListener("click", (e) => {
|
|
265
|
+
e.stopPropagation();
|
|
266
|
+
const isOpen = !sessionDropdown.classList.contains("hidden");
|
|
267
|
+
sessionDropdown.classList.toggle("hidden");
|
|
268
|
+
if (!isOpen) {
|
|
269
|
+
refreshSessionList();
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
// Close dropdown on outside click
|
|
274
|
+
document.addEventListener("click", (e) => {
|
|
275
|
+
if (!sessionDropdown.contains(e.target) && e.target !== sessionBtn) {
|
|
276
|
+
sessionDropdown.classList.add("hidden");
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
saveBtn.addEventListener("click", async () => {
|
|
281
|
+
const label = saveLabelInput.value.trim() || undefined;
|
|
282
|
+
try {
|
|
283
|
+
const res = await fetch("/api/sessions/save", {
|
|
284
|
+
method: "POST",
|
|
285
|
+
headers: { "Content-Type": "application/json" },
|
|
286
|
+
body: JSON.stringify({ label }),
|
|
287
|
+
});
|
|
288
|
+
if (res.ok) {
|
|
289
|
+
const data = await res.json();
|
|
290
|
+
saveLabelInput.value = "";
|
|
291
|
+
addMessage(`Session saved: "${data.label}"`, "thinking");
|
|
292
|
+
refreshSessionList();
|
|
293
|
+
}
|
|
294
|
+
} catch {
|
|
295
|
+
// ignore
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
saveLabelInput.addEventListener("keydown", (e) => {
|
|
300
|
+
if (e.key === "Enter") {
|
|
301
|
+
e.preventDefault();
|
|
302
|
+
saveBtn.click();
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
async function refreshSessionList() {
|
|
307
|
+
try {
|
|
308
|
+
const res = await fetch("/api/sessions");
|
|
309
|
+
const data = await res.json();
|
|
310
|
+
const sessions = data.sessions;
|
|
311
|
+
|
|
312
|
+
if (!sessions || sessions.length === 0) {
|
|
313
|
+
sessionList.innerHTML = '<div class="dropdown-empty">No saved sessions</div>';
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
sessionList.innerHTML = "";
|
|
318
|
+
for (const s of sessions) {
|
|
319
|
+
const el = document.createElement("div");
|
|
320
|
+
el.className = "session-item";
|
|
321
|
+
el.innerHTML =
|
|
322
|
+
`<span class="session-label">${escapeHtml(s.label)}</span>` +
|
|
323
|
+
`<span class="session-meta">${s.messageCount} msgs</span>`;
|
|
324
|
+
el.addEventListener("click", () => loadSession(s.label));
|
|
325
|
+
sessionList.appendChild(el);
|
|
326
|
+
}
|
|
327
|
+
} catch {
|
|
328
|
+
sessionList.innerHTML = '<div class="dropdown-empty">Failed to load sessions</div>';
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async function loadSession(label) {
|
|
333
|
+
try {
|
|
334
|
+
const res = await fetch("/api/sessions/load", {
|
|
335
|
+
method: "POST",
|
|
336
|
+
headers: { "Content-Type": "application/json" },
|
|
337
|
+
body: JSON.stringify({ label }),
|
|
338
|
+
});
|
|
339
|
+
if (res.ok) {
|
|
340
|
+
sessionDropdown.classList.add("hidden");
|
|
341
|
+
messagesEl.innerHTML = "";
|
|
342
|
+
addMessage(`Session "${label}" restored`, "thinking");
|
|
343
|
+
}
|
|
344
|
+
} catch {
|
|
345
|
+
// ignore
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Start connection
|
|
350
|
+
connect();
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>minicode</title>
|
|
7
|
+
<link rel="stylesheet" href="style.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="app">
|
|
11
|
+
<header>
|
|
12
|
+
<div class="header-left">
|
|
13
|
+
<h1>minicode</h1>
|
|
14
|
+
<span id="status-badge" class="badge ready">ready</span>
|
|
15
|
+
</div>
|
|
16
|
+
<div class="header-right">
|
|
17
|
+
<span id="model-info"></span>
|
|
18
|
+
<div class="session-menu">
|
|
19
|
+
<button id="session-btn" class="header-btn" title="Sessions">Sessions</button>
|
|
20
|
+
<div id="session-dropdown" class="dropdown hidden">
|
|
21
|
+
<div class="dropdown-section">
|
|
22
|
+
<div class="dropdown-row">
|
|
23
|
+
<input id="save-label" type="text" placeholder="Label (optional)" />
|
|
24
|
+
<button id="save-btn" class="dropdown-action">Save</button>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
<div class="dropdown-divider"></div>
|
|
28
|
+
<div id="session-list" class="dropdown-section">
|
|
29
|
+
<div class="dropdown-empty">No saved sessions</div>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
</header>
|
|
35
|
+
|
|
36
|
+
<main id="messages"></main>
|
|
37
|
+
|
|
38
|
+
<footer>
|
|
39
|
+
<form id="chat-form">
|
|
40
|
+
<textarea id="chat-input" placeholder="Send a message..." rows="1" autofocus></textarea>
|
|
41
|
+
<button type="submit" id="send-btn">Send</button>
|
|
42
|
+
<button type="button" id="cancel-btn" class="hidden">Cancel</button>
|
|
43
|
+
</form>
|
|
44
|
+
</footer>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<script type="module" src="app.js"></script>
|
|
48
|
+
</body>
|
|
49
|
+
</html>
|