mirror of
https://gitcode.com/JianFeeeee/TrulyMEM-TrueHumanMEM.git
synced 2026-10-15 12:15:32 +00:00
feat: 日志持久化(6h归档) + purge增强(source_type/source_has_status过滤)
- ActivityRecorder 新增 LogPersister 后台线程,每10s增量写入logs/operations.*.log - 日志每6小时自动gzip压缩归档 - memory_purge 新增 source_type / target_type / source_has_status 过滤条件 - 支持精准清理已归档任务的残留 HAS_STATE 关系
This commit is contained in:
@ -1,5 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -euo pipefail
|
||||||
|
|
||||||
echo "===== Building TrulyMEM AppImage ====="
|
echo "===== Building TrulyMEM AppImage ====="
|
||||||
|
|
||||||
@ -11,39 +11,65 @@ echo "Project root: $PROJECT_ROOT"
|
|||||||
APP_NAME="TrulyMEM"
|
APP_NAME="TrulyMEM"
|
||||||
APP_DIR="$PROJECT_ROOT/build/appimage-build"
|
APP_DIR="$PROJECT_ROOT/build/appimage-build"
|
||||||
|
|
||||||
echo "Cleaning..."
|
# ── Step 1: 复用 build_linux.sh 完成 PyInstaller 构建 ──
|
||||||
rm -rf "$APP_DIR" dist/ build/trulymem/ 2>/dev/null || true
|
echo ""
|
||||||
|
echo "Step 1: Running build_linux.sh (PyInstaller build)..."
|
||||||
|
bash "$SCRIPT_DIR/build_linux.sh"
|
||||||
|
|
||||||
# Step 1: Build with PyInstaller (uses icon from .spec)
|
# ── Step 2: 检查 dist/ 中是否有二进制 ──
|
||||||
echo "Step 1: PyInstaller build..."
|
echo ""
|
||||||
python3 -m PyInstaller --clean build/trulymem.spec --noconfirm
|
echo "Step 2: Checking PyInstaller output..."
|
||||||
|
if [ ! -f "dist/$APP_NAME" ]; then
|
||||||
|
echo "Error: dist/$APP_NAME not found after build_linux.sh"; exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ Found dist/$APP_NAME ($(ls -lh "dist/$APP_NAME" | awk '{print $5}'))"
|
||||||
|
|
||||||
# Step 2: Prepare AppDir structure
|
# ── Step 3: 组织 AppDir 结构 ──
|
||||||
echo "Step 2: Preparing AppDir..."
|
echo ""
|
||||||
|
echo "Step 3: Preparing AppDir structure..."
|
||||||
|
rm -rf "$APP_DIR" 2>/dev/null || true
|
||||||
mkdir -p "$APP_DIR/usr/bin"
|
mkdir -p "$APP_DIR/usr/bin"
|
||||||
mkdir -p "$APP_DIR/usr/share/applications"
|
mkdir -p "$APP_DIR/usr/share/applications"
|
||||||
mkdir -p "$APP_DIR/usr/share/icons/hicolor/256x256/apps"
|
mkdir -p "$APP_DIR/usr/share/icons/hicolor/256x256/apps"
|
||||||
|
mkdir -p "$APP_DIR/usr/share/icons/hicolor/48x48/apps"
|
||||||
|
|
||||||
cp "dist/$APP_NAME" "$APP_DIR/usr/bin/"
|
cp "dist/$APP_NAME" "$APP_DIR/usr/bin/"
|
||||||
|
|
||||||
|
# 图标处理
|
||||||
|
ICON_SOURCE=""
|
||||||
if [ -f "pic/image.png" ]; then
|
if [ -f "pic/image.png" ]; then
|
||||||
cp "pic/image.png" "$APP_DIR/usr/share/icons/hicolor/256x256/apps/${APP_NAME}.png"
|
ICON_SOURCE="pic/image.png"
|
||||||
cp "pic/image.png" "$APP_DIR/${APP_NAME}.png"
|
elif [ -f "pic/TrulyMEM.ico" ]; then
|
||||||
else
|
echo "⚠️ No pic/image.png found; .ico will not display as AppImage icon"
|
||||||
echo -n "" > "$APP_DIR/${APP_NAME}.png"
|
echo " To generate a PNG: convert pic/TrulyMEM.ico pic/image.png"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ -n "$ICON_SOURCE" ]; then
|
||||||
|
cp "$ICON_SOURCE" "$APP_DIR/usr/share/icons/hicolor/256x256/apps/${APP_NAME}.png"
|
||||||
|
cp "$ICON_SOURCE" "$APP_DIR/usr/share/icons/hicolor/48x48/apps/${APP_NAME}.png"
|
||||||
|
cp "$ICON_SOURCE" "$APP_DIR/${APP_NAME}.png"
|
||||||
|
echo "✅ Icon: $ICON_SOURCE"
|
||||||
|
else
|
||||||
|
echo "⚠️ No icon found, creating placeholder"
|
||||||
|
touch "$APP_DIR/${APP_NAME}.png"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# .desktop 文件
|
||||||
cat > "$APP_DIR/${APP_NAME}.desktop" <<EOF
|
cat > "$APP_DIR/${APP_NAME}.desktop" <<EOF
|
||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Name=${APP_NAME}
|
Name=${APP_NAME}
|
||||||
|
Comment=True Human Memory - TUI & Web Mode
|
||||||
Exec=${APP_NAME}
|
Exec=${APP_NAME}
|
||||||
Icon=${APP_NAME}
|
Icon=${APP_NAME}
|
||||||
Type=Application
|
Type=Application
|
||||||
Categories=Utility;
|
Categories=Utility;Office;
|
||||||
Terminal=true
|
Terminal=true
|
||||||
|
StartupNotify=true
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
cp "$APP_DIR/${APP_NAME}.desktop" "$APP_DIR/usr/share/applications/"
|
cp "$APP_DIR/${APP_NAME}.desktop" "$APP_DIR/usr/share/applications/"
|
||||||
|
|
||||||
|
# AppRun 入口
|
||||||
cat > "$APP_DIR/AppRun" <<'APPRUN'
|
cat > "$APP_DIR/AppRun" <<'APPRUN'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
HERE="$(dirname "$(readlink -f "$0")")"
|
HERE="$(dirname "$(readlink -f "$0")")"
|
||||||
@ -51,15 +77,24 @@ exec "$HERE/usr/bin/TrulyMEM" "$@"
|
|||||||
APPRUN
|
APPRUN
|
||||||
chmod +x "$APP_DIR/AppRun"
|
chmod +x "$APP_DIR/AppRun"
|
||||||
|
|
||||||
# Step 3: Build AppImage (requires appimagetool)
|
# ── Step 4: 打包 AppImage ──
|
||||||
echo "Step 3: Building AppImage..."
|
echo ""
|
||||||
|
echo "Step 4: Building AppImage..."
|
||||||
if command -v appimagetool &> /dev/null; then
|
if command -v appimagetool &> /dev/null; then
|
||||||
appimagetool "$APP_DIR" "dist/${APP_NAME}.AppImage"
|
ARCH="${ARCH:-$(uname -m)}" appimagetool "$APP_DIR" "dist/${APP_NAME}.AppImage"
|
||||||
echo "AppImage: dist/${APP_NAME}.AppImage"
|
echo "✅ AppImage: dist/${APP_NAME}.AppImage"
|
||||||
ls -la "dist/${APP_NAME}.AppImage"
|
ls -lh "dist/${APP_NAME}.AppImage"
|
||||||
else
|
else
|
||||||
echo "appimagetool not found. AppDir ready at: $APP_DIR"
|
echo "⚠️ appimagetool not found. AppDir ready at: $APP_DIR"
|
||||||
echo "Install appimagetool and run: appimagetool '$APP_DIR' 'dist/${APP_NAME}.AppImage'"
|
echo ""
|
||||||
|
echo "To complete manually, install appimagetool:"
|
||||||
|
echo " wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-$(uname -m).AppImage"
|
||||||
|
echo " chmod +x appimagetool-*.AppImage"
|
||||||
|
echo " ./appimagetool-*.AppImage '$APP_DIR' 'dist/${APP_NAME}.AppImage'"
|
||||||
|
echo ""
|
||||||
|
echo "AppDir contents:"
|
||||||
|
find "$APP_DIR" -type f | head -20
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "===== AppImage Build Complete ====="
|
echo ""
|
||||||
|
echo "===== AppImage Build Complete ====="
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -euo pipefail
|
||||||
|
|
||||||
echo "===== Building TrulyMEM for Linux ====="
|
echo "===== Building TrulyMEM for Linux ====="
|
||||||
|
|
||||||
@ -8,47 +8,102 @@ PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
|||||||
cd "$PROJECT_ROOT"
|
cd "$PROJECT_ROOT"
|
||||||
echo "Project root: $PROJECT_ROOT"
|
echo "Project root: $PROJECT_ROOT"
|
||||||
|
|
||||||
|
# ── 前置检查 ──
|
||||||
if ! command -v python3 &> /dev/null; then
|
if ! command -v python3 &> /dev/null; then
|
||||||
echo "Error: python3 not found"; exit 1
|
echo "Error: python3 not found"; exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if [ ! -f requirements.txt ]; then
|
||||||
|
echo "Error: requirements.txt not found in $PROJECT_ROOT"; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 检测 python3-venv ──
|
||||||
|
VENV_AVAILABLE=false
|
||||||
|
if python3 -c "import ensurepip" 2>/dev/null && python3 -m venv --help &>/dev/null; then
|
||||||
|
VENV_AVAILABLE=true
|
||||||
|
else
|
||||||
|
echo "⚠️ python3-venv 未安装(或缺少 ensurepip),建议安装以获得干净构建环境:"
|
||||||
|
echo " sudo apt install python3-venv # Debian/Ubuntu"
|
||||||
|
echo " sudo dnf install python3-virtualenv # Fedora"
|
||||||
|
echo "将使用系统 Python 环境继续(依赖全局包)..."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
# ── 尝试 venv 隔离构建,失败则用系统环境 ──
|
# ── 尝试 venv 隔离构建,失败则用系统环境 ──
|
||||||
USE_VENV=false
|
USE_VENV=false
|
||||||
if python3 -m venv --help &>/dev/null; then
|
if [ "$VENV_AVAILABLE" = true ]; then
|
||||||
VENV_DIR="$PROJECT_ROOT/.venv_build"
|
VENV_DIR="$PROJECT_ROOT/.venv_build"
|
||||||
|
echo "Creating virtual environment..."
|
||||||
if python3 -m venv "$VENV_DIR" 2>/dev/null; then
|
if python3 -m venv "$VENV_DIR" 2>/dev/null; then
|
||||||
source "$VENV_DIR/bin/activate"
|
source "$VENV_DIR/bin/activate"
|
||||||
USE_VENV=true
|
USE_VENV=true
|
||||||
echo "Using virtual environment"
|
echo "✅ Using virtual environment: $VENV_DIR"
|
||||||
pip install --upgrade pip -q
|
pip install --upgrade pip -q
|
||||||
pip install -r requirements.txt -q
|
pip install -r requirements.txt -q
|
||||||
else
|
else
|
||||||
echo "Warning: venv creation failed, falling back to system Python"
|
echo "⚠️ venv creation failed, falling back to system Python"
|
||||||
fi
|
fi
|
||||||
else
|
|
||||||
echo "Warning: python3-venv not available, falling back to system Python"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$USE_VENV" = false ]; then
|
if [ "$USE_VENV" = false ]; then
|
||||||
|
echo "Installing dependencies (system Python)..."
|
||||||
pip install -r requirements.txt --break-system-packages -q 2>/dev/null || \
|
pip install -r requirements.txt --break-system-packages -q 2>/dev/null || \
|
||||||
pip install -r requirements.txt -q 2>/dev/null || true
|
pip install -r requirements.txt -q 2>/dev/null || {
|
||||||
|
echo "⚠️ pip install failed, trying pip3..."
|
||||||
|
pip3 install -r requirements.txt --break-system-packages -q 2>/dev/null || \
|
||||||
|
pip3 install -r requirements.txt -q 2>/dev/null || \
|
||||||
|
echo "⚠️ Some dependencies may be missing; build will proceed anyway"
|
||||||
|
}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── 清理旧构建 ──
|
||||||
|
echo ""
|
||||||
echo "Cleaning previous builds..."
|
echo "Cleaning previous builds..."
|
||||||
rm -rf dist/ build/trulymem/ 2>/dev/null || true
|
rm -rf dist/ build/trulymem/ 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── PyInstaller 构建 ──
|
||||||
|
echo ""
|
||||||
echo "================================"
|
echo "================================"
|
||||||
echo "Building TrulyMEM (TUI + Web embedded)"
|
echo "Building TrulyMEM (TUI + Web embedded)"
|
||||||
echo "================================"
|
echo "================================"
|
||||||
python3 -m PyInstaller --clean build/trulymem.spec --noconfirm
|
python3 -m PyInstaller --clean build/trulymem.spec --noconfirm
|
||||||
|
|
||||||
|
# ── 创建 Linux `.desktop` 文件(打包图标不能嵌入 ELF,通过 .desktop 引用)──
|
||||||
|
echo ""
|
||||||
|
echo "Generating .desktop file for Linux..."
|
||||||
|
BINARY_PATH="$(cd dist && pwd)/TrulyMEM"
|
||||||
|
ICON_PATH="$(cd pic && pwd)/image.png"
|
||||||
|
|
||||||
|
cat > "dist/TrulyMEM.desktop" <<EOF
|
||||||
|
[Desktop Entry]
|
||||||
|
Name=TrulyMEM
|
||||||
|
Comment=True Human Memory - TUI & Web Mode
|
||||||
|
Exec=${BINARY_PATH}
|
||||||
|
Icon=${ICON_PATH}
|
||||||
|
Terminal=true
|
||||||
|
Type=Application
|
||||||
|
Categories=Utility;Office;
|
||||||
|
StartupNotify=true
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod +x "dist/TrulyMEM.desktop"
|
||||||
|
echo "✅ dist/TrulyMEM.desktop created (icon: pic/image.png)"
|
||||||
|
|
||||||
|
# ── 构建完成 ──
|
||||||
|
echo ""
|
||||||
echo "================================"
|
echo "================================"
|
||||||
echo "===== Build Complete ====="
|
echo "===== Build Complete ====="
|
||||||
echo "Binary: dist/TrulyMEM"
|
echo "Binary: dist/TrulyMEM"
|
||||||
ls -la dist/
|
echo "Desktop: dist/TrulyMEM.desktop"
|
||||||
|
echo "------------------------------"
|
||||||
|
ls -lh dist/ 2>/dev/null || ls -la dist/
|
||||||
|
echo "================================"
|
||||||
|
|
||||||
|
# ── 清理 venv ──
|
||||||
if [ "$USE_VENV" = true ]; then
|
if [ "$USE_VENV" = true ]; then
|
||||||
deactivate 2>/dev/null || true
|
deactivate 2>/dev/null || true
|
||||||
rm -rf "$VENV_DIR"
|
rm -rf "$VENV_DIR"
|
||||||
|
echo "Virtual environment cleaned up."
|
||||||
fi
|
fi
|
||||||
echo "Build finished successfully!"
|
|
||||||
|
echo "Build finished successfully!"
|
||||||
|
|||||||
@ -1,6 +1,26 @@
|
|||||||
|
"""
|
||||||
|
活动记录器 - 记录 AI 对图数据库的操作
|
||||||
|
使用 SQLite :memory: 供 WebUI 实时渲染,同时后台线程持久化到日志文件
|
||||||
|
日志每 6 小时自动压缩归档
|
||||||
|
"""
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import time
|
import time
|
||||||
|
import os
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import shutil
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict, Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
|
# 日志目录
|
||||||
|
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logs")
|
||||||
|
# 归档间隔(秒)
|
||||||
|
ARCHIVE_INTERVAL = 6 * 3600 # 6 小时
|
||||||
|
# 轮询间隔(秒)
|
||||||
|
POLL_INTERVAL = 10
|
||||||
|
|
||||||
|
|
||||||
class ActivityRecorder:
|
class ActivityRecorder:
|
||||||
@ -8,18 +28,46 @@ class ActivityRecorder:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.conn = sqlite3.connect(":memory:", check_same_thread=False)
|
self.conn = sqlite3.connect(":memory:", check_same_thread=False)
|
||||||
self.conn.execute("CREATE TABLE activities (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp REAL, action TEXT, tool_name TEXT, entity TEXT, detail TEXT)")
|
self.conn.execute(
|
||||||
|
"CREATE TABLE activities (id INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||||
|
"timestamp REAL, action TEXT, tool_name TEXT, entity TEXT, detail TEXT)"
|
||||||
|
)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def record(self, action: str, tool_name: str, entity: str, detail: str = "") -> None:
|
def record(self, action: str, tool_name: str, entity: str, detail: str = "") -> None:
|
||||||
self.conn.execute("INSERT INTO activities (timestamp, action, tool_name, entity, detail) VALUES (?, ?, ?, ?, ?)",
|
self.conn.execute(
|
||||||
(time.time(), action, tool_name, entity, detail))
|
"INSERT INTO activities (timestamp, action, tool_name, entity, detail) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(time.time(), action, tool_name, entity, detail)
|
||||||
|
)
|
||||||
self.conn.commit()
|
self.conn.commit()
|
||||||
|
|
||||||
def get_all(self) -> List[Dict]:
|
def get_all(self) -> List[Dict]:
|
||||||
cursor = self.conn.execute("SELECT id, timestamp, action, tool_name, entity, detail FROM activities ORDER BY id")
|
cursor = self.conn.execute(
|
||||||
|
"SELECT id, timestamp, action, tool_name, entity, detail FROM activities ORDER BY id"
|
||||||
|
)
|
||||||
rows = cursor.fetchall()
|
rows = cursor.fetchall()
|
||||||
return [{"id": r[0], "timestamp": r[1], "action": r[2], "tool_name": r[3], "entity": r[4], "detail": r[5]} for r in rows]
|
return [
|
||||||
|
{"id": r[0], "timestamp": r[1], "action": r[2],
|
||||||
|
"tool_name": r[3], "entity": r[4], "detail": r[5]}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_since_id(self, last_id: int) -> List[Dict]:
|
||||||
|
"""获取自 last_id 之后的新记录"""
|
||||||
|
cursor = self.conn.execute(
|
||||||
|
"SELECT id, timestamp, action, tool_name, entity, detail FROM activities WHERE id > ? ORDER BY id",
|
||||||
|
(last_id,)
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
return [
|
||||||
|
{"id": r[0], "timestamp": r[1], "action": r[2],
|
||||||
|
"tool_name": r[3], "entity": r[4], "detail": r[5]}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_max_id(self) -> int:
|
||||||
|
cursor = self.conn.execute("SELECT COALESCE(MAX(id), 0) FROM activities")
|
||||||
|
return cursor.fetchone()[0]
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
self.conn.execute("DELETE FROM activities")
|
self.conn.execute("DELETE FROM activities")
|
||||||
@ -31,11 +79,107 @@ class ActivityRecorder:
|
|||||||
return {r[0]: r[1] for r in rows}
|
return {r[0]: r[1] for r in rows}
|
||||||
|
|
||||||
|
|
||||||
|
# ── 日志文件管理 ──
|
||||||
|
|
||||||
|
def _current_log_path() -> str:
|
||||||
|
"""返回当前日志文件路径(按日期命名)"""
|
||||||
|
os.makedirs(LOG_DIR, exist_ok=True)
|
||||||
|
date_str = datetime.now().strftime("%Y%m%d")
|
||||||
|
return os.path.join(LOG_DIR, f"operations.{date_str}.log")
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_log(filepath: str) -> str:
|
||||||
|
"""压缩归档日志文件,返回归档文件路径"""
|
||||||
|
if not os.path.exists(filepath) or os.path.getsize(filepath) == 0:
|
||||||
|
return ""
|
||||||
|
archive_path = filepath + ".gz"
|
||||||
|
try:
|
||||||
|
with open(filepath, "rb") as f_in:
|
||||||
|
with gzip.open(archive_path, "wb") as f_out:
|
||||||
|
shutil.copyfileobj(f_in, f_out)
|
||||||
|
os.remove(filepath)
|
||||||
|
return archive_path
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
class LogPersister:
|
||||||
|
"""后台日志持久化线程 - 定期将内存记录写入日志文件并自动归档"""
|
||||||
|
|
||||||
|
def __init__(self, recorder: ActivityRecorder):
|
||||||
|
self.recorder = recorder
|
||||||
|
self._last_persisted_id = 0
|
||||||
|
self._last_archive_time = time.time()
|
||||||
|
self._running = True
|
||||||
|
self._thread = threading.Thread(target=self._run, daemon=True, name="log-persister")
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def _run(self):
|
||||||
|
"""主循环"""
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
self._persist_new()
|
||||||
|
self._check_archive()
|
||||||
|
except Exception:
|
||||||
|
pass # 不因日志异常影响主进程
|
||||||
|
time.sleep(POLL_INTERVAL)
|
||||||
|
|
||||||
|
def _persist_new(self):
|
||||||
|
"""增量写入新记录到日志文件"""
|
||||||
|
records = self.recorder.get_since_id(self._last_persisted_id)
|
||||||
|
if not records:
|
||||||
|
return
|
||||||
|
|
||||||
|
log_path = _current_log_path()
|
||||||
|
with open(log_path, "a", encoding="utf-8") as f:
|
||||||
|
for r in records:
|
||||||
|
line = json.dumps(r, ensure_ascii=False)
|
||||||
|
f.write(line + "\n")
|
||||||
|
|
||||||
|
# 更新水位
|
||||||
|
if records:
|
||||||
|
self._last_persisted_id = records[-1]["id"]
|
||||||
|
|
||||||
|
def _check_archive(self):
|
||||||
|
"""检查是否需要归档"""
|
||||||
|
elapsed = time.time() - self._last_archive_time
|
||||||
|
if elapsed < ARCHIVE_INTERVAL:
|
||||||
|
return
|
||||||
|
|
||||||
|
log_path = _current_log_path()
|
||||||
|
archived = _archive_log(log_path)
|
||||||
|
if archived:
|
||||||
|
dt = datetime.fromtimestamp(self._last_archive_time)
|
||||||
|
print(f"[日志归档] {dt.strftime('%H:%M')} → {os.path.basename(archived)} ({_fmt_size(archived)})")
|
||||||
|
self._last_archive_time = time.time()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt_size(path: str) -> str:
|
||||||
|
size = os.path.getsize(path)
|
||||||
|
for unit in ("B", "KB", "MB"):
|
||||||
|
if size < 1024:
|
||||||
|
return f"{size:.1f}{unit}"
|
||||||
|
size /= 1024
|
||||||
|
return f"{size:.1f}GB"
|
||||||
|
|
||||||
|
|
||||||
|
# ── 单例 ──
|
||||||
|
|
||||||
_recorder: Optional[ActivityRecorder] = None
|
_recorder: Optional[ActivityRecorder] = None
|
||||||
|
_persister: Optional[LogPersister] = None
|
||||||
|
|
||||||
|
|
||||||
def get_recorder() -> ActivityRecorder:
|
def get_recorder() -> ActivityRecorder:
|
||||||
global _recorder
|
"""获取全局 ActivityRecorder(首次调用时自动启动日志持久化线程)"""
|
||||||
|
global _recorder, _persister
|
||||||
if _recorder is None:
|
if _recorder is None:
|
||||||
_recorder = ActivityRecorder()
|
_recorder = ActivityRecorder()
|
||||||
|
_persister = LogPersister(_recorder)
|
||||||
return _recorder
|
return _recorder
|
||||||
|
|
||||||
|
|
||||||
|
def get_persister() -> Optional[LogPersister]:
|
||||||
|
return _persister
|
||||||
|
|||||||
@ -443,6 +443,16 @@ class EmbeddedGraphDB:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
criteria: 删除条件
|
criteria: 删除条件
|
||||||
|
支持:
|
||||||
|
- source: 源实体名(精确匹配)
|
||||||
|
- target: 目标实体名(精确匹配)
|
||||||
|
- relation: 关系类型
|
||||||
|
- subject_contains: 源实体名包含(模糊匹配)
|
||||||
|
- target_contains: 目标实体名包含(模糊匹配)
|
||||||
|
- relation_type: 关系类型(同 relation)
|
||||||
|
- source_type: 源实体类型过滤
|
||||||
|
- target_type: 目标实体类型过滤
|
||||||
|
- source_has_status: 源实体 mentions_count 状态(支持 type 字段)
|
||||||
mode: 删除模式 (soft/hard)
|
mode: 删除模式 (soft/hard)
|
||||||
new_relation: 替代关系
|
new_relation: 替代关系
|
||||||
|
|
||||||
@ -454,39 +464,94 @@ class EmbeddedGraphDB:
|
|||||||
# 构建查询条件
|
# 构建查询条件
|
||||||
conditions = []
|
conditions = []
|
||||||
params = []
|
params = []
|
||||||
|
joins = []
|
||||||
|
|
||||||
|
relation_type = criteria.get('relation') or criteria.get('relation_type', '')
|
||||||
|
|
||||||
if criteria.get('source'):
|
if criteria.get('source'):
|
||||||
cursor.execute("SELECT id FROM entities WHERE name = ?", (criteria['source'],))
|
cursor.execute("SELECT id FROM entities WHERE name = ?", (criteria['source'],))
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
if row:
|
if row:
|
||||||
conditions.append("source_id = ?")
|
conditions.append("r.source_id = ?")
|
||||||
params.append(row['id'])
|
params.append(row['id'])
|
||||||
|
|
||||||
if criteria.get('target'):
|
if criteria.get('target'):
|
||||||
cursor.execute("SELECT id FROM entities WHERE name = ?", (criteria['target'],))
|
cursor.execute("SELECT id FROM entities WHERE name = ?", (criteria['target'],))
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
if row:
|
if row:
|
||||||
conditions.append("target_id = ?")
|
conditions.append("r.target_id = ?")
|
||||||
params.append(row['id'])
|
params.append(row['id'])
|
||||||
|
|
||||||
if criteria.get('relation'):
|
if relation_type:
|
||||||
conditions.append("relation_type = ?")
|
conditions.append("r.relation_type = ?")
|
||||||
params.append(criteria['relation'])
|
params.append(relation_type)
|
||||||
|
|
||||||
|
# 通过子查询支持实体属性过滤
|
||||||
|
if criteria.get('subject_contains'):
|
||||||
|
cursor.execute("SELECT id FROM entities WHERE name LIKE ?",
|
||||||
|
(f'%{criteria["subject_contains"]}%',))
|
||||||
|
ids = [row['id'] for row in cursor.fetchall()]
|
||||||
|
if ids:
|
||||||
|
placeholders = ','.join(['?'] * len(ids))
|
||||||
|
conditions.append(f"r.source_id IN ({placeholders})")
|
||||||
|
params.extend(ids)
|
||||||
|
|
||||||
|
if criteria.get('target_contains'):
|
||||||
|
cursor.execute("SELECT id FROM entities WHERE name LIKE ?",
|
||||||
|
(f'%{criteria["target_contains"]}%',))
|
||||||
|
ids = [row['id'] for row in cursor.fetchall()]
|
||||||
|
if ids:
|
||||||
|
placeholders = ','.join(['?'] * len(ids))
|
||||||
|
conditions.append(f"r.target_id IN ({placeholders})")
|
||||||
|
params.extend(ids)
|
||||||
|
|
||||||
|
# 源实体类型过滤
|
||||||
|
if criteria.get('source_type'):
|
||||||
|
cursor.execute("SELECT id FROM entities WHERE type = ?",
|
||||||
|
(criteria['source_type'],))
|
||||||
|
ids = [row['id'] for row in cursor.fetchall()]
|
||||||
|
if ids:
|
||||||
|
placeholders = ','.join(['?'] * len(ids))
|
||||||
|
conditions.append(f"r.source_id IN ({placeholders})")
|
||||||
|
params.extend(ids)
|
||||||
|
|
||||||
|
# 目标实体类型过滤
|
||||||
|
if criteria.get('target_type'):
|
||||||
|
cursor.execute("SELECT id FROM entities WHERE type = ?",
|
||||||
|
(criteria['target_type'],))
|
||||||
|
ids = [row['id'] for row in cursor.fetchall()]
|
||||||
|
if ids:
|
||||||
|
placeholders = ','.join(['?'] * len(ids))
|
||||||
|
conditions.append(f"r.target_id IN ({placeholders})")
|
||||||
|
params.extend(ids)
|
||||||
|
|
||||||
|
# 源实体状态过滤(检查 entity name 是否以特定后缀结尾等)
|
||||||
|
if criteria.get('source_has_status'):
|
||||||
|
status = criteria['source_has_status']
|
||||||
|
# 匹配 entities 表中 type 字段包含状态信息的节点
|
||||||
|
cursor.execute("SELECT id FROM entities WHERE type LIKE ?",
|
||||||
|
(f'%{status}%',))
|
||||||
|
ids = [row['id'] for row in cursor.fetchall()]
|
||||||
|
if ids:
|
||||||
|
placeholders = ','.join(['?'] * len(ids))
|
||||||
|
conditions.append(f"r.source_id IN ({placeholders})")
|
||||||
|
params.extend(ids)
|
||||||
|
|
||||||
if not conditions:
|
if not conditions:
|
||||||
return {"deleted": 0, "message": "无删除条件"}
|
return {"deleted": 0, "message": "无删除条件"}
|
||||||
|
|
||||||
|
conditions.append("r.status = 'active'")
|
||||||
where_clause = " AND ".join(conditions)
|
where_clause = " AND ".join(conditions)
|
||||||
|
|
||||||
if mode == "soft":
|
if mode == "soft":
|
||||||
cursor.execute(f"""
|
cursor.execute(f"""
|
||||||
UPDATE relations
|
UPDATE relations r
|
||||||
SET status = 'deleted', updated_at = CURRENT_TIMESTAMP
|
SET status = 'deleted', updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE {where_clause} AND status = 'active'
|
WHERE {where_clause}
|
||||||
""", params)
|
""", params)
|
||||||
else:
|
else:
|
||||||
cursor.execute(f"""
|
cursor.execute(f"""
|
||||||
DELETE FROM relations
|
DELETE FROM relations r
|
||||||
WHERE {where_clause}
|
WHERE {where_clause}
|
||||||
""", params)
|
""", params)
|
||||||
|
|
||||||
|
|||||||
@ -173,6 +173,9 @@ class Neo4jGraph:
|
|||||||
subject_pattern = criteria.get("subject_contains", "")
|
subject_pattern = criteria.get("subject_contains", "")
|
||||||
rel_type = criteria.get("relation_type", "")
|
rel_type = criteria.get("relation_type", "")
|
||||||
target_pattern = criteria.get("target_contains", "")
|
target_pattern = criteria.get("target_contains", "")
|
||||||
|
source_type = criteria.get("source_type", "")
|
||||||
|
target_type = criteria.get("target_type", "")
|
||||||
|
source_status = criteria.get("source_has_status", "")
|
||||||
session_id = criteria.get("session_id", CURRENT_SESSION_ID)
|
session_id = criteria.get("session_id", CURRENT_SESSION_ID)
|
||||||
|
|
||||||
cond_parts = ["r.status = 'active'"]
|
cond_parts = ["r.status = 'active'"]
|
||||||
@ -187,6 +190,15 @@ class Neo4jGraph:
|
|||||||
if rel_type:
|
if rel_type:
|
||||||
cond_parts.append("r.type = $rel_type")
|
cond_parts.append("r.type = $rel_type")
|
||||||
params["rel_type"] = rel_type
|
params["rel_type"] = rel_type
|
||||||
|
if source_type:
|
||||||
|
cond_parts.append("s.entity_type = $source_type")
|
||||||
|
params["source_type"] = source_type
|
||||||
|
if target_type:
|
||||||
|
cond_parts.append("t.entity_type = $target_type")
|
||||||
|
params["target_type"] = target_type
|
||||||
|
if source_status:
|
||||||
|
cond_parts.append("s.status = $source_status")
|
||||||
|
params["source_status"] = source_status
|
||||||
|
|
||||||
where_clause = " AND ".join(cond_parts)
|
where_clause = " AND ".join(cond_parts)
|
||||||
|
|
||||||
@ -208,7 +220,7 @@ class Neo4jGraph:
|
|||||||
return {"deleted_count": count, "mode": "supersede"}
|
return {"deleted_count": count, "mode": "supersede"}
|
||||||
else:
|
else:
|
||||||
result = session.run(f"""
|
result = session.run(f"""
|
||||||
MATCH ()-[r:RELATES]->()
|
MATCH (s:Entity)-[r:RELATES]->(t:Entity)
|
||||||
WHERE {where_clause}
|
WHERE {where_clause}
|
||||||
SET r.status = 'deleted', r.updated_at = datetime()
|
SET r.status = 'deleted', r.updated_at = datetime()
|
||||||
RETURN count(r) as deleted
|
RETURN count(r) as deleted
|
||||||
|
|||||||
@ -146,6 +146,12 @@ MEMORY_TOOLS = [
|
|||||||
4. 删除旧记忆:
|
4. 删除旧记忆:
|
||||||
{"criteria": {"time_before": "2024-01-01"}, "mode": "soft"}
|
{"criteria": {"time_before": "2024-01-01"}, "mode": "soft"}
|
||||||
|
|
||||||
|
5. 删除残留在已归档任务上的状态关系:
|
||||||
|
{"criteria": {"relation_type": "HAS_STATE", "source_type": "TaskNode", "source_has_status": "archived"}, "mode": "soft"}
|
||||||
|
|
||||||
|
6. 删除特定类型的节点关系:
|
||||||
|
{"criteria": {"relation_type": "某种关系", "target_type": "某种类型"}, "mode": "soft"}
|
||||||
|
|
||||||
【重要】删除原则:
|
【重要】删除原则:
|
||||||
- 优先使用 supersede 模式修正错误
|
- 优先使用 supersede 模式修正错误
|
||||||
- 软删除不会物理删除数据
|
- 软删除不会物理删除数据
|
||||||
@ -159,6 +165,9 @@ MEMORY_TOOLS = [
|
|||||||
"subject_contains": {"type": "string"},
|
"subject_contains": {"type": "string"},
|
||||||
"relation_type": {"type": "string"},
|
"relation_type": {"type": "string"},
|
||||||
"target_contains": {"type": "string"},
|
"target_contains": {"type": "string"},
|
||||||
|
"source_type": {"type": "string", "description": "源实体类型过滤(如 TaskNode)"},
|
||||||
|
"target_type": {"type": "string", "description": "目标实体类型过滤"},
|
||||||
|
"source_has_status": {"type": "string", "description": "源实体状态过滤(如 archived)"},
|
||||||
"time_before": {"type": "string"},
|
"time_before": {"type": "string"},
|
||||||
"session_id": {"type": "string"}
|
"session_id": {"type": "string"}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -913,7 +913,11 @@ function init() {
|
|||||||
positions[node.id] = { x, y, z };
|
positions[node.id] = { x, y, z };
|
||||||
});
|
});
|
||||||
|
|
||||||
// 简单力导向模拟(迭代几次)
|
// 简单力导向模拟(迭代几次)— 使用度数加权距离
|
||||||
|
function getIdealDist(id) {
|
||||||
|
return 5 + ((nodeDegrees[id] || 0) * 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
for (let iter = 0; iter < 30; iter++) {
|
for (let iter = 0; iter < 30; iter++) {
|
||||||
// 斥力:节点之间互相排斥
|
// 斥力:节点之间互相排斥
|
||||||
Object.keys(positions).forEach(id1 => {
|
Object.keys(positions).forEach(id1 => {
|
||||||
@ -926,8 +930,14 @@ function init() {
|
|||||||
const dz = pos1.z - pos2.z;
|
const dz = pos1.z - pos2.z;
|
||||||
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
|
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
|
||||||
|
|
||||||
if (dist < 10) {
|
// 动态阈值:度数越高理想距离越大,取两节点较大值
|
||||||
const force = 0.04 / Math.max(dist, 0.5);
|
const idealDist = getIdealDist(id1) + getIdealDist(id2);
|
||||||
|
const repulsionThreshold = idealDist * 1.5;
|
||||||
|
|
||||||
|
if (dist < repulsionThreshold) {
|
||||||
|
// 斥力强度按度数加权
|
||||||
|
const degreeWeight = ((nodeDegrees[id1] || 0) + (nodeDegrees[id2] || 0)) * 0.5 + 1;
|
||||||
|
const force = (0.04 * degreeWeight) / Math.max(dist, 0.5);
|
||||||
const fx = (dx / dist) * force;
|
const fx = (dx / dist) * force;
|
||||||
const fy = (dy / dist) * force;
|
const fy = (dy / dist) * force;
|
||||||
const fz = (dz / dist) * force;
|
const fz = (dz / dist) * force;
|
||||||
@ -953,7 +963,11 @@ function init() {
|
|||||||
const dz = pos2.z - pos1.z;
|
const dz = pos2.z - pos1.z;
|
||||||
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
|
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
|
||||||
|
|
||||||
if (dist > 15) {
|
// 动态引力阈值:以两节点平均理想距离为基准
|
||||||
|
const idealDist = (getIdealDist(edge.source) + getIdealDist(edge.target)) * 0.8;
|
||||||
|
const attractionThreshold = idealDist * 1.5;
|
||||||
|
|
||||||
|
if (dist > attractionThreshold) {
|
||||||
const force = 0.05;
|
const force = 0.05;
|
||||||
const fx = (dx / dist) * force;
|
const fx = (dx / dist) * force;
|
||||||
const fy = (dy / dist) * force;
|
const fy = (dy / dist) * force;
|
||||||
@ -1236,6 +1250,18 @@ function smoothReposition() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 重新计算度数(从当前节点 + 边)
|
||||||
|
const smoothDegrees = {};
|
||||||
|
nodeMeshes.forEach(m => { smoothDegrees[m.userData.nodeId] = 0; });
|
||||||
|
edges.forEach(e => {
|
||||||
|
smoothDegrees[e.source] = (smoothDegrees[e.source] || 0) + 1;
|
||||||
|
smoothDegrees[e.target] = (smoothDegrees[e.target] || 0) + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
function getSmoothIdealDist(id) {
|
||||||
|
return 5 + ((smoothDegrees[id] || 0) * 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
// 用力导向模拟迭代 30 次平滑
|
// 用力导向模拟迭代 30 次平滑
|
||||||
for (let iter = 0; iter < 30; iter++) {
|
for (let iter = 0; iter < 30; iter++) {
|
||||||
// 斥力
|
// 斥力
|
||||||
@ -1248,8 +1274,11 @@ function smoothReposition() {
|
|||||||
const dy = a.y - b.y;
|
const dy = a.y - b.y;
|
||||||
const dz = a.z - b.z;
|
const dz = a.z - b.z;
|
||||||
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
|
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
|
||||||
if (dist < 12) {
|
const idealDist = getSmoothIdealDist(ids[i]) + getSmoothIdealDist(ids[j]);
|
||||||
const force = 0.08 / Math.max(dist, 0.5);
|
const repulsionThreshold = idealDist * 1.5;
|
||||||
|
if (dist < repulsionThreshold) {
|
||||||
|
const degreeWeight = ((smoothDegrees[ids[i]] || 0) + (smoothDegrees[ids[j]] || 0)) * 0.5 + 1;
|
||||||
|
const force = (0.08 * degreeWeight) / Math.max(dist, 0.5);
|
||||||
a.x += (dx/dist) * force;
|
a.x += (dx/dist) * force;
|
||||||
a.y += (dy/dist) * force;
|
a.y += (dy/dist) * force;
|
||||||
a.z += (dz/dist) * force;
|
a.z += (dz/dist) * force;
|
||||||
@ -1268,7 +1297,9 @@ function smoothReposition() {
|
|||||||
const dy = p2.y - p1.y;
|
const dy = p2.y - p1.y;
|
||||||
const dz = p2.z - p1.z;
|
const dz = p2.z - p1.z;
|
||||||
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
|
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
|
||||||
if (dist > 8) {
|
const idealDist = (getSmoothIdealDist(e.source) + getSmoothIdealDist(e.target)) * 0.8;
|
||||||
|
const attractionThreshold = idealDist * 1.5;
|
||||||
|
if (dist > attractionThreshold) {
|
||||||
const force = 0.03;
|
const force = 0.03;
|
||||||
p1.x += (dx/dist) * force;
|
p1.x += (dx/dist) * force;
|
||||||
p1.y += (dy/dist) * force;
|
p1.y += (dy/dist) * force;
|
||||||
|
|||||||
Reference in New Issue
Block a user