feat(diagnostic): Anhang-Limit einstellbar + Datum/Uhrzeit bei Gedanken

Zwei Dinge, die bisher nur im Code bzw. nur in der DB standen.

Anhang-Limit (Einstellungen → System & Wartung):
- Das Limit war eine Env-Variable mit 20 MB Default — zum Anheben musste man
  die .env editieren und das Brain neu starten. Jetzt steht es in runtime.json
  und das Brain liest es bei JEDEM Upload neu: Aenderung greift sofort.
- Reihenfolge: runtime.json > ENV > Default. Anders als beim OpenAI-Endpunkt
  gewinnt hier NICHT die .env — dort erzwingt ein Docker-Port-Mapping das,
  hier gibt es keinen solchen Zwang, und eine Einstellung die nichts tut waere
  schlimmer als keine.
- Default von 20 auf 64 MB. Genau so viel holt die App auch auf ausdruecklichen
  Tipp noch herunter (TAP_DL_MAX_BYTES); darueber warnt die UI, verbietet es
  aber nicht — der Anhang liegt dann sauber auf dem Server, ist mobil nur nicht
  mehr ladbar.
- Neuer Endpunkt GET /attachments/limit. Das fest eingetippte "max 20 MB pro
  Datei" im HTML ist weg — es stimmte nach jeder Aenderung nicht mehr.
- Die Fehlermeldung nennt jetzt MB statt Bytes und sagt, wo die Einstellung ist.

Datum/Uhrzeit bei Gedanken:
- created_at/updated_at lagen schon immer in der DB und wurden ausgeliefert —
  nur nie angezeigt. Jede Zeile zeigt jetzt "angelegt <Zeit>" und, falls
  abweichend, "geaendert <Zeit>" plus ein relatives "(vor 3 Std)". Damit sieht
  man auf den ersten Blick, was der letzte Eintrag war.
- Sortier-Dropdown: nach Typ gruppiert (wie bisher) oder nach Anlage/Aenderung
  auf- und absteigend. Bei Zeit-Sortierung wird die Liste flach dargestellt —
  man will sehen was zuletzt passiert ist, nicht in welche Schublade es gehoert.
- Zeitraum-Filter (🕒) mit von/bis, umschaltbar zwischen angelegt und geaendert,
  plus Schnellwahl Heute / 7 / 30 Tage. Gilt auch fuer Such-Treffer, damit
  "suche X, aber nur letzte Woche" geht. Zuklappen schaltet ihn aus, und das
  ✕-Zuruecksetzen raeumt ihn mit auf (sonst filtert er unsichtbar weiter).

