From c046e6ff6a18d0df79d95326bf6cf157674804a3 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Apr 2026 08:36:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=97=A5=E5=BF=97=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96(6h=E5=BD=92=E6=A1=A3)=20+=20purge=E5=A2=9E=E5=BC=BA(s?= =?UTF-8?q?ource=5Ftype/source=5Fhas=5Fstatus=E8=BF=87=E6=BB=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ActivityRecorder 新增 LogPersister 后台线程,每10s增量写入logs/operations.*.log - 日志每6小时自动gzip压缩归档 - memory_purge 新增 source_type / target_type / source_has_status 过滤条件 - 支持精准清理已归档任务的残留 HAS_STATE 关系 --- build/build_appimage.sh | 77 +++++++++++++----- build/build_linux.sh | 75 +++++++++++++++--- core/activity_recorder.py | 156 +++++++++++++++++++++++++++++++++++-- core/embedded_db.py | 81 +++++++++++++++++-- core/graph_client.py | 14 +++- core/tools/memory_tools.py | 9 +++ ui/static/graph.html | 45 +++++++++-- 7 files changed, 404 insertions(+), 53 deletions(-) diff --git a/build/build_appimage.sh b/build/build_appimage.sh index 7414fd8..052ece3 100755 --- a/build/build_appimage.sh +++ b/build/build_appimage.sh @@ -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" < "$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 =====" \ No newline at end of file +echo "" +echo "===== AppImage Build Complete =====" diff --git a/build/build_linux.sh b/build/build_linux.sh index ac2a71e..13521f3 100755 --- a/build/build_linux.sh +++ b/build/build_linux.sh @@ -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" </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!" \ No newline at end of file + +echo "Build finished successfully!" diff --git a/core/activity_recorder.py b/core/activity_recorder.py index bfec8b5..c185b64 100644 --- a/core/activity_recorder.py +++ b/core/activity_recorder.py @@ -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 diff --git a/core/embedded_db.py b/core/embedded_db.py index a6fea78..669a0e2 100644 --- a/core/embedded_db.py +++ b/core/embedded_db.py @@ -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) diff --git a/core/graph_client.py b/core/graph_client.py index 8f784a5..1df0b71 100644 --- a/core/graph_client.py +++ b/core/graph_client.py @@ -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 diff --git a/core/tools/memory_tools.py b/core/tools/memory_tools.py index a902022..fb7da6b 100644 --- a/core/tools/memory_tools.py +++ b/core/tools/memory_tools.py @@ -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"} }, diff --git a/ui/static/graph.html b/ui/static/graph.html index 478c0cf..4caac28 100644 --- a/ui/static/graph.html +++ b/ui/static/graph.html @@ -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;