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:
root
2026-04-30 08:36:02 +08:00
parent 6749811e49
commit c046e6ff6a
7 changed files with 404 additions and 53 deletions

View File

@ -1,5 +1,5 @@
#!/bin/bash
set -e
set -euo pipefail
echo "===== Building TrulyMEM AppImage ====="
@ -11,39 +11,65 @@ echo "Project root: $PROJECT_ROOT"
APP_NAME="TrulyMEM"
APP_DIR="$PROJECT_ROOT/build/appimage-build"
echo "Cleaning..."
rm -rf "$APP_DIR" dist/ build/trulymem/ 2>/dev/null || true
# ── Step 1: 复用 build_linux.sh 完成 PyInstaller 构建 ──
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)
echo "Step 1: PyInstaller build..."
python3 -m PyInstaller --clean build/trulymem.spec --noconfirm
# ── Step 2: 检查 dist/ 中是否有二进制 ──
echo ""
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
echo "Step 2: Preparing AppDir..."
# ── Step 3: 组织 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/share/applications"
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/"
# 图标处理
ICON_SOURCE=""
if [ -f "pic/image.png" ]; then
cp "pic/image.png" "$APP_DIR/usr/share/icons/hicolor/256x256/apps/${APP_NAME}.png"
cp "pic/image.png" "$APP_DIR/${APP_NAME}.png"
else
echo -n "" > "$APP_DIR/${APP_NAME}.png"
ICON_SOURCE="pic/image.png"
elif [ -f "pic/TrulyMEM.ico" ]; then
echo "⚠️ No pic/image.png found; .ico will not display as AppImage icon"
echo " To generate a PNG: convert pic/TrulyMEM.ico pic/image.png"
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
[Desktop Entry]
Name=${APP_NAME}
Comment=True Human Memory - TUI & Web Mode
Exec=${APP_NAME}
Icon=${APP_NAME}
Type=Application
Categories=Utility;
Categories=Utility;Office;
Terminal=true
StartupNotify=true
EOF
cp "$APP_DIR/${APP_NAME}.desktop" "$APP_DIR/usr/share/applications/"
# AppRun 入口
cat > "$APP_DIR/AppRun" <<'APPRUN'
#!/bin/bash
HERE="$(dirname "$(readlink -f "$0")")"
@ -51,15 +77,24 @@ exec "$HERE/usr/bin/TrulyMEM" "$@"
APPRUN
chmod +x "$APP_DIR/AppRun"
# Step 3: Build AppImage (requires appimagetool)
echo "Step 3: Building AppImage..."
# ── Step 4: 打包 AppImage ──
echo ""
echo "Step 4: Building AppImage..."
if command -v appimagetool &> /dev/null; then
appimagetool "$APP_DIR" "dist/${APP_NAME}.AppImage"
echo "AppImage: dist/${APP_NAME}.AppImage"
ls -la "dist/${APP_NAME}.AppImage"
ARCH="${ARCH:-$(uname -m)}" appimagetool "$APP_DIR" "dist/${APP_NAME}.AppImage"
echo "AppImage: dist/${APP_NAME}.AppImage"
ls -lh "dist/${APP_NAME}.AppImage"
else
echo "appimagetool not found. AppDir ready at: $APP_DIR"
echo "Install appimagetool and run: appimagetool '$APP_DIR' 'dist/${APP_NAME}.AppImage'"
echo "⚠️ appimagetool not found. AppDir ready at: $APP_DIR"
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
echo "===== AppImage Build Complete ====="
echo ""
echo "===== AppImage Build Complete ====="

View File

@ -1,5 +1,5 @@
#!/bin/bash
set -e
set -euo pipefail
echo "===== Building TrulyMEM for Linux ====="
@ -8,47 +8,102 @@ PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_ROOT"
echo "Project root: $PROJECT_ROOT"
# ── 前置检查 ──
if ! command -v python3 &> /dev/null; then
echo "Error: python3 not found"; exit 1
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 隔离构建,失败则用系统环境 ──
USE_VENV=false
if python3 -m venv --help &>/dev/null; then
if [ "$VENV_AVAILABLE" = true ]; then
VENV_DIR="$PROJECT_ROOT/.venv_build"
echo "Creating virtual environment..."
if python3 -m venv "$VENV_DIR" 2>/dev/null; then
source "$VENV_DIR/bin/activate"
USE_VENV=true
echo "Using virtual environment"
echo "Using virtual environment: $VENV_DIR"
pip install --upgrade pip -q
pip install -r requirements.txt -q
else
echo "Warning: venv creation failed, falling back to system Python"
echo "⚠️ venv creation failed, falling back to system Python"
fi
else
echo "Warning: python3-venv not available, falling back to system Python"
fi
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 -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
# ── 清理旧构建 ──
echo ""
echo "Cleaning previous builds..."
rm -rf dist/ build/trulymem/ 2>/dev/null || true
# ── PyInstaller 构建 ──
echo ""
echo "================================"
echo "Building TrulyMEM (TUI + Web embedded)"
echo "================================"
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 "===== Build Complete ====="
echo "Binary: dist/TrulyMEM"
ls -la dist/
echo "Binary: dist/TrulyMEM"
echo "Desktop: dist/TrulyMEM.desktop"
echo "------------------------------"
ls -lh dist/ 2>/dev/null || ls -la dist/
echo "================================"
# ── 清理 venv ──
if [ "$USE_VENV" = true ]; then
deactivate 2>/dev/null || true
rm -rf "$VENV_DIR"
echo "Virtual environment cleaned up."
fi
echo "Build finished successfully!"
echo "Build finished successfully!"

View File

