- MODELS.md: 8B vs 24B Vergleich, :wiki-Doku, Entscheidungsbaum
- README.md: 24B mit ⚠️ Manuell markiert (FILE:-Pattern unzuverlässig)
- SESSION_HANDOVER.md: Neue Session 14:30-18:00
- TEST_SCENARIO.md: Referenz-Testszenario
- crowdcode.js: 24B-Kompatibilität (flexible FILE:-Erkennung,
:wiki-Befehl, configurable Timeout/GPU/Context via ENV,
Streaming AbortController, Orphan-Codeblock-Detection)
790 lines
35 KiB
JavaScript
790 lines
35 KiB
JavaScript
#!/usr/bin/env node
|
||
// crowdcode.js — MiniLlama Terminal CLI (Linux + Windows)
|
||
// Chat + Dateioperationen via llama.cpp API
|
||
// Aufruf: node crowdcode.js [endpoint] [model]
|
||
|
||
// ─── Konfiguration (via ENV überschreibbar) ──────────────────────────────────
|
||
const API = process.argv[2] || process.env.LLAMA_API || 'http://127.0.0.1:8080/v1';
|
||
const MODEL = process.argv[3] || process.env.LLAMA_MODEL || '';
|
||
const CONFIG = {
|
||
timeout: parseInt(process.env.LLAMA_TIMEOUT || '120000'), // Streaming-Timeout (ms)
|
||
ctxSize: parseInt(process.env.LLAMA_CTX_SIZE || '4096'), // Kontext-Fenster
|
||
gpuLayers: parseInt(process.env.LLAMA_GPU_LAYERS || '0'), // GPU-Layer für --n-gpu-layers
|
||
};
|
||
|
||
// ─── 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 with FULL shell access.
|
||
|
||
FILE OPERATIONS (no confirmation needed):
|
||
- DIR: path/to/dir — create a directory
|
||
- FILE: path/to/file on its own line, then fenced code block with COMPLETE file content — create or overwrite a file
|
||
- EDIT: path/to/file, then ---OLD, old text, ---NEW, new text, --- — edit a file
|
||
|
||
SHELL COMMANDS (user confirms each):
|
||
- Use \`\`\`bash for: cp, mv, rm, grep, find, git, npm, and other shell operations that aren't file/dir creation
|
||
- When the user asks about files/dirs/system, use a \`\`\`bash block
|
||
|
||
Examples:
|
||
- user: "erstelle test.txt mit Hallo" → DIR: tests \n FILE: tests/test.txt \n\`\`\`\nHallo\n\`\`\`
|
||
- user: "liste dateien" → \`\`\`bash\nls -la\n\`\`\`
|
||
- user: "kopiere test.txt als test2.txt" → \`\`\`bash\ncp test.txt test2.txt\n\`\`\`
|
||
|
||
Never explain — just output the commands.
|
||
NEVER use echo, touch, mkdir, cat, printf to create files — 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, maxTokens) {
|
||
const model = MODEL || (await fetchModels())[0]?.id || 'unknown';
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), CONFIG.timeout);
|
||
try {
|
||
const body = JSON.stringify({ model, messages, stream: true, temperature: 0.2, top_p: 0.9, ...(maxTokens ? { max_tokens: maxTokens } : {}) });
|
||
const res = await fetch(`${API}/chat/completions`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer local' },
|
||
body,
|
||
signal: controller.signal,
|
||
});
|
||
clearTimeout(timeoutId);
|
||
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;
|
||
} finally {
|
||
clearTimeout(timeoutId);
|
||
}
|
||
}
|
||
|
||
// ─── @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:/path: Pattern (8B + 24B kompatibel) ===
|
||
// 8B: FILE: pfad\n```\n...```
|
||
// 24B: path: name.md\n```markdown\n...```
|
||
// 24B: Path: name.md\n```\n...```
|
||
const fileWithPrefixRegex = /(?:^|\n)[ \t]*(?:FILE|Path|path|Datei|datei|file|File)[:\s]\s*`?\s*(.+?)\s*`?\n```(?:\w+)?\n([\s\S]+?)```/g;
|
||
let m;
|
||
const matchedRanges = [];
|
||
|
||
while ((m = fileWithPrefixRegex.exec(response)) !== null) {
|
||
let filePath = m[1].trim().replace(/^`+|`+$/g, '');
|
||
if (filePath && filePath.length < 300) {
|
||
actions.push({ type: 'file', path: filePath, content: m[2].trim() });
|
||
matchedRanges.push({ start: m.index, end: m.index + m[0].length });
|
||
}
|
||
}
|
||
|
||
// === 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() });
|
||
}
|
||
|
||
// === Namenlose Markdown-Codeblöcke (24B-Fallback) ===
|
||
// Erfasst Codeblöcke ohne FILE:-Prefix, die wie Wiki-Seiten aussehen
|
||
const orphanCodeRegex = /```(?:\w+)?\n([\s\S]+?)```/g;
|
||
while ((m = orphanCodeRegex.exec(response)) !== null) {
|
||
// Bereits abgedeckt?
|
||
const alreadyMatched = matchedRanges.some(r => m.index >= r.start && m.index < r.end);
|
||
if (alreadyMatched) continue;
|
||
|
||
const content = m[1].trim();
|
||
// Nur Markdown-ähnliche Inhalte mit Substanz erkennen
|
||
if (content.length > 50 &&
|
||
(content.startsWith('#') || content.includes('\n##') ||
|
||
content.includes('|') || content.includes('Siehe auch') ||
|
||
content.includes('Gliederung') || content.includes('Inhalt') ||
|
||
content.includes('Übersicht') || content.includes('Grundlagen'))) {
|
||
actions.push({ type: 'file-orphan', content });
|
||
}
|
||
}
|
||
|
||
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 'file-orphan': {
|
||
// Namenloser Codeblock — User nach Pfad fragen
|
||
const firstLine = a.content.split('\n')[0].trim().replace(/^#+\s*/, '');
|
||
console.log(` ${C.cyan}📄${C.reset} Codeblock erkannt: ${C.grey}"${firstLine}"${C.reset}`);
|
||
const pfad = await askSingleLine(` ${C.yellow}Speichern als?${C.reset} (wiki/Dateiname.md / Enter=überspringen) `);
|
||
if (pfad && pfad.trim()) {
|
||
const fullPath = path.resolve(WORKSPACE, pfad.trim());
|
||
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)}`);
|
||
} else {
|
||
console.log(` ${C.dim} (übersprungen)${C.reset}`);
|
||
}
|
||
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', String(CONFIG.ctxSize),
|
||
'--n-gpu-layers', String(CONFIG.gpuLayers),
|
||
], {
|
||
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);
|
||
});
|
||
}
|
||
|
||
// ─── Wiki-Ingest (Chunked: eine Seite pro Request) ──────────────────────────
|
||
async function wikiIngest(chatCompleteFn) {
|
||
const wikiDir = path.join(WORKSPACE, 'wiki');
|
||
const rawDir = path.join(WORKSPACE, 'raw');
|
||
const agentsFile = path.join(WORKSPACE, 'AGENTS.md');
|
||
|
||
// Prüfe ob Wiki-Umgebung
|
||
if (!fs.existsSync(rawDir) || !fs.existsSync(agentsFile)) {
|
||
console.log(` ${C.red}❌${C.reset} Keine Wiki-Umgebung gefunden.`);
|
||
console.log(` ${C.dim} Erwartet: raw/ (Quellen) und AGENTS.md (Konfiguration) im Workspace.${C.reset}`);
|
||
return;
|
||
}
|
||
|
||
const rawFiles = fs.readdirSync(rawDir).filter(f => f.endsWith('.md'));
|
||
if (rawFiles.length === 0) {
|
||
console.log(` ${C.red}❌${C.reset} Keine .md-Quellen in raw/ gefunden.`);
|
||
return;
|
||
}
|
||
|
||
// AGENTS.md lesen
|
||
const agents = fs.readFileSync(agentsFile, 'utf8');
|
||
const assistantsFile = path.join(WORKSPACE, 'wiki-assistant.md');
|
||
const assistants = fs.existsSync(assistantsFile) ? fs.readFileSync(assistantsFile, 'utf8') : '';
|
||
|
||
// Rohdaten lesen
|
||
const rawContents = {};
|
||
const rawCompact = {};
|
||
for (const f of rawFiles) {
|
||
const content = fs.readFileSync(path.join(rawDir, f), 'utf8');
|
||
rawContents[f] = content;
|
||
// Kompakte Version: erste 800 Zeichen reichen als Kontext
|
||
rawCompact[f] = (content.length > 800 ? content.substring(0, 800) + '\n...' : content);
|
||
}
|
||
// Kompakte Gesamt-Rohdaten (max ~1600 Zeichen für alle Quellen)
|
||
const compactSources = Object.entries(rawCompact).map(([name, c]) => `--- ${name} ---\n${c}`).join('\n\n');
|
||
const fullSources = Object.entries(rawContents).map(([name, c]) => `--- ${name} ---\n${c}`).join('\n\n');
|
||
|
||
// Themen aus AGENTS.md extrahieren
|
||
const topicMatch = agents.match(/## Aktuelle Wiki-Themen\n\n([\s\S]*?)(?:\n##|\n$)/);
|
||
const topics = [];
|
||
if (topicMatch) {
|
||
const lines = topicMatch[1].split('\n').filter(l => l.trim().startsWith('- **'));
|
||
for (const line of lines) {
|
||
const m = line.match(/- \*\*(.+?)\*\*\s*[\u2014\u2013-]\s*(.+)/);
|
||
if (m) topics.push({ title: m[1].trim(), description: m[2].trim() });
|
||
}
|
||
}
|
||
|
||
if (topics.length === 0) {
|
||
console.log(` ${C.yellow}⚠️${C.reset} Keine Themen in AGENTS.md gefunden.`);
|
||
console.log(` ${C.dim} Bitte pflege "## Aktuelle Wiki-Themen" in AGENTS.md.${C.reset}`);
|
||
return;
|
||
}
|
||
|
||
// Inhalt.md als erstes — Gesamtüberblick
|
||
const pages = [
|
||
{ file: 'Inhalt.md', title: 'Inhalt (Startseite)', prompt: `Create wiki/Inhalt.md — the index page for this wiki about IFTMIN (EDIFACT Transportauftrag) for OBI. Include a brief overview paragraph and a table of contents linking to these pages:\n${topics.map(t => `- ${t.title} — ${t.description}`).join('\n')}\n\nWrite in German. Use markdown links like [Titel](Datei.md).` }
|
||
];
|
||
|
||
// Content-Pages: Nur ERSTE Seite bekommt volle Rohdaten, restliche nur Kurzfassung
|
||
for (let i = 0; i < topics.length; i++) {
|
||
const t = topics[i];
|
||
const sourceSection = i === 0
|
||
? `Quellen:\n${fullSources}`
|
||
: `Quellen (Auszug):\n${compactSources}`;
|
||
|
||
pages.push({
|
||
file: `${t.title.replace(/[^a-zA-Z0-9äöüÄÖÜß-]/g, '').replace(/\s+/g, '-')}.md`,
|
||
title: t.title,
|
||
prompt: `Create wiki/${t.title.replace(/[^a-zA-Z0-9äöüÄÖÜß-]/g, '').replace(/\s+/g, '-')}.md in German for the IFTMIN OBI wiki.\n\nTopic: ${t.title} — ${t.description}\n\n${sourceSection}\n\nWrite a well-structured markdown page with sections, tables where appropriate, and 'Siehe auch' links at the end referencing the other wiki pages.`
|
||
});
|
||
}
|
||
|
||
fs.mkdirSync(wikiDir, { recursive: true });
|
||
console.log(`\n${C.bold}📚 Wiki-Ingest gestartet: ${pages.length} Seiten${C.reset}\n`);
|
||
|
||
for (let i = 0; i < pages.length; i++) {
|
||
const page = pages[i];
|
||
console.log(` ${C.cyan}[${i+1}/${pages.length}]${C.reset} ${page.title} ${C.dim}(${page.file})${C.reset}`);
|
||
|
||
const history = [
|
||
{ role: 'system', content: `You are a wiki builder. Create markdown wiki pages from source material. Output the page content directly as markdown (no code block wrapper needed).` },
|
||
{ role: 'user', content: page.prompt }
|
||
];
|
||
|
||
let result = '';
|
||
let hasError = false;
|
||
try {
|
||
result = await chatCompleteFn(history, () => {}, 1500);
|
||
} catch (err) {
|
||
console.log(` ${C.red}✗${C.reset} Fehler: ${err.message}`);
|
||
hasError = true;
|
||
}
|
||
|
||
if (!hasError) {
|
||
result = result.trim();
|
||
// Codeblock extrahieren falls vorhanden (8B mit FILE:-Pattern, 24B-Roh-Output)
|
||
const cb = result.match(/```(?:\w+)?\n([\s\S]+?)```/);
|
||
const saveContent = cb ? cb[1].trim() : result;
|
||
fs.writeFileSync(path.join(wikiDir, page.file), saveContent, 'utf8');
|
||
console.log(` ${C.green}✓${C.reset} Gespeichert (${saveContent.length} Zeichen${cb ? '' : ', Roh-Output'})`);
|
||
}
|
||
|
||
// Kleine Pause zwischen Requests (Server-Entlastung)
|
||
if (i < pages.length - 1) await new Promise(r => setTimeout(r, 500));
|
||
}
|
||
|
||
console.log(`\n${C.green}✅${C.bold} Wiki-Ingest abgeschlossen${C.reset}`);
|
||
console.log(` ${C.dim}${pages.length} Seiten in ${path.relative(WORKSPACE, wikiDir)}${C.reset}`);
|
||
}
|
||
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
|
||
:wiki Wiki-Ingest aus raw/ + AGENTS.md (Chunked-Modus)
|
||
|
||
${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
|
||
(24B: auch path:, Datei: oder Codeblock ohne Prefix)
|
||
|
||
${C.dim}Konfiguration (via ENV):
|
||
LLAMA_TIMEOUT=300000 Streaming-Timeout in ms (Default: 120000)
|
||
LLAMA_CTX_SIZE=8192 Kontext-Fenster (Default: 4096)
|
||
LLAMA_GPU_LAYERS=8 GPU-Offload-Layer (Default: 0)
|
||
LLAMA_API=url API-Endpoint (Default: http://127.0.0.1:8080/v1)${C.reset}
|
||
`);
|
||
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 === ':wiki') {
|
||
await wikiIngest(chatComplete);
|
||
continue;
|
||
}
|
||
if (!trimmed) continue;
|
||
|
||
// ─── Shell-Kommando-Erkennung (direkt ausführen, nicht ans LLM) ─────
|
||
const SHELL_COMMANDS = [
|
||
'pwd', 'ls', 'll', 'cd', 'git', 'npm', 'npx', 'node', 'python', 'python3',
|
||
'pip', 'curl', 'wget', 'docker', 'docker-compose', 'ps', 'top', 'htop',
|
||
'df', 'du', 'whoami', 'env', 'which', 'whereis', 'ssh', 'scp', 'make',
|
||
'cargo', 'go', 'rustc', 'date', 'uptime', 'uname', 'hostname', 'id', 'free',
|
||
'netstat', 'ss', 'ip', 'ping', 'traceroute', 'nslookup', 'dig', 'tree',
|
||
'wc', 'sort', 'uniq', 'cut', 'awk', 'sed', 'tr', 'diff', 'cmp', 'stat',
|
||
'file', 'type', 'printenv', 'history', 'screen', 'tmux', 'bash', 'sh',
|
||
'zsh', 'source', '.',
|
||
];
|
||
// Shell-Prompt-Präfixe entfernen ($, >, #) und erstes Wort prüfen
|
||
const cmdTrimmed = trimmed.replace(/^[$>#]\s*/, '');
|
||
const firstWord = cmdTrimmed.split(/\s+/)[0];
|
||
const isShellCmd = SHELL_COMMANDS.includes(firstWord) && !/[?!.]$/.test(cmdTrimmed);
|
||
if (isShellCmd) {
|
||
const lines = cmdTrimmed.split('\n').filter(l => l.trim());
|
||
process.stdout.write(`\n${C.dim}⚡ ${trimmed}${C.reset}\n`);
|
||
for (const cmd of lines) {
|
||
try {
|
||
const out = execSync(cmd.trim(), { cwd: WORKSPACE, timeout: 10000, encoding: 'utf8', maxBuffer: 1024 * 512 });
|
||
console.log(`${C.grey}${out || '(keine Ausgabe)'}${C.reset}`);
|
||
} catch (e) {
|
||
console.log(`${C.red}${e.stderr || e.message}${C.reset}`);
|
||
}
|
||
}
|
||
console.log('');
|
||
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);
|
||
});
|