diff --git a/cli/crowdcode.js b/cli/crowdcode.js new file mode 100644 index 0000000..bbb28ce --- /dev/null +++ b/cli/crowdcode.js @@ -0,0 +1,464 @@ +#!/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 ask(` ${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 showAvailableModels(modelsDir) { + if (!fs.existsSync(modelsDir)) return []; + const files = fs.readdirSync(modelsDir).filter(f => f.endsWith('.gguf')); + if (files.length === 0) return []; + console.log(`\n${C.bold}📦 Verfügbare Modelle:${C.reset}`); + files.forEach((f, i) => { + try { + const stat = fs.statSync(path.join(modelsDir, f)); + const size = (stat.size / 1024 / 1024 / 1024).toFixed(1); + console.log(` ${C.cyan}${i + 1})${C.reset} ${f} ${C.grey}(${size} GB)${C.reset}`); + } catch { console.log(` ${C.cyan}${i + 1})${C.reset} ${f}`); } + }); + return files; +} + +// ─── 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 prüfen + const modelsDir = path.join(__dirname, '..', 'models'); + const localModels = showAvailableModels(modelsDir); + + // API-Verbindung testen + console.log(`\n${C.dim}🔌 Teste Verbindung zu ${API}...${C.reset}`); + let connected = false; + 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} Verbunden. Modell: ${C.bold}${modelName}${C.reset}`); + connected = true; + } else { + console.log(` ${C.yellow}⚠️${C.reset} Server verbunden, aber keine Modelle geladen.`); + console.log(` ${C.dim} Stelle sicher, dass ein Modell geladen ist.${C.reset}`); + connected = true; // Server läuft trotzdem + } + } catch (e) { + console.log(` ${C.red}❌${C.reset} Keine Verbindung zu ${API}`); + console.log(` ${C.dim} Starte zuerst den Server: ./start-server.sh${C.reset}`); + console.log(` ${C.dim} Oder: ./bin/linux/llama-server -m models/.gguf --host 127.0.0.1 --port 8080${C.reset}`); + if (localModels.length > 0) { + const ans = await ask(` ${C.yellow}Server lokal starten?${C.reset} (j/N) `); + if (ans.toLowerCase() === 'j' || ans.toLowerCase() === 'ja') { + const serverPath = path.join(__dirname, '..', 'start-server.sh'); + if (fs.existsSync(serverPath)) { + const child = require('child_process').spawn(serverPath, [], { stdio: 'inherit', cwd: path.join(__dirname, '..') }); + console.log(` ${C.dim}Server gestartet, drücke Enter wenn bereit...${C.reset}`); + await ask(''); + // Erneut testen + try { await fetchModels(); connected = true; } catch { } + } + } + } + if (!connected) { + console.log(`\n${C.red}Bitte zuerst llama-server starten.${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); +}); diff --git a/crowdcode.bat b/crowdcode.bat new file mode 100644 index 0000000..9a2fb53 --- /dev/null +++ b/crowdcode.bat @@ -0,0 +1,11 @@ +@echo off +:: crowdcode.bat — MiniLlama Terminal CLI (Windows) +:: Aufruf: crowdcode.bat [endpoint] [model] +cd /d "%~dp0" +echo CrowdCode CLI — MiniLlama Chat +node cli\crowdcode.js %* +if errorlevel 1 ( + echo. + echo Fehler beim Start von CrowdCode. + pause +) diff --git a/crowdcode.sh b/crowdcode.sh new file mode 100755 index 0000000..658433d --- /dev/null +++ b/crowdcode.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# crowdcode.sh — MiniLlama Terminal CLI (Linux) +# Aufruf: ./crowdcode.sh [endpoint] [model] +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +node "$SCRIPT_DIR/cli/crowdcode.js" "$@"