@ -1,6 +1,26 @@
"""
活动记录器 - 记录 AI 对图数据库的操作
使用 SQLite :memory: 供 WebUI 实时渲染,同时后台线程持久化到日志文件
日志每 6 小时自动压缩归档
"""
import sqlite3
import time
import os
import gzip
import json
import threading
import shutil
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:
@ -8,18 +28,46 @@ class ActivityRecorder:
def __init__(self):
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()
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 (?, ?, ?, ?, ?)",
(time.time(), action, tool_name, entity, detail))
self.conn.execute(
"INSERT INTO activities (timestamp, action, tool_name, entity, detail) VALUES (?, ?, ?, ?, ?)",
(time.time(), action, tool_name, entity, detail)
)
self.conn.commit()
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()
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:
self.conn.execute("DELETE FROM activities")
@ -31,11 +79,107 @@ class ActivityRecorder:
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
_persister: Optional[LogPersister] = None
def get_recorder() -> ActivityRecorder:
global _recorder
"""获取全局 ActivityRecorder首次调用时自动启动日志持久化线程"""
global _recorder, _persister
if _recorder is None:
_recorder = ActivityRecorder()
_persister = LogPersister(_recorder)
return _recorder
def get_persister() -> Optional[LogPersister]:
return _persister

View File

@ -443,6 +443,16 @@ class EmbeddedGraphDB:
Args:
criteria: 删除条件
支持:
- source: 源实体名(精确匹配)
- target: 目标实体名(精确匹配)
- relation: 关系类型
- subject_contains: 源实体名包含(模糊匹配)
- target_contains: 目标实体名包含(模糊匹配)
- relation_type: 关系类型(同 relation
- source_type: 源实体类型过滤
- target_type: 目标实体类型过滤
- source_has_status: 源实体 mentions_count 状态(支持 type 字段)
mode: 删除模式 (soft/hard)
new_relation: 替代关系
@ -454,39 +464,94 @@ class EmbeddedGraphDB:
# 构建查询条件
conditions = []
params = []
joins = []
relation_type = criteria.get('relation') or criteria.get('relation_type', '')
if criteria.get('source'):
cursor.execute("SELECT id FROM entities WHERE name = ?", (criteria['source'],))
row = cursor.fetchone()
if row:
conditions.append("source_id = ?")
conditions.append("r.source_id = ?")
params.append(row['id'])
if criteria.get('target'):
cursor.execute("SELECT id FROM entities WHERE name = ?", (criteria['target'],))
row = cursor.fetchone()
if row:
conditions.append("target_id = ?")
conditions.append("r.target_id = ?")
params.append(row['id'])
if criteria.get('relation'):
conditions.append("relation_type = ?")
params.append(criteria['relation'])
if relation_type:
conditions.append("r.relation_type = ?")
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:
return {"deleted": 0, "message": "无删除条件"}
conditions.append("r.status = 'active'")
where_clause = " AND ".join(conditions)
if mode == "soft":
cursor.execute(f"""
UPDATE relations
UPDATE relations r
SET status = 'deleted', updated_at = CURRENT_TIMESTAMP
WHERE {where_clause} AND status = 'active'
WHERE {where_clause}
""", params)
else:
cursor.execute(f"""
DELETE FROM relations
DELETE FROM relations r
WHERE {where_clause}
""", params)

View File

@ -173,6 +173,9 @@ class Neo4jGraph:
subject_pattern = criteria.get("subject_contains", "")
rel_type = criteria.get("relation_type", "")
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)
cond_parts = ["r.status = 'active'"]
@ -187,6 +190,15 @@ class Neo4jGraph:
if rel_type:
cond_parts.append("r.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)
@ -208,7 +220,7 @@ class Neo4jGraph:
return {"deleted_count": count, "mode": "supersede"}
else:
result = session.run(f"""
MATCH ()-[r:RELATES]->()
MATCH (s:Entity)-[r:RELATES]->(t:Entity)
WHERE {where_clause}
SET r.status = 'deleted', r.updated_at = datetime()
RETURN count(r) as deleted

View File

@ -146,6 +146,12 @@ MEMORY_TOOLS = [
4. 删除旧记忆:
{"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 模式修正错误
- 软删除不会物理删除数据
@ -159,6 +165,9 @@ MEMORY_TOOLS = [
"subject_contains": {"type": "string"},
"relation_type": {"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"},
"session_id": {"type": "string"}
},

View File

@ -913,7 +913,11 @@ function init() {
positions[node.id] = { x, y, z };
});
// 简单力导向模拟(迭代几次)
// 简单力导向模拟(迭代几次)— 使用度数加权距离
function getIdealDist(id) {
return 5 + ((nodeDegrees[id] || 0) * 0.8);
}
for (let iter = 0; iter < 30; iter++) {
// 斥力:节点之间互相排斥
Object.keys(positions).forEach(id1 => {
@ -926,8 +930,14 @@ function init() {
const dz = pos1.z - pos2.z;
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 fy = (dy / dist) * force;
const fz = (dz / dist) * force;
@ -953,7 +963,11 @@ function init() {
const dz = pos2.z - pos1.z;
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 fx = (dx / 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 次平滑
for (let iter = 0; iter < 30; iter++) {
// 斥力
@ -1248,8 +1274,11 @@ function smoothReposition() {
const dy = a.y - b.y;
const dz = a.z - b.z;
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
if (dist < 12) {
const force = 0.08 / Math.max(dist, 0.5);
const idealDist = getSmoothIdealDist(ids[i]) + getSmoothIdealDist(ids[j]);
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.y += (dy/dist) * force;
a.z += (dz/dist) * force;
@ -1268,7 +1297,9 @@ function smoothReposition() {
const dy = p2.y - p1.y;
const dz = p2.z - p1.z;
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;
p1.x += (dx/dist) * force;
p1.y += (dy/dist) * force;