#!/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); });