from flask import Flask, jsonify, request, render_template_string
import subprocess, json
app = Flask(__name__)
SERVICES = [
{'id': 'hermes-komfort', 'name': 'Hermes Workspace', 'desc': 'Arbeitsoberflaeche fuer Hermes Carlo.', 'icon': 'β', 'url': 'https://hermes-workspace.agentsolutions-mallorca.com/', 'container': 'hermes-carlo-komfort', 'compose': '/opt/struktur/hermes-ui-auswahl', 'persona_url': 'https://cockpit-carlo.agentsolutions-mallorca.com/personas/?instance=carlo'},
{'id': 'hermes-carlo', 'name': 'Hermes Carlo', 'desc': 'Produktive Hermes-Carlo-Instanz.', 'icon': 'β‘', 'url': 'https://hermes-carlo.agentsolutions-mallorca.com', 'container': 'hermes-carlo', 'persona_url': 'https://cockpit-carlo.agentsolutions-mallorca.com/personas/?instance=carlo', 'compose': '/opt/struktur/hermes-carlo'},
{'id': 'carlo-worker', 'name': 'Worker aufrufen', 'desc': 'Direkter Aufruf der laufenden Worker-Oberflaeche fuer KI-Aufgaben und Automationen.', 'icon': 'π€', 'url': 'https://worker.agentsolutions-mallorca.com', 'container': 'hermes-carlo', 'compose': '/opt/struktur/hermes-carlo', 'open_only': True},
{'id': 'hermes-werkstandard', 'name': 'Hermes Werkstandard', 'desc': 'Unveraenderte Nousresearch Upstream-Instanz. Referenz fuer Carlo/Mari.', 'icon': 'π§ ', 'url': 'https://werkstandard.agentsolutions-mallorca.com', 'container': 'hermes-werkstandard', 'compose': '/opt/struktur/hermes-werkstandard'},
{'id': 'mirofish', 'name': 'MiroFish', 'desc': 'Multi-Agenten-Simulation zur Zukunftsvorhersage und Marktforschung.', 'icon': 'π', 'url': 'https://mirofish.agentsolutions-mallorca.com', 'container': 'mirofish', 'compose': '/opt/struktur/mirofish'}
]
def get_status(container):
try:
result = subprocess.run(
['docker', 'inspect', '--format', '{{.State.Status}}', container],
capture_output=True, text=True, timeout=5
)
status = result.stdout.strip()
if status == 'running':
return 'online'
elif status == 'exited':
return 'offline'
else:
return status or 'unbekannt'
except:
return 'unbekannt'
def docker_action(service, action):
if service['compose']:
compose_dir = service['compose']
if action == 'start':
cmd = f"cd {compose_dir} && docker compose up -d"
elif action == 'stop':
cmd = f"cd {compose_dir} && docker compose stop"
elif action == 'restart':
cmd = f"cd {compose_dir} && docker compose restart"
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=60)
else:
result = subprocess.run(
['docker', action, service['container']],
capture_output=True, text=True, timeout=30
)
return result.returncode == 0
HTML = '''<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cockpit Carlo</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0f1117; color: #e2e8f0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; min-height: 100vh; }
header { padding: 2rem 3rem; border-bottom: 1px solid #1e2330; }
header h1 { font-size: 1.5rem; font-weight: 600; color: #fff; }
header span { font-size: 0.85rem; color: #64748b; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.5rem; padding: 3rem; }
.card { background: #161b27; border: 1px solid #1e2330; border-radius: 12px; padding: 1.5rem; }
.card-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 1rem; }
.card-icon { font-size: 2rem; }
.badge { font-size: 0.7rem; padding: 0.25rem 0.7rem; border-radius: 99px; font-weight: 600; }
.badge.online { background: #1a3a2a; color: #4ade80; border: 1px solid #166534; }
.badge.offline { background: #3a1a1a; color: #f87171; border: 1px solid #7f1d1d; }
.badge.loading { background: #2a2a1a; color: #fbbf24; border: 1px solid #78350f; }
.badge.planned { background: #1a2a3a; color: #93c5fd; border: 1px solid #1e3a5f; }
.card-title { font-size: 1.1rem; font-weight: 600; color: #fff; margin-bottom: 0.4rem; }
.card-desc { font-size: 0.85rem; color: #64748b; line-height: 1.5; margin-bottom: 1.2rem; }
.card-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; }
.btn { font-size: 0.75rem; padding: 0.4rem 0.9rem; border-radius: 6px; border: none; cursor: pointer; font-weight: 500; transition: all 0.15s; }
.btn-open { background: #1e3a5f; color: #60a5fa; }
.btn-open:hover { background: #1e4a7f; }
.btn-restart { background: #1e3a2a; color: #4ade80; }
.btn-restart:hover { background: #1e5a3a; }
.btn-stop { background: #3a1e1e; color: #f87171; }
.btn-stop:hover { background: #5a1e1e; }
.btn-start { background: #1a3a2a; color: #4ade80; }
.btn-start:hover { background: #1a5a3a; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.modal-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:1000; align-items:center; justify-content:center; }
.modal-overlay.visible { display:flex; }
.modal { background:#1e2330; border:1px solid #2d3748; border-radius:14px; padding:2rem; min-width:420px; max-width:95vw; }
.modal h2 { font-size:1.1rem; font-weight:700; color:#fff; margin-bottom:1.2rem; }
.modal select { width:100%; padding:0.5rem 0.75rem; background:#0f1117; border:1px solid #2d3748; border-radius:7px; color:#e2e8f0; font-size:0.9rem; margin-bottom:1rem; }
.modal-actions { display:flex; gap:0.75rem; justify-content:flex-end; margin-top:0.5rem; }
.btn-cancel { background:#1e2330; border:1px solid #2d3748; color:#94a3b8; padding:0.45rem 1.1rem; border-radius:7px; cursor:pointer; font-size:0.85rem; }
.btn-save { background:#1e3a5f; color:#60a5fa; border:none; padding:0.45rem 1.1rem; border-radius:7px; cursor:pointer; font-size:0.85rem; font-weight:600; }
.current-model { font-size:0.8rem; color:#64748b; margin-bottom:1rem; }
footer { text-align: center; padding: 2rem; color: #334155; font-size: 0.8rem; }
</style>
</head>
<body>
<header style="display:flex; align-items:center; justify-content:space-between;">
<div>
<h1>Cockpit Carlo</h1>
<span>Werkzeuge & Dienste β VPS 31.70.68.228</span>
</div>
<a href="https://agentsolutions-mallorca.com" style="font-size:0.85rem; color:#3b82f6; text-decoration:none; border:1px solid #1e3a5f; padding:0.4rem 1rem; border-radius:8px;">← ZurΓΌck</a>
</header>
<div class="grid" id="grid">
{% for s in services %}
<div class="card" id="card-{{s.id}}">
<div class="card-header">
<div class="card-icon">{{s.icon}}</div>
<span class="badge loading" id="badge-{{s.id}}">{% if s.get('external') %}extern{% elif s.get('planned') %}geplant{% else %}...{% endif %}</span>
</div>
<div class="card-title">{{s.name}}</div>
<div class="card-desc">{{s.desc}}</div>
<div class="card-actions">
<a href="{{s.url}}" target="_blank"><button class="btn btn-open">Γffnen</button></a>
{% if s.get('persona_url') %}<a href="{{s.persona_url}}" target="_blank"><button class="btn" style="background:#7c3aed;color:#fff">Personas</button></a>{% endif %}
{% if not s.get('external') and not s.get('open_only') %}
<button class="btn btn-restart" onclick="action('{{s.id}}','restart')">Restart</button>
<button class="btn btn-stop" onclick="action('{{s.id}}','stop')">Stop</button>
<button class="btn btn-start" onclick="action('{{s.id}}','start')">Start</button>
{% if s.id == 'hermes-carlo' %}
<button class="btn" style="background:#3a1a1a;color:#f87171;font-weight:700;" onclick="hermesJobsReset()">🗑 Jobs Reset</button>
{% endif %}
{% if s.id == 'mirofish' %}
<button class="btn" style="background:#1e2a3a;color:#a78bfa;" onclick="openMirofishModal()">π§ Modell</button>
{% endif %}
{% endif %}
</div>
</div>
{% endfor %}
</div>
<footer>Agent Solutions Β· VPS IONOS Β· 31.70.68.228</footer>
<script>
function updateStatus() {
fetch('/api/status').then(r=>r.json()).then(data => {
data.forEach(s => {
const b = document.getElementById('badge-'+s.id);
b.textContent = s.status;
b.className = 'badge ' + (s.status === 'online' ? 'online' : s.status === 'offline' ? 'offline' : s.status === 'geplant' ? 'planned' : 'loading');
});
});
}
function action(id, act) {
const b = document.getElementById('badge-'+id);
b.textContent = '...';
b.className = 'badge loading';
fetch('/api/action', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({id, action: act})
}).then(()=> setTimeout(updateStatus, 3000));
}
updateStatus();
setInterval(updateStatus, 15000);
</script>
<div class="modal-overlay" id="mirofish-modal">
<div class="modal">
<h2>π MiroFish β Modell wechseln</h2>
<div class="current-model">Aktuell: <span id="modal-current-model">...</span></div>
<select id="modal-model-select"></select>
<div class="modal-actions">
<button class="btn-cancel" onclick="closeMirofishModal()">Abbrechen</button>
<button class="btn-save" id="btn-modal-save" onclick="saveMirofishModel()">Speichern & Neustart</button>
</div>
</div>
</div>
<script>
async function openMirofishModal() {
document.getElementById('mirofish-modal').classList.add('visible');
const res = await fetch('/api/mirofish/model');
const data = await res.json();
document.getElementById('modal-current-model').textContent = data.current;
const sel = document.getElementById('modal-model-select');
sel.innerHTML = data.models.map(m =>
`<option value="${m.model}" ${m.model===data.current?'selected':''}>[${m.provider}] ${m.label}</option>`
).join('');
}
function closeMirofishModal() {
document.getElementById('mirofish-modal').classList.remove('visible');
}
async function saveMirofishModel() {
const btn = document.getElementById('btn-modal-save');
btn.disabled = true; btn.textContent = 'β³ Neustart...';
const model = document.getElementById('modal-model-select').value;
const res = await fetch('/api/mirofish/model', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({model})});
const data = await res.json();
if (data.ok) { btn.textContent = 'β
Gespeichert'; setTimeout(closeMirofishModal, 1500); }
else { btn.textContent = 'β Fehler'; btn.disabled = false; }
}
</script>
</body>
</html>'''
@app.route('/')
def index():
return render_template_string(HTML, services=SERVICES)
@app.route('/api/status')
def status():
return jsonify([{'id': s['id'], 'status': 'geplant' if s.get('planned') else (get_status(s['container']) if s.get('container') else 'extern')} for s in SERVICES])
@app.route('/api/action', methods=['POST'])
def action():
data = request.json
service = next((s for s in SERVICES if s['id'] == data['id']), None)
if not service:
return jsonify({'ok': False}), 404
ok = docker_action(service, data['action'])
return jsonify({'ok': ok})
MIROFISH_ENV = '/opt/struktur/mirofish/.env'
MIROFISH_MODELS = [
{"provider": "OpenAI", "label": "GPT-4.1-mini (empfohlen, ~3β¬/15Rdn)", "model": "gpt-4.1-mini-2025-04-14", "base_url": "https://api.openai.com/v1", "api_key": "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"},
{"provider": "OpenAI", "label": "GPT-4.1 (~15β¬/15Rdn)", "model": "gpt-4.1-2025-04-14", "base_url": "https://api.openai.com/v1", "api_key": "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"},
{"provider": "OpenAI", "label": "GPT-4o (~30β¬/15Rdn)", "model": "gpt-4o", "base_url": "https://api.openai.com/v1", "api_key": "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"},
{"provider": "OpenRouter", "label": "DeepSeek-V3 (~2β¬/15Rdn)", "model": "deepseek/deepseek-chat", "base_url": "https://openrouter.ai/api/v1", "api_key": "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"},
{"provider": "OpenRouter", "label": "DeepSeek-R1 (~5β¬/15Rdn)", "model": "deepseek/deepseek-reasoner","base_url": "https://openrouter.ai/api/v1", "api_key": "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"},
{"provider": "OpenRouter", "label": "Qwen2.5-72B (~2β¬/15Rdn)", "model": "qwen/qwen2.5-72b-instruct", "base_url": "https://openrouter.ai/api/v1", "api_key": "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"},
{"provider": "OpenRouter", "label": "Kimi K2 (~2β¬/15Rdn)", "model": "moonshotai/kimi-k2", "base_url": "https://openrouter.ai/api/v1", "api_key": "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"},
]
def _read_mirofish_model():
try:
with open(MIROFISH_ENV) as f:
for line in f:
if line.startswith('LLM_MODEL_NAME='):
return line.strip().split('=', 1)[1]
except:
pass
return 'unbekannt'
def _write_mirofish_env(model_cfg):
with open(MIROFISH_ENV) as f:
lines = f.readlines()
keys = {
'LLM_API_KEY': model_cfg['api_key'],
'LLM_BASE_URL': model_cfg['base_url'],
'LLM_MODEL_NAME': model_cfg['model'],
'OPENAI_API_KEY': model_cfg['api_key'],
'OPENAI_API_BASE_URL': model_cfg['base_url'],
}
new_lines = []
for line in lines:
replaced = False
for k, v in keys.items():
if line.startswith(k + '='):
new_lines.append(k + '=' + v + chr(10))
replaced = True
break
if not replaced:
new_lines.append(line)
with open(MIROFISH_ENV, 'w') as f:
f.writelines(new_lines)
@app.route('/api/mirofish/model', methods=['GET'])
def mirofish_model_get():
current = _read_mirofish_model()
return jsonify({'current': current, 'models': MIROFISH_MODELS})
@app.route('/api/mirofish/model', methods=['POST'])
def mirofish_model_set():
data = request.json
model_id = data.get('model')
cfg = next((m for m in MIROFISH_MODELS if m['model'] == model_id), None)
if not cfg:
return jsonify({'ok': False, 'error': 'Unbekanntes Modell'}), 400
_write_mirofish_env(cfg)
subprocess.run('cd /opt/struktur/mirofish && docker compose restart mirofish', shell=True, timeout=60)
return jsonify({'ok': True, 'model': model_id})
# βββ KANBAN API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
KANBAN_DB = '/opt/struktur/hermes-carlo/data/kanban.db'
VALID_STATUSES = {'backlog', 'todo', 'in_progress', 'blocked', 'done', 'triage'}
def _kanban_conn():
import sqlite3
conn = sqlite3.connect(KANBAN_DB)
conn.row_factory = sqlite3.Row
return conn
@app.route('/api/kanban/tasks', methods=['GET'])
def kanban_tasks_list():
status = request.args.get('status')
conn = _kanban_conn()
if status:
rows = conn.execute(
'SELECT id, title, body, status, priority, created_by, created_at FROM tasks WHERE status=? ORDER BY priority DESC, created_at DESC',
(status,)
).fetchall()
else:
rows = conn.execute(
'SELECT id, title, body, status, priority, created_by, created_at FROM tasks ORDER BY priority DESC, created_at DESC'
).fetchall()
conn.close()
return jsonify([dict(r) for r in rows])
@app.route('/api/kanban/task', methods=['POST'])
def kanban_task_create():
import sqlite3, re, time
data = request.json or {}
title = (data.get('title') or '').strip()
if not title:
return jsonify({'ok': False, 'error': 'title fehlt'}), 400
body = (data.get('body') or data.get('description') or '').strip()
priority = int(data.get('priority', 50))
status = data.get('status', 'backlog')
if status not in VALID_STATUSES:
status = 'backlog'
task_id = 'proj_' + re.sub(r'[^a-z0-9]', '_', title.lower())[:40]
conn = _kanban_conn()
try:
conn.execute(
'INSERT OR IGNORE INTO tasks (id, title, body, status, priority, created_by, created_at, workspace_kind) VALUES (?,?,?,?,?,?,?,?)',
(task_id, title, body, status, priority, 'carlo', int(time.time()), 'scratch')
)
conn.commit()
except Exception as e:
conn.close()
return jsonify({'ok': False, 'error': str(e)}), 500
conn.close()
return jsonify({'ok': True, 'id': task_id, 'title': title, 'status': status})
@app.route('/api/kanban/task/<task_id>', methods=['PATCH'])
def kanban_task_update(task_id):
import sqlite3, time
data = request.json or {}
allowed = {k: v for k, v in data.items() if k in ('title', 'body', 'status', 'priority')}
if 'status' in allowed and allowed['status'] not in VALID_STATUSES:
return jsonify({'ok': False, 'error': f"UngΓΌltiger Status. Erlaubt: {VALID_STATUSES}"}), 400
if not allowed:
return jsonify({'ok': False, 'error': 'Keine gΓΌltigen Felder'}), 400
set_clause = ', '.join(f'{k}=?' for k in allowed)
values = list(allowed.values()) + [task_id]
conn = _kanban_conn()
conn.execute(f'UPDATE tasks SET {set_clause} WHERE id=?', values)
conn.commit()
conn.close()
return jsonify({'ok': True, 'id': task_id, 'updated': list(allowed.keys())})
@app.route('/api/kanban/task/<task_id>', methods=['DELETE'])
def kanban_task_delete(task_id):
conn = _kanban_conn()
conn.execute('DELETE FROM tasks WHERE id=?', (task_id,))
conn.commit()
conn.close()
return jsonify({'ok': True, 'id': task_id})
@app.route('/api/hermes-reset', methods=['POST'])
def hermes_reset():
import urllib.request, urllib.error
try:
req = urllib.request.Request('http://127.0.0.1:9099/reset', method='POST', data=b'')
with urllib.request.urlopen(req, timeout=10) as r:
return jsonify({'ok': True})
except Exception as e:
return jsonify({'ok': False, 'error': str(e)}), 500
if __name__ == '__main__':
app.run(host='127.0.0.1', port=5053, debug=False)