MiniLlama/cli/crowdcode.js
xray da86c2d83b feat: CrowdCode CLI für Terminal/CMD (Linux + Windows)
- cli/crowdcode.js: Node.js CLI mit Streaming-Chat, @mentions,
  FILE:/DIR:/EDIT:/bash Pattern-Erkennung + Ausführung
- crowdcode.sh: Linux-Wrapper
- crowdcode.bat: Windows-Wrapper
- Funktioniert ohne VSCode, nutzt llama.cpp API
2026-07-09 10:44:22 +02:00

464 lines
21 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 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/<modell>.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);
});