Beides gegen den echten Code getestet: 7 Faelle fuers Limit (inkl. kaputter
runtime.json und einem 50-MB-PDF an beiden Grenzen), 10 fuer Sortierung und
Zeitraum.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-03 10:33:54 +02:00
co-authored by Claude Opus 5
parent 7b46881677
commit 734868efab
4 changed files with 367 additions and 10 deletions
+13
View File
@@ -398,6 +398,19 @@ def memory_delete(point_id: str):
# ─── Memory-Anhaenge ────────────────────────────────────────────────── # ─── Memory-Anhaenge ──────────────────────────────────────────────────
@app.get("/attachments/limit")
def memory_attachments_limit():
"""Aktuelles Groessen-Limit fuer Memory-Anhaenge.
Die Oberflaechen zeigen damit die ECHTE Zahl an statt eines fest
eingetippten "max 20 MB" — das stand vorher im HTML und stimmte nicht mehr,
sobald jemand das Limit anhob.
"""
import memory_attachments as mem_att
limit = mem_att.max_bytes()
return {"maxBytes": limit, "maxMb": round(limit / 1024 / 1024, 1)}
@app.get("/memory/{point_id}/attachments") @app.get("/memory/{point_id}/attachments")
def memory_attachments_list(point_id: str): def memory_attachments_list(point_id: str):
"""Liste der Anhaenge zum Memory. Source-of-Truth ist das Payload """Liste der Anhaenge zum Memory. Source-of-Truth ist das Payload
+46 -5
View File
@@ -16,6 +16,7 @@ selbst hat in Stufe A kein Tool zum Upload.
from __future__ import annotations from __future__ import annotations
import base64 import base64
import json
import logging import logging
import mimetypes import mimetypes
import os import os
@@ -27,9 +28,41 @@ from typing import List, Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
ROOT = Path(os.environ.get("MEMORY_ATTACHMENTS_DIR", "/shared/memory-attachments")) ROOT = Path(os.environ.get("MEMORY_ATTACHMENTS_DIR", "/shared/memory-attachments"))
MAX_BYTES = int(os.environ.get("MEMORY_ATTACHMENT_MAX_BYTES", str(20 * 1024 * 1024))) # 20 MB
SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._\-]") SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._\-]")
RUNTIME_CONFIG_FILE = Path("/shared/config/runtime.json")
# 64 MB: so viel holt die App auch auf ausdruecklichen Tipp noch herunter
# (TAP_DL_MAX_BYTES in ChatScreen.tsx). Ein hoeheres Limit wuerde Anhaenge
# erlauben, die das Handy anschliessend nicht mehr laden kann.
_DEFAULT_MAX_BYTES = 64 * 1024 * 1024
def max_bytes() -> int:
"""Aktuelles Groessen-Limit fuer Anhaenge.
Reihenfolge: runtime.json (Diagnostic → System & Wartung) > ENV > Default.
Wird bei JEDEM Aufruf gelesen, damit eine Aenderung in der Diagnostic sofort
greift — ein Brain-Neustart nur fuers Anheben einer Zahl waere Unsinn.
"""
try:
with RUNTIME_CONFIG_FILE.open() as f:
mb = json.load(f).get("memoryAttachmentMaxMb")
if mb is not None:
val = int(float(mb) * 1024 * 1024)
if val > 0:
return val
except FileNotFoundError:
pass
except Exception as exc:
logger.warning("runtime.json lesen fehlgeschlagen: %s", exc)
env = os.environ.get("MEMORY_ATTACHMENT_MAX_BYTES")
if env:
try:
return int(env)
except ValueError:
logger.warning("MEMORY_ATTACHMENT_MAX_BYTES ist keine Zahl: %r", env)
return _DEFAULT_MAX_BYTES
def _safe_filename(name: str) -> str: def _safe_filename(name: str) -> str:
"""Macht aus einem User-Namen einen filesystem-sicheren String — """Macht aus einem User-Namen einen filesystem-sicheren String —
@@ -78,8 +111,12 @@ def save_attachment(memory_id: str, filename: str, data: bytes) -> dict:
Ueberschreibt eine bestehende Datei mit gleichem Namen.""" Ueberschreibt eine bestehende Datei mit gleichem Namen."""
if not memory_id: if not memory_id:
raise ValueError("memory_id ist Pflicht") raise ValueError("memory_id ist Pflicht")
if len(data) > MAX_BYTES: limit = max_bytes()
raise ValueError(f"Anhang zu gross ({len(data)} > {MAX_BYTES} Byte)") if len(data) > limit:
raise ValueError(
f"Anhang zu gross ({len(data) // (1024 * 1024)} MB > "
f"{limit // (1024 * 1024)} MB). Das Limit steht in der Diagnostic "
f"unter System & Wartung.")
safe = _safe_filename(filename) safe = _safe_filename(filename)
d = memory_dir(memory_id) d = memory_dir(memory_id)
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
@@ -165,8 +202,12 @@ def attach_from_path(memory_id: str, source_path: str) -> dict:
if not src.is_file(): if not src.is_file():
raise ValueError(f"Datei nicht gefunden: {source_path}") raise ValueError(f"Datei nicht gefunden: {source_path}")
size = src.stat().st_size size = src.stat().st_size
if size > MAX_BYTES: limit = max_bytes()
raise ValueError(f"Datei zu gross ({size} > {MAX_BYTES} Byte)") if size > limit:
raise ValueError(
f"Datei zu gross ({size // (1024 * 1024)} MB > "
f"{limit // (1024 * 1024)} MB). Das Limit steht in der Diagnostic "
f"unter System & Wartung.")
# Reuse save_attachment damit Filename-Sanitization + Logging konsistent # Reuse save_attachment damit Filename-Sanitization + Logging konsistent
data = src.read_bytes() data = src.read_bytes()
return save_attachment(memory_id, src.name, data) return save_attachment(memory_id, src.name, data)
+231 -5
View File
@@ -1051,6 +1051,31 @@
<div class="settings-subtab" data-sub="system"> <div class="settings-subtab" data-sub="system">
<!-- Anhang-Limit -->
<div class="settings-section">
<h2>Grösse von Anhängen</h2>
<div class="card" style="max-width:560px;line-height:1.6;">
<p style="color:#8888AA;font-size:12px;margin:0 0 10px;">
Grösste Datei, die an einen Gedanken angehängt werden darf.
Greift <strong>sofort</strong> — kein Neustart nötig.
</p>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<input type="number" id="attachment-limit-input" min="1" max="1024" step="1"
style="width:110px;background:#12121E;border:1px solid #2A2A3E;border-radius:6px;
color:#E0E0F0;padding:8px 10px;font-size:13px;">
<span style="color:#8888AA;font-size:12px;">MB</span>
<button class="btn secondary" onclick="saveAttachmentLimit()">Speichern</button>
</div>
<p style="color:#8888AA;font-size:11px;margin:10px 0 0;">
Über <strong>64 MB</strong> lädt die Handy-App Anhänge nicht mehr herunter —
ein Anhang muss als Base64 komplett in den Speicher, und daran ist die App
schon einmal gestorben. Er liegt dann sauber auf dem Server, ist mobil aber
nur noch über den Dateimanager erreichbar.
</p>
<div id="attachment-limit-status" style="margin-top:10px;font-size:11px;color:#8888AA;"></div>
</div>
</div>
<!-- Reparatur & Restart --> <!-- Reparatur & Restart -->
<div class="settings-section"> <div class="settings-section">
<h2>Reparatur & Restart</h2> <h2>Reparatur & Restart</h2>
@@ -1371,9 +1396,42 @@
<option value="pinned">📌 Nur Pinned</option> <option value="pinned">📌 Nur Pinned</option>
<option value="cold">Nur Cold</option> <option value="cold">Nur Cold</option>
</select> </select>
<select id="brain-sort" onchange="onBrainFiltersChanged()"
style="background:#12121E;border:1px solid #2A2A3E;border-radius:6px;color:#E0E0F0;padding:4px 8px;font-size:11px;"
title="Sortierung der Liste">
<option value="type">Nach Typ gruppiert</option>
<option value="updated_desc">Neueste Änderung zuerst</option>
<option value="updated_asc">Älteste Änderung zuerst</option>
<option value="created_desc">Neueste Anlage zuerst</option>
<option value="created_asc">Älteste Anlage zuerst</option>
</select>
<button class="btn secondary" onclick="toggleAdvancedSearch()" id="btn-advanced-search" style="padding:4px 8px;font-size:11px;color:#8888AA;" title="Erweiterte Suche mit AND/OR-Verknuepfungen">⌃ Erweitert</button> <button class="btn secondary" onclick="toggleAdvancedSearch()" id="btn-advanced-search" style="padding:4px 8px;font-size:11px;color:#8888AA;" title="Erweiterte Suche mit AND/OR-Verknuepfungen">⌃ Erweitert</button>
<button class="btn secondary" onclick="toggleDateFilter()" id="btn-date-filter" style="padding:4px 8px;font-size:11px;color:#8888AA;" title="Nach Zeitraum filtern">🕒 Zeitraum</button>
<button class="btn secondary" onclick="resetBrainFilters();loadBrainMemoryList()" style="padding:4px 8px;font-size:11px;color:#8888AA;" title="Suche + Filter zurücksetzen">✕</button> <button class="btn secondary" onclick="resetBrainFilters();loadBrainMemoryList()" style="padding:4px 8px;font-size:11px;color:#8888AA;" title="Suche + Filter zurücksetzen">✕</button>
</div> </div>
<div id="brain-date-panel" style="display:none;margin-top:10px;padding:10px;background:#080810;border:1px solid #1E1E2E;border-radius:6px;">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;font-size:11px;color:#8888AA;">
<select id="brain-date-field"
style="background:#12121E;border:1px solid #2A2A3E;border-radius:6px;color:#E0E0F0;padding:4px 8px;font-size:11px;">
<option value="updated">geändert</option>
<option value="created">angelegt</option>
</select>
<span>von</span>
<input type="datetime-local" id="brain-date-from"
style="background:#12121E;border:1px solid #2A2A3E;border-radius:6px;color:#E0E0F0;padding:4px 8px;font-size:11px;">
<span>bis</span>
<input type="datetime-local" id="brain-date-to"
style="background:#12121E;border:1px solid #2A2A3E;border-radius:6px;color:#E0E0F0;padding:4px 8px;font-size:11px;">
<button class="btn" onclick="onBrainFiltersChanged()" style="padding:4px 12px;font-size:11px;">Filtern</button>
<button class="btn secondary" onclick="clearDateFilter()" style="padding:4px 10px;font-size:11px;color:#8888AA;">Leeren</button>
</div>
<div style="display:flex;gap:6px;margin-top:8px;flex-wrap:wrap;">
<button class="btn secondary" onclick="setDateQuick(1)" style="padding:3px 9px;font-size:10px;">Heute</button>
<button class="btn secondary" onclick="setDateQuick(7)" style="padding:3px 9px;font-size:10px;">7 Tage</button>
<button class="btn secondary" onclick="setDateQuick(30)" style="padding:3px 9px;font-size:10px;">30 Tage</button>
<span style="color:#555570;font-size:10px;margin-left:auto;">Nur ein Feld ausfüllen = offenes Ende</span>
</div>
</div>
<div id="brain-advanced-panel" style="display:none;margin-top:10px;padding:10px;background:#080810;border:1px solid #1E1E2E;border-radius:6px;"> <div id="brain-advanced-panel" style="display:none;margin-top:10px;padding:10px;background:#080810;border:1px solid #1E1E2E;border-radius:6px;">
<div style="color:#8888AA;font-size:11px;margin-bottom:6px;"> <div style="color:#8888AA;font-size:11px;margin-bottom:6px;">
Mehrere Begriffe mit AND/OR verknuepfen — Volltext-Substring, case-insensitive, links-nach-rechts ausgewertet. Mehrere Begriffe mit AND/OR verknuepfen — Volltext-Substring, case-insensitive, links-nach-rechts ausgewertet.
@@ -1732,7 +1790,7 @@
<div id="memory-attachments-block" style="display:none;margin-top:14px;padding-top:10px;border-top:1px solid #1E1E2E;"> <div id="memory-attachments-block" style="display:none;margin-top:14px;padding-top:10px;border-top:1px solid #1E1E2E;">
<label style="display:flex;align-items:center;justify-content:space-between;font-size:11px;color:#8888AA;margin-bottom:6px;"> <label style="display:flex;align-items:center;justify-content:space-between;font-size:11px;color:#8888AA;margin-bottom:6px;">
<span>📎 Anhaenge</span> <span>📎 Anhaenge</span>
<span style="color:#555570;font-size:10px;">max 20 MB pro Datei</span> <span class="attachment-limit-label" style="color:#555570;font-size:10px;">Limit: Einstellungen → System &amp; Wartung</span>
</label> </label>
<div id="memory-attachments-list" style="display:flex;flex-direction:column;gap:4px;margin-bottom:6px;font-size:12px;color:#555570;"></div> <div id="memory-attachments-list" style="display:flex;flex-direction:column;gap:4px;margin-bottom:6px;font-size:12px;color:#555570;"></div>
<div style="display:flex;gap:6px;align-items:center;"> <div style="display:flex;gap:6px;align-items:center;">
@@ -2650,6 +2708,26 @@
return; return;
} }
if (msg.type === 'attachment_limit_info') {
const inp = document.getElementById('attachment-limit-input');
const st = document.getElementById('attachment-limit-status');
if (inp && msg.maxMb != null) inp.value = msg.maxMb;
// Das Label an der Upload-Stelle im Gehirn-Tab mitziehen, damit dort
// nicht weiter ein festes "max 20 MB" steht das nicht mehr stimmt.
if (msg.maxMb != null) {
attachmentLimitMb = msg.maxMb;
document.querySelectorAll('.attachment-limit-label').forEach(el => {
el.textContent = 'max ' + msg.maxMb + ' MB pro Datei';
});
}
if (st) {
st.textContent = msg.error ? ('Fehler: ' + msg.error) : (msg.info || '');
st.style.color = msg.error ? '#FF6B6B'
: (msg.maxMb > (msg.softMb || 64) ? '#FFD60A' : '#3FFF9F');
}
return;
}
// session_restarted / openclaw_config WS-Events entfernt — aria-core ist raus. // session_restarted / openclaw_config WS-Events entfernt — aria-core ist raus.
if (msg.type === 'model_info') { if (msg.type === 'model_info') {
const el = document.getElementById('setting-model'); const el = document.getElementById('setting-model');
@@ -4543,6 +4621,22 @@
// ── Haupt-Tab Navigation ────────────────────────────────── // ── Haupt-Tab Navigation ──────────────────────────────────
// Zuletzt bekanntes Anhang-Limit (MB) — nur fuer Anzeige/Vorab-Pruefung.
// Die verbindliche Pruefung macht das Brain.
let attachmentLimitMb = null;
function saveAttachmentLimit() {
const inp = document.getElementById('attachment-limit-input');
const st = document.getElementById('attachment-limit-status');
const v = Number(inp && inp.value);
if (!isFinite(v) || v <= 0) {
if (st) { st.textContent = 'Bitte eine Zahl grösser 0 angeben.'; st.style.color = '#FF6B6B'; }
return;
}
if (st) { st.textContent = 'Speichere …'; st.style.color = '#8888AA'; }
send({ action: 'set_attachment_limit', maxMb: v });
}
function switchSettingsTab(sub) { function switchSettingsTab(sub) {
sub = sub || 'models'; sub = sub || 'models';
document.querySelectorAll('.settings-subtab').forEach(el => el.classList.toggle('visible', el.dataset.sub === sub)); document.querySelectorAll('.settings-subtab').forEach(el => el.classList.toggle('visible', el.dataset.sub === sub));
@@ -4567,9 +4661,12 @@
loadOAuthServices(); loadOAuthServices();
loadJellyfinConfig(); loadJellyfinConfig();
refreshVoiceIdStatus(); refreshVoiceIdStatus();
send({ action: 'get_attachment_limit' });
} else if (tab === 'brain') { } else if (tab === 'brain') {
loadBrainStatus(); loadBrainStatus();
loadBrainMemoryList(); loadBrainMemoryList();
// Damit das Anhang-Label die echte Zahl zeigt, nicht eine fest eingetippte.
send({ action: 'get_attachment_limit' });
refreshImportFiles(); refreshImportFiles();
loadMetrics(); loadMetrics();
loadProjects(); loadProjects();
@@ -6493,6 +6590,14 @@
const t = document.getElementById('brain-filter-type'); if (t) t.value = ''; const t = document.getElementById('brain-filter-type'); if (t) t.value = '';
const p = document.getElementById('brain-filter-pinned'); if (p) p.value = 'all'; const p = document.getElementById('brain-filter-pinned'); if (p) p.value = 'all';
const info = document.getElementById('brain-search-info'); if (info) info.style.display = 'none'; const info = document.getElementById('brain-search-info'); if (info) info.style.display = 'none';
const so = document.getElementById('brain-sort'); if (so) so.value = 'type';
// Zeitraum-Panel zu + leeren, sonst filtert es nach dem ✕ unsichtbar weiter
const dp = document.getElementById('brain-date-panel');
if (dp) dp.style.display = 'none';
const db = document.getElementById('btn-date-filter');
if (db) db.style.color = '#8888AA';
const df = document.getElementById('brain-date-from'); if (df) df.value = '';
const dt = document.getElementById('brain-date-to'); if (dt) dt.value = '';
brainSearchIds = null; brainSearchIds = null;
clearAdvancedSearch(); clearAdvancedSearch();
} }
@@ -6745,7 +6850,10 @@
if (!el) return; if (!el) return;
// Wenn aktive Search-Treffer da sind: die anzeigen, nicht neu laden // Wenn aktive Search-Treffer da sind: die anzeigen, nicht neu laden
if (brainSearchIds && brainSearchIds.length) { if (brainSearchIds && brainSearchIds.length) {
const items = brainSearchIds.map(id => brainMemoryCache[id]).filter(Boolean); // Zeitraum gilt auch fuer Such-Treffer — sonst waere "suche X,
// aber nur von letzter Woche" nicht moeglich.
const items = applyDateFilter(
brainSearchIds.map(id => brainMemoryCache[id]).filter(Boolean));
renderBrainList(items, true); renderBrainList(items, true);
return; return;
} }
@@ -6761,8 +6869,12 @@
items.forEach(m => { brainMemoryCache[m.id] = m; }); items.forEach(m => { brainMemoryCache[m.id] = m; });
if (pinnedFilter === 'pinned') items = items.filter(m => m.pinned); if (pinnedFilter === 'pinned') items = items.filter(m => m.pinned);
else if (pinnedFilter === 'cold') items = items.filter(m => !m.pinned); else if (pinnedFilter === 'cold') items = items.filter(m => !m.pinned);
const before = items.length;
items = applyDateFilter(items);
if (!items.length) { if (!items.length) {
el.innerHTML = '(Keine Memories — leere DB oder Filter zu eng)'; el.innerHTML = before > 0
? `(Kein Gedanke im gewählten Zeitraum — ${before} wurden durch den Zeitfilter ausgeblendet)`
: '(Keine Memories — leere DB oder Filter zu eng)';
return; return;
} }
renderBrainList(items, false); renderBrainList(items, false);
@@ -6831,6 +6943,31 @@
`); `);
} }
// ─── Datum/Uhrzeit eines Gedankens ────────────────────────────────
// created_at / updated_at liegen schon immer als ISO-String in der DB —
// sie wurden nur nie angezeigt. Darum sah man nicht, was der letzte
// Eintrag war.
function memTs(m, which) {
const raw = which === 'created' ? m.created_at : (m.updated_at || m.created_at);
if (!raw) return 0;
const t = Date.parse(raw);
return isFinite(t) ? t : 0;
}
function fmtMemTs(ms) {
if (!ms) return '';
return new Date(ms).toLocaleString('de-DE',
{ day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' });
}
/** "vor 3 Min" / "vor 2 Std" / "vor 5 Tagen" — fuer "was war der letzte Eintrag". */
function fmtMemAgo(ms) {
if (!ms) return '';
const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
if (s < 60) return 'gerade';
if (s < 3600) return `vor ${Math.floor(s / 60)} Min`;
if (s < 86400) return `vor ${Math.floor(s / 3600)} Std`;
return `vor ${Math.floor(s / 86400)} Tg`;
}
function renderMemoryRow(m, withScore) { function renderMemoryRow(m, withScore) {
const pin = m.pinned ? '📌 ' : ''; const pin = m.pinned ? '📌 ' : '';
const preview = (m.content || '').slice(0, 140).replace(/\n/g, ' '); const preview = (m.content || '').slice(0, 140).replace(/\n/g, ' ');
@@ -6844,12 +6981,25 @@
? `<span style="color:#3FFF3F;font-size:9px;margin-left:6px;border:1px solid #3FFF3F;border-radius:3px;padding:0 3px;" title="System-Regel — kommt in den System-Export">SYS</span>` ? `<span style="color:#3FFF3F;font-size:9px;margin-left:6px;border:1px solid #3FFF3F;border-radius:3px;padding:0 3px;" title="System-Regel — kommt in den System-Export">SYS</span>`
: `<span style="color:#FF9F0A;font-size:9px;margin-left:6px;border:1px solid #FF9F0A;border-radius:3px;padding:0 3px;" title="Persönlich — bleibt privat">PRIV</span>`) : `<span style="color:#FF9F0A;font-size:9px;margin-left:6px;border:1px solid #FF9F0A;border-radius:3px;padding:0 3px;" title="Persönlich — bleibt privat">PRIV</span>`)
: ''; : '';
// Zeitstempel-Zeile: angelegt immer, geaendert nur wenn es sich
// unterscheidet (sonst stehen zwei identische Zeiten nebeneinander).
const cMs = memTs(m, 'created');
const uMs = memTs(m, 'updated');
const tsLine = (cMs || uMs)
? `<div style="color:#555570;font-size:10px;margin-top:2px;">`
+ (cMs ? `angelegt ${fmtMemTs(cMs)}` : '')
+ (uMs && Math.abs(uMs - cMs) > 60000
? ` · geändert ${fmtMemTs(uMs)} <span style="color:#0096FF;">(${fmtMemAgo(uMs)})</span>`
: (cMs ? ` <span style="color:#0096FF;">(${fmtMemAgo(cMs)})</span>` : ''))
+ `</div>`
: '';
return `<div style="padding:6px 0;border-bottom:1px solid #1E1E2E;display:flex;gap:6px;align-items:flex-start;"> return `<div style="padding:6px 0;border-bottom:1px solid #1E1E2E;display:flex;gap:6px;align-items:flex-start;">
<div style="flex:1;min-width:0;cursor:pointer;" onclick="openMemoryModal('${m.id}')"> <div style="flex:1;min-width:0;cursor:pointer;" onclick="openMemoryModal('${m.id}')">
<div style="color:#E0E0F0;font-size:12px;">${typeBadge}${pin}<strong>${escapeHtml(m.title || '(ohne Titel)')}</strong>${score}${attBadge}${scopeBadge} <div style="color:#E0E0F0;font-size:12px;">${typeBadge}${pin}<strong>${escapeHtml(m.title || '(ohne Titel)')}</strong>${score}${attBadge}${scopeBadge}
${m.category ? `<span style="color:#555570;font-weight:normal;font-size:10px;margin-left:6px;">[${escapeHtml(m.category)}]</span>` : ''} ${m.category ? `<span style="color:#555570;font-weight:normal;font-size:10px;margin-left:6px;">[${escapeHtml(m.category)}]</span>` : ''}
</div> </div>
<div style="color:#888;font-size:11px;line-height:1.4;">${escapeHtml(preview)}${m.content && m.content.length > 140 ? '...' : ''}</div> <div style="color:#888;font-size:11px;line-height:1.4;">${escapeHtml(preview)}${m.content && m.content.length > 140 ? '...' : ''}</div>
${tsLine}
</div> </div>
<button class="btn secondary" onclick="event.stopPropagation();openMemoryModal('${m.id}')" style="padding:2px 8px;font-size:10px;flex-shrink:0;" title="Bearbeiten">✎</button> <button class="btn secondary" onclick="event.stopPropagation();openMemoryModal('${m.id}')" style="padding:2px 8px;font-size:10px;flex-shrink:0;" title="Bearbeiten">✎</button>
<button class="btn secondary" onclick="event.stopPropagation();deleteMemory('${m.id}')" style="padding:2px 8px;font-size:10px;flex-shrink:0;color:#FF6B6B;border-color:#FF6B6B;" title="Loeschen">🗑</button> <button class="btn secondary" onclick="event.stopPropagation();deleteMemory('${m.id}')" style="padding:2px 8px;font-size:10px;flex-shrink:0;color:#FF6B6B;border-color:#FF6B6B;" title="Loeschen">🗑</button>
@@ -6867,6 +7017,71 @@
</div>`; </div>`;
} }
/** Sortiert nach der Auswahl im brain-sort-Dropdown. 'type' = unveraendert. */
function sortMemories(items) {
const mode = document.getElementById('brain-sort')?.value || 'type';
if (mode === 'type') return items;
const field = mode.startsWith('created') ? 'created' : 'updated';
const desc = mode.endsWith('_desc');
// Kopie sortieren — die Aufrufer halten teils denselben Array weiter.
return items.slice().sort((a, b) => {
const d = memTs(a, field) - memTs(b, field);
return desc ? -d : d;
});
}
/** Zeitraum-Filter aus dem 🕒-Panel. Leere Felder = offenes Ende. */
function applyDateFilter(items) {
const panel = document.getElementById('brain-date-panel');
if (!panel || panel.style.display === 'none') return items;
const field = document.getElementById('brain-date-field')?.value || 'updated';
const fromRaw = document.getElementById('brain-date-from')?.value || '';
const toRaw = document.getElementById('brain-date-to')?.value || '';
const from = fromRaw ? Date.parse(fromRaw) : null;
const to = toRaw ? Date.parse(toRaw) : null;
if (from == null && to == null) return items;
return items.filter(m => {
const t = memTs(m, field);
if (!t) return false; // ohne Zeitstempel nicht in einen Zeitraum einsortierbar
if (from != null && t < from) return false;
if (to != null && t > to) return false;
return true;
});
}
function toggleDateFilter() {
const panel = document.getElementById('brain-date-panel');
const btn = document.getElementById('btn-date-filter');
if (!panel) return;
const open = panel.style.display === 'none';
panel.style.display = open ? 'block' : 'none';
if (btn) btn.style.color = open ? '#FFD60A' : '#8888AA';
if (!open) loadBrainMemoryList(); // zugeklappt = Filter aus
}
function clearDateFilter() {
const f = document.getElementById('brain-date-from');
const t = document.getElementById('brain-date-to');
if (f) f.value = '';
if (t) t.value = '';
loadBrainMemoryList();
}
/** Schnellwahl: letzte N Tage (0 Uhr bis jetzt). */
function setDateQuick(days) {
const now = new Date();
const from = new Date(now.getTime() - (days - 1) * 86400000);
from.setHours(0, 0, 0, 0);
const pad = (n) => String(n).padStart(2, '0');
const fmt = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
+ `T${pad(d.getHours())}:${pad(d.getMinutes())}`;
const ef = document.getElementById('brain-date-from');
const et = document.getElementById('brain-date-to');
if (ef) ef.value = fmt(from);
if (et) et.value = fmt(now);
loadBrainMemoryList();
}
function renderBrainList(items, isSearchResult) { function renderBrainList(items, isSearchResult) {
const el = document.getElementById('brain-memory-list'); const el = document.getElementById('brain-memory-list');
if (!el) return; if (!el) return;
@@ -6875,11 +7090,22 @@
_updateCategoryDatalist(items); _updateCategoryDatalist(items);
if (isSearchResult) { if (isSearchResult) {
// Such-Treffer: in Aehnlichkeits-Reihenfolge, kein Type-Gruppieren // Such-Treffer: Aehnlichkeits-Reihenfolge, ausser es ist bewusst eine
const html = items.map(m => renderMemoryRow(m, true)).join(''); // Zeit-Sortierung gewaehlt — dann gilt die auch fuer Treffer.
const sorted = sortMemories(items);
const html = sorted.map(m => renderMemoryRow(m, true)).join('');
el.innerHTML = html || '(Keine Treffer)'; el.innerHTML = html || '(Keine Treffer)';
return; return;
} }
// Zeit-Sortierung gewaehlt → flache Liste, keine Typ-Gruppen. Das ist der
// Sinn der Sache: man will sehen, was zuletzt passiert ist, nicht in
// welche Schublade es gehoert.
const sortMode = document.getElementById('brain-sort')?.value || 'type';
if (sortMode !== 'type') {
const html = sortMemories(items).map(m => renderMemoryRow(m, false)).join('');
el.innerHTML = html || '(Keine Memories)';
return;
}
// Normale Liste: nach Type gruppieren, Header klappbar // Normale Liste: nach Type gruppieren, Header klappbar
const byType = {}; const byType = {};
items.forEach(m => { (byType[m.type] = byType[m.type] || []).push(m); }); items.forEach(m => { (byType[m.type] = byType[m.type] || []).push(m); });
+77
View File
@@ -268,6 +268,9 @@ const RUNTIME_CONFIG_FIELDS = [
"RVS_HOST", "RVS_PORT", "RVS_TLS", "RVS_TOKEN", "RVS_HOST", "RVS_PORT", "RVS_TLS", "RVS_TOKEN",
"ARIA_AUTH_TOKEN", "WHISPER_MODEL", "WHISPER_LANGUAGE", "ARIA_AUTH_TOKEN", "WHISPER_MODEL", "WHISPER_LANGUAGE",
"brainModel", "brainModel",
// Groessen-Limit fuer Memory-Anhaenge in MB. Das Brain liest es pro Upload
// aus derselben Datei — Aenderung greift ohne Neustart.
"memoryAttachmentMaxMb",
]; ];
function readRuntimeConfig() { function readRuntimeConfig() {
const envDefaults = { const envDefaults = {
@@ -3207,6 +3210,10 @@ wss.on("connection", (ws) => {
handleGetModel(ws); handleGetModel(ws);
} else if (msg.action === "set_model") { } else if (msg.action === "set_model") {
handleSetModel(ws, msg.model); handleSetModel(ws, msg.model);
} else if (msg.action === "get_attachment_limit") {
handleGetAttachmentLimit(ws);
} else if (msg.action === "set_attachment_limit") {
handleSetAttachmentLimit(ws, msg.maxMb);
} else if (msg.action === "voice_id_status") { } else if (msg.action === "voice_id_status") {
// An whisper-bridge weiterleiten + Antwort an Browser zurueck // An whisper-bridge weiterleiten + Antwort an Browser zurueck
const reqId = `vid_${Date.now().toString(36)}`; const reqId = `vid_${Date.now().toString(36)}`;
@@ -3629,6 +3636,76 @@ function handleSetModel(clientWs, model) {
return; return;
} }
// ── Einstellungen: Groessen-Limit fuer Memory-Anhaenge ──────────────
// Steht in runtime.json als `memoryAttachmentMaxMb`. Das Brain liest es bei
// JEDEM Upload neu (memory_attachments.max_bytes()) — eine Aenderung greift
// also sofort, ohne aria-brain neu zu starten.
//
// Obergrenze mit Bedacht: der Anhang geht als base64-in-JSON zur App, die muss
// ihn komplett in den Speicher nehmen. Ueber ~64 MB laedt das Handy ihn nicht
// mehr (TAP_DL_MAX_BYTES in ChatScreen.tsx) — dann liegt er zwar sauber auf dem
// Server, ist mobil aber nur noch per Dateimanager erreichbar. Darum warnen wir
// darueber, verbieten es aber nicht.
const ATTACHMENT_LIMIT_SOFT_MB = 64;
const ATTACHMENT_LIMIT_MAX_MB = 1024;
function handleGetAttachmentLimit(clientWs) {
// Das Brain ist die Wahrheit — es kennt auch den ENV-Fallback, den der
// Diagnostic-Container nicht sieht.
const brain = process.env.BRAIN_URL || "http://aria-brain:8080";
const req = http.get(`${brain}/attachments/limit`, { timeout: 5000 }, (r) => {
let body = "";
r.on("data", (c) => { body += c; });
r.on("end", () => {
let maxMb = null;
try { maxMb = JSON.parse(body).maxMb; } catch (_) {}
clientWs.send(JSON.stringify({
type: "attachment_limit_info",
maxMb,
softMb: ATTACHMENT_LIMIT_SOFT_MB,
hardMb: ATTACHMENT_LIMIT_MAX_MB,
error: maxMb == null ? "Brain antwortet nicht verwertbar" : undefined,
}));
});
});
req.on("error", (err) => {
clientWs.send(JSON.stringify({ type: "attachment_limit_info", error: err.message }));
});
req.on("timeout", () => { req.destroy(new Error("Brain antwortet nicht")); });
}
function handleSetAttachmentLimit(clientWs, maxMb) {
const n = Number(maxMb);
if (!isFinite(n) || n <= 0) {
clientWs.send(JSON.stringify({
type: "attachment_limit_info", error: "Bitte eine Zahl groesser 0 angeben (MB)" }));
return;
}
if (n > ATTACHMENT_LIMIT_MAX_MB) {
clientWs.send(JSON.stringify({
type: "attachment_limit_info",
error: `Maximal ${ATTACHMENT_LIMIT_MAX_MB} MB — darueber reisst der WebSocket-Transport` }));
return;
}
try {
writeRuntimeConfig({ memoryAttachmentMaxMb: n });
log("info", "server", `Anhang-Limit gesetzt: ${n} MB`);
clientWs.send(JSON.stringify({
type: "attachment_limit_info",
maxMb: n,
softMb: ATTACHMENT_LIMIT_SOFT_MB,
hardMb: ATTACHMENT_LIMIT_MAX_MB,
info: n > ATTACHMENT_LIMIT_SOFT_MB
? `${n} MB gesetzt (greift sofort). Hinweis: ueber ${ATTACHMENT_LIMIT_SOFT_MB} MB `
+ `laedt die Handy-App Anhaenge nicht mehr herunter — sie liegen dann nur `
+ `noch auf dem Server.`
: `${n} MB gesetzt — greift sofort, kein Neustart noetig.`,
}));
} catch (err) {
clientWs.send(JSON.stringify({ type: "attachment_limit_info", error: err.message }));
}
}
// OpenClaw-Config-Handler entfernt — aria-core ist raus. // OpenClaw-Config-Handler entfernt — aria-core ist raus.
// ── Start ─────────────────────────────────────────────── // ── Start ───────────────────────────────────────────────