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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 & 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); });
|
||||||
|
|||||||
@@ -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 ───────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user