MiniLlama/cli/crowdcode.js
xray b9dadb479a fix: Server-Auto-Start im CLI — kein manueller Vorstart nötig
- CLI startet llama-server automatisch bei fehlender Verbindung
- Modellauswahl per Ziffer (Default=3=kleinstes)
- Live-Logs während Server-Start
- Timeout 120s für große Modelle vom USB-Stick
- Kein interaktives start-server.sh mehr nötig
2026-07-09 10:57:19 +02:00

571 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// crowdcode.js — MiniLlama Terminal CLI (Linux + Windows)
// Chat + Dateioperationen via llama.cpp API
// Aufruf: node crowdcode.js [endpoint] [model]
// ─── Konfiguration ────────────────────────────────────────────────────────────
const API = process.argv[2] || 'http://127.0.0.1:8080/v1';
const MODEL = process.argv[3] || '';
// ─── Terminal-Farben (ANSI, funktioniert auf Win/Lin) ─────────────────────────
const C = {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
red: '\x1b[31m',
grey: '\x1b[90m',
};
// ─── async/await readline ─────────────────────────────────────────────────────
const readline = require('readline');
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const WORKSPACE = process.argv[4] ? path.resolve(process.argv[4]) : process.cwd();
const SYSTEM_PROMPT = `You are CrowdCode, a concise coding assistant.
Context: @file reads a file, @dir/ lists a directory, @dir/** shows the tree, @?term searches in files.
When writing or rewriting a file, ALWAYS output FILE: path/to/file on its own line BEFORE the fenced code block with the complete file content.
To create a directory: DIR: path/to/dir on its own line.
For targeted edits: EDIT: path/to/file followed by ---OLD, old text, ---NEW, new text, --- on separate lines.
To run shell commands (only when user explicitly asks): output a fenced \`\`\`bash code block.
No explanations unless the user asks a question.
NEVER output touch, mkdir, echo, cat commands — use FILE: and DIR: instead.`;
function askSingleLine(prompt) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(prompt, (answer) => { rl.close(); resolve(answer.trim()); });
});
}
async function askInput() {
const line = await askSingleLine(`${C.cyan}${C.reset} `);
if (line === ':m') {
// Multiline-Modus
return askMultiline();
}
return line;
}
function askMultiline() {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
const lines = [];
console.log(`${C.dim} (Multiline — ${C.reset}:send${C.dim} zum Senden, ${C.reset}:cancel${C.dim} zum Abbrechen)${C.reset}`);
rl.on('line', (line) => {
const trimmed = line.trim();
if (trimmed === ':send' || trimmed === ':s') { rl.close(); return; }
if (trimmed === ':cancel' || trimmed === ':c') { rl.close(); resolve(null); return; }
lines.push(line);
});
rl.on('close', () => resolve(lines.join('\n').replace(/\n+$/, '')));
});
}
// ─── Modelle auflisten ─────────────────────────────────────────────────────────
async function fetchModels() {
try {
const res = await fetch(`${API}/models`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return data.data || data.models || [];
} catch { return []; }
}
// ─── Chat Completion (Streaming) ───────────────────────────────────────────────
async function chatComplete(messages, onToken) {
const model = MODEL || (await fetchModels())[0]?.id || 'unknown';
const res = await fetch(`${API}/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer local' },
body: JSON.stringify({ model, messages, stream: true, temperature: 0.2, top_p: 0.9 }),
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let fullText = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
for (const line of chunk.split('\n')) {
const trimmed = line.trim();
if (!trimmed.startsWith('data:')) continue;
const data = trimmed.slice(5).trim();
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
// reasoning_content für DeepSeek-artige Modelle
const reasoning = parsed.choices?.[0]?.delta?.reasoning_content || '';
const token = parsed.choices?.[0]?.delta?.content || '';
if (reasoning) {
fullText += reasoning;
onToken(reasoning, true);
}
if (token) {
fullText += token;
onToken(token, false);
}
} catch { /* skip malformed JSON */ }
}
}
return fullText;
}
// ─── @mention Auflösung ───────────────────────────────────────────────────────
async function resolveMentions(text) {
const mentionRegex = /@(\S+)/g;
let result = text;
for (const match of text.matchAll(mentionRegex)) {
const ref = match[1];
let replacement = '';
try {
const targetPath = path.resolve(WORKSPACE, ref);
// @dir/** — tree
if (ref.endsWith('/**')) {
const dirPath = ref.slice(0, -3);
replacement = await buildTree(path.resolve(WORKSPACE, dirPath), '');
}
// @dir/ — listing
else if (ref.endsWith('/')) {
const dirPath = ref.slice(0, -1);
const entries = fs.readdirSync(path.resolve(WORKSPACE, dirPath), { withFileTypes: true });
entries.sort((a, b) => {
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
return a.name.localeCompare(b.name);
});
replacement = entries.map(e => e.isDirectory() ? e.name + '/' : e.name).join('\n');
}
// @?term — grep
else if (ref.startsWith('?')) {
const term = ref.slice(1);
const isGlob = term.includes('*');
replacement = isGlob ? await globSearch(term) : await grepSearch(term);
}
// @file.ext — file read
else if (fs.existsSync(targetPath) && fs.statSync(targetPath).isFile()) {
const content = fs.readFileSync(targetPath, 'utf8');
const ext = ref.split('.').pop() || '';
replacement = `\n[Dateiinhalt: ${ref}]\n\`\`\`${ext}\n${content}\n\`\`\`\n`;
}
} catch { replacement = `(${ref}: nicht gefunden) ⚠️`; }
result = result.replace(match[0], replacement);
}
return result;
}
async function buildTree(dirPath, prefix) {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
entries.sort((a, b) => {
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
return a.name.localeCompare(b.name);
});
const lines = [];
for (let i = 0; i < entries.length; i++) {
const e = entries[i];
const isLast = i === entries.length - 1;
const conn = isLast ? '└── ' : '├── ';
lines.push(`${prefix}${conn}${e.name}${e.isDirectory() ? '/' : ''}`);
if (e.isDirectory()) {
const childPrefix = prefix + (isLast ? ' ' : '│ ');
const sub = await buildTree(path.join(dirPath, e.name), childPrefix);
lines.push(...sub);
}
}
return lines.join('\n');
}
async function grepSearch(term) {
const results = [];
const maxFiles = 20;
const maxPerFile = 5;
let filesWithMatches = 0;
const allFiles = walkSync(WORKSPACE).filter(f => !f.includes('node_modules') && !f.includes('.git'));
for (const file of allFiles) {
if (filesWithMatches >= maxFiles) break;
try {
const content = fs.readFileSync(file, 'utf8');
const fileResults = [];
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
if (lines[i].toLowerCase().includes(term.toLowerCase())) {
const relPath = path.relative(WORKSPACE, file);
fileResults.push(` ${relPath}:${i + 1}: ${lines[i].trim()}`);
if (fileResults.length >= maxPerFile) break;
}
}
if (fileResults.length > 0) { results.push(...fileResults); filesWithMatches++; }
} catch { /* skip */ }
}
return results.length > 0 ? results.join('\n') : `(keine Treffer für "${term}")`;
}
async function globSearch(pattern) {
const allFiles = walkSync(WORKSPACE).filter(f => !f.includes('node_modules') && !f.includes('.git'));
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i');
const matches = allFiles.map(f => path.relative(WORKSPACE, f)).filter(f => regex.test(f));
return matches.length > 0 ? matches.join('\n') : `(keine Dateien für "${pattern}")`;
}
function walkSync(dir) {
const files = [];
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name !== 'node_modules' && entry.name !== '.git') files.push(...walkSync(fullPath));
} else { files.push(fullPath); }
}
} catch { /* permission denied, skip */ }
return files;
}
// ─── Aktionen aus LLM-Response ausführen ─────────────────────────────────────
function executeActions(response) {
const actions = [];
// FILE: pattern
const fileRegex = /FILE:\s*(.+)\n```(?:\w+)?\n([\s\S]+?)```/g;
let m;
while ((m = fileRegex.exec(response)) !== null) {
actions.push({ type: 'file', path: m[1].trim(), content: m[2] });
}
// DIR: pattern
const dirRegex = /^DIR:\s*(.+)$/gm;
while ((m = dirRegex.exec(response)) !== null) {
actions.push({ type: 'dir', path: m[1].trim() });
}
// EDIT: pattern
const editRegex = /EDIT:\s*(.+?)\n---OLD\n([\s\S]*?)\n---NEW\n([\s\S]*?)\n---/gm;
while ((m = editRegex.exec(response)) !== null) {
actions.push({ type: 'edit', path: m[1].trim(), oldText: m[2], newText: m[3] });
}
// Bash pattern
const bashRegex = /```bash\n([\s\S]+?)```/g;
while ((m = bashRegex.exec(response)) !== null) {
actions.push({ type: 'bash', command: m[1].trim() });
}
return actions;
}
async function applyActions(actions) {
for (const a of actions) {
switch (a.type) {
case 'file': {
const fullPath = path.resolve(WORKSPACE, a.path);
const parentDir = path.dirname(fullPath);
fs.mkdirSync(parentDir, { recursive: true });
fs.writeFileSync(fullPath, a.content, 'utf8');
console.log(` ${C.green}${C.reset} Datei gespeichert: ${path.relative(WORKSPACE, fullPath)}`);
break;
}
case 'dir': {
const fullPath = path.resolve(WORKSPACE, a.path);
fs.mkdirSync(fullPath, { recursive: true });
console.log(` ${C.yellow}${C.reset} Verzeichnis erstellt: ${path.relative(WORKSPACE, fullPath)}`);
break;
}
case 'edit': {
const fullPath = path.resolve(WORKSPACE, a.path);
if (fs.existsSync(fullPath)) {
let content = fs.readFileSync(fullPath, 'utf8');
if (content.includes(a.oldText)) {
content = content.replace(a.oldText, a.newText);
fs.writeFileSync(fullPath, content, 'utf8');
console.log(` ${C.magenta}${C.reset} Edit angewendet: ${path.relative(WORKSPACE, fullPath)}`);
} else {
console.log(` ${C.red}${C.reset} Edit fehlgeschlagen: "${a.oldText}" nicht gefunden in ${path.relative(WORKSPACE, fullPath)}`);
}
} else {
console.log(` ${C.red}${C.reset} Datei nicht gefunden: ${path.relative(WORKSPACE, fullPath)}`);
}
break;
}
case 'bash': {
console.log(` ${C.cyan}${C.reset} Bash-Befehl erkannt:`);
console.log(` ${C.grey}${a.command}${C.reset}`);
const confirm = await askSingleLine(` ${C.yellow}Ausführen?${C.reset} (j/N) `);
if (confirm.toLowerCase() === 'j' || confirm.toLowerCase() === 'ja') {
try {
const out = execSync(a.command, { cwd: WORKSPACE, timeout: 30000, encoding: 'utf8', maxBuffer: 1024 * 512 });
if (out) console.log(` ${C.grey}${out.split('\n').map(l => ' ' + l).join('\n')}${C.reset}`);
console.log(` ${C.green}${C.reset} Bash beendet (exit 0)`);
} catch (e) {
console.log(` ${C.red}${C.reset} Bash-Fehler: ${e.stderr || e.message}`);
}
} else {
console.log(` ${C.dim} (übersprungen)${C.reset}`);
}
break;
}
}
}
}
// ─── Modelle aus dem MiniLlama/models-Ordner anzeigen ─────────────────────────
function getLocalModels() {
const modelsDir = path.join(__dirname, '..', 'models');
if (!fs.existsSync(modelsDir)) return [];
return fs.readdirSync(modelsDir)
.filter(f => f.endsWith('.gguf'))
.sort()
.map((name, i) => {
const fullPath = path.join(modelsDir, name);
try {
const size = (fs.statSync(fullPath).size / 1024 / 1024 / 1024).toFixed(1);
return { index: i + 1, name, path: fullPath, size };
} catch { return { index: i + 1, name, path: fullPath, size: '?' }; }
});
}
function showLocalModels(models) {
if (models.length === 0) return;
console.log(`\n${C.bold}📦 Verfügbare Modelle:${C.reset}`);
models.forEach(m => {
console.log(` ${C.cyan}${m.index})${C.reset} ${m.name} ${C.grey}(${m.size} GB)${C.reset}`);
});
}
// ─── Server starten ───────────────────────────────────────────────────────────
function findLlamaServer() {
// Pfade für llama-server Binary suchen
const candidates = [
path.join(__dirname, '..', 'bin', 'linux', 'llama-server'),
path.join(__dirname, '..', 'bin', 'win', 'llama-server.exe'),
];
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
return null;
}
async function startServerWithModel(localModels) {
showLocalModels(localModels);
if (localModels.length === 0) {
console.log(`\n${C.red}❌ Keine .gguf-Modelle im MiniLlama/models/-Ordner gefunden.${C.reset}`);
process.exit(1);
}
// User wählt Modell per Ziffer
let modelPath = null;
while (!modelPath) {
const input = await askSingleLine(`\n${C.yellow}🔌 Server starten mit Modell Nr.${C.reset} [1-${localModels.length}] ${C.dim}(Enter=3=kleinstes)${C.reset}: `);
const choice = input ? parseInt(input) : 3;
if (!isNaN(choice) && choice >= 1 && choice <= localModels.length) {
modelPath = localModels[choice - 1];
} else {
console.log(` ${C.red}Bitte eine Zahl zwischen 1 und ${localModels.length}${C.reset}`);
}
}
const serverBin = findLlamaServer();
if (!serverBin) {
console.log(`\n${C.red}❌ Kein llama-server Binary gefunden.${C.reset}`);
console.log(` ${C.dim}Erwartet in bin/linux/llama-server oder bin/win/llama-server.exe${C.reset}`);
process.exit(1);
}
console.log(`\n${C.dim}🚀 Starte Server mit ${modelPath.name}...${C.reset}\n`);
const { spawn } = require('child_process');
const MINILLAMA_DIR = path.join(__dirname, '..');
return new Promise((resolve, reject) => {
const server = spawn(serverBin, [
'-m', modelPath.path,
'--host', '127.0.0.1',
'--port', '8080',
'--ctx-size', '4096',
'--n-gpu-layers', '0',
], {
cwd: MINILLAMA_DIR,
stdio: ['ignore', 'pipe', 'pipe'],
});
let started = false;
server.stdout.on('data', (data) => {
const text = data.toString();
process.stdout.write(text); // Live-Logs zeigen
if (!started && (text.includes('model loaded') || text.includes('listening on') || text.includes('start'))) {
started = true;
// Kurz warten bis Server wirklich bereit
setTimeout(() => resolve(server), 500);
}
});
server.stderr.on('data', (data) => {
const text = data.toString();
process.stdout.write(text); // Live-Logs zeigen
if (!started && (text.includes('model loaded') || text.includes('listening on') || text.includes('start')) && !text.includes('error')) {
started = true;
setTimeout(() => resolve(server), 500);
}
});
server.on('error', (err) => {
reject(new Error(`Server-Start fehlgeschlagen: ${err.message}`));
});
server.on('exit', (code) => {
if (!started) reject(new Error(`Server beendet mit Code ${code}`));
});
// Timeout: Wenn Server nicht innerhalb von 120s startet (große Modelle vom Stick)
setTimeout(() => {
if (!started) reject(new Error('Timeout: Server nicht innerhalb von 120s gestartet. Möglicherweise zu großes Modell oder USB-Stick zu langsam.'));
}, 120000);
});
}
// ─── Hauptprogramm ────────────────────────────────────────────────────────────
async function main() {
console.clear ? console.clear() : null;
console.log(`\n${C.bold}${C.cyan}╔══════════════════════════════════════════╗${C.reset}`);
console.log(`${C.bold}${C.cyan}║ 🦙 CrowdCode CLI — ${C.reset}miniLLM Chat ${C.bold}${C.cyan}${C.reset}`);
console.log(`${C.bold}${C.cyan}╚══════════════════════════════════════════╝${C.reset}`);
console.log(`${C.grey} API: ${API} | Workspace: ${WORKSPACE}${C.reset}\n`);
// Verfügbare Modelle anzeigen
const localModels = getLocalModels();
// Lokale Modelle IMMER anzeigen (auch wenn Server schon läuft)
if (localModels.length > 0) {
console.log(`${C.bold}📦 Verfügbare lokale Modelle:${C.reset}`);
localModels.forEach(m => {
console.log(` ${C.cyan}${m.index})${C.reset} ${m.name} ${C.grey}(${m.size} GB)${C.reset}`);
});
}
// API-Verbindung testen → falls Server schon läuft, direkt loslegen
let serverProcess = null;
let connected = false;
console.log(`\n${C.dim}🔌 Teste Verbindung zu ${API}...${C.reset}`);
try {
const models = await fetchModels();
if (models.length > 0) {
const modelName = MODEL || models[0]?.id || models[0]?.name || models[0]?.model || '(unbekannt)';
console.log(` ${C.green}${C.reset} Server läuft bereits. Modell: ${C.bold}${modelName}${C.reset}`);
connected = true;
} else {
console.log(` ${C.yellow}⚠️${C.reset} Server antwortet, aber kein Modell geladen.`);
console.log(` ${C.dim} → Starte Server neu mit einem lokalen Modell.${C.reset}`);
}
} catch {
console.log(` ${C.red}${C.reset} Kein Server an ${API}`);
console.log(` ${C.dim} → Starte Server automatisch mit lokalem Modell.${C.reset}`);
}
// Server starten, falls nicht verbunden
if (!connected) {
if (localModels.length === 0) {
console.log(`\n${C.red}❌ Keine .gguf-Modelle gefunden.${C.reset}`);
console.log(` ${C.dim}Lege Modelle in models/ ab oder starte llama-server manuell.${C.reset}`);
process.exit(1);
}
try {
serverProcess = await startServerWithModel(localModels);
connected = true;
console.log(`\n${C.green}✅ Server bereit.${C.reset}`);
} catch (err) {
console.log(`\n${C.red}❌ Server-Start fehlgeschlagen: ${err.message}${C.reset}`);
process.exit(1);
}
}
// ─── Chat-Loop ──────────────────────────────────────────────────────────
const history = [{ role: 'system', content: SYSTEM_PROMPT }];
console.log(`\n${C.green}💬 Chat bereit.${C.reset} ${C.dim}Tippe deine Nachricht, :q zum Beenden, :h für Hilfe.${C.reset}`);
while (true) {
const input = await askInput();
if (input === null) continue; // :c (abbrechen)
const trimmed = input.trim();
if (trimmed === ':q' || trimmed === ':quit' || trimmed === ':exit') break;
if (trimmed === ':h' || trimmed === ':help') {
console.log(`\n${C.bold}Hilfe:${C.reset}
:h, :help Diese Hilfe
:q, :quit Beenden
:m Multiline-Eingabe (für Code, :send zum Senden)
:clear Chat-Verlauf löschen
${C.cyan}@datei.py${C.reset} Dateiinhalt lesen
${C.cyan}@verz/${C.reset} Verzeichnis auflisten
${C.cyan}@verz/**${C.reset} Rekursiver Dateibaum
${C.cyan}@?begriff${C.reset} Grep-Suche
${C.cyan}@?*.ts${C.reset} Glob-Suche
Das LLM kann: FILE:, DIR:, EDIT:, \`\`\`bash ausgeben
`);
continue;
}
if (trimmed === ':clear' || trimmed === ':reset') {
history.length = 1; // nur System-Prompt behalten
console.log(` ${C.dim}Chat-Verlauf gelöscht.${C.reset}`);
continue;
}
if (!trimmed) continue;
// @mentions auflösen
const enhanced = await resolveMentions(trimmed);
history.push({ role: 'user', content: enhanced });
// Streaming-Antwort
let fullResponse = '';
let thinkingDone = false;
process.stdout.write(`\n${C.cyan}──${C.reset}`);
try {
await chatComplete(history, (token, isReasoning) => {
if (isReasoning) {
if (!thinkingDone) {
process.stdout.write(`\n${C.dim}🤔 `);
thinkingDone = true;
}
process.stdout.write(`${C.grey}${token}${C.reset}`);
} else {
if (thinkingDone) {
process.stdout.write(`${C.reset}\n${C.cyan}──${C.reset}`);
thinkingDone = false;
}
process.stdout.write(token);
}
});
} catch (err) {
process.stdout.write(`\n${C.red}❌ Fehler: ${err.message}${C.reset}`);
console.log(`\n ${C.dim}Versuche erneute Verbindung...${C.reset}`);
history.pop(); // fehlgeschlagene User-Nachricht entfernen
continue;
}
process.stdout.write('\n');
history.push({ role: 'assistant', content: fullResponse });
// Aktionen ausführen
const actions = executeActions(fullResponse);
if (actions.length > 0) {
console.log(`\n${C.bold}Aktionen:${C.reset}`);
await applyActions(actions);
}
console.log(''); // Leerzeile vor nächstem Prompt
}
console.log(`\n${C.cyan}Tschüss!${C.reset}\n`);
process.exit(0);
}
main().catch((err) => {
console.error(`${C.red}❌ Fatal:${C.reset}`, err.message);
process.exit(1);
});