diff --git a/.gitignore b/.gitignore index 4064d6b..96d4a48 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,11 @@ jimeng*.png # Web config (contains passwords, secret keys) web_config.json + +# Node.js +node_modules/ + +# Test artifacts +/session_*/ +task_archive/ +ts/ diff --git a/build/build_appimage.sh b/build/build_appimage.sh index cef4bed..f3e28d4 100755 --- a/build/build_appimage.sh +++ b/build/build_appimage.sh @@ -12,7 +12,9 @@ APPDIR="$PROJECT_ROOT/TrulyMEM.AppDir" rm -rf "$APPDIR" mkdir -p "$APPDIR/usr/bin" mkdir -p "$APPDIR/usr/share/trulymem" -echo "===== Step 1: Build binary with PyInstaller =====" +mkdir -p "$APPDIR/usr/share/trulymem-web" + +echo "===== Step 1: Build binaries with PyInstaller =====" VENV_DIR="$PROJECT_ROOT/.venv_appimage" rm -rf "$VENV_DIR" python3 -m venv "$VENV_DIR" @@ -21,16 +23,33 @@ pip install --upgrade pip pip install -r requirements.txt pip install pyinstaller rm -rf "$PROJECT_ROOT/build/pyinstaller_build" "$PROJECT_ROOT/dist" -echo "Running PyInstaller..." + +CORE_HIDDEN=( + --hidden-import core + --hidden-import core.embedded_db + --hidden-import core.graph_client + --hidden-import core.tool_executor + --hidden-import core.tool_limiter + --hidden-import core.tools + --hidden-import core.tools.memory_tools + --hidden-import core.prompts + --hidden-import core.prompts.prompt_manager + --hidden-import core.server + --hidden-import core.client + --hidden-import core.migrate + --hidden-import core.activity_recorder +) + +echo "Running PyInstaller for TUI..." pyinstaller trulymem_entry.py \ - --clean \ - --onefile \ - --console \ - --name TrulyMEM \ + --clean --onefile --console --name TrulyMEM \ --distpath "$PROJECT_ROOT/dist" \ - --workpath "$PROJECT_ROOT/build/pyinstaller_build" \ + --workpath "$PROJECT_ROOT/build/pyinstaller_build/tui" \ --add-data "ui/styles:ui/styles" \ --add-data "core/prompts/templates:core/prompts/templates" \ + --add-data "static:static" \ + --add-data "templates:templates" \ + --add-data "web_api.py:." \ --hidden-import textual \ --hidden-import textual.app \ --hidden-import textual.widgets \ @@ -39,17 +58,10 @@ pyinstaller trulymem_entry.py \ --hidden-import openai._client \ --hidden-import neo4j \ --hidden-import sqlite3 \ - --hidden-import core \ - --hidden-import core.embedded_db \ - --hidden-import core.graph_client \ - --hidden-import core.tool_executor \ - --hidden-import core.tool_limiter \ - --hidden-import core.tools \ - --hidden-import core.tools.memory_tools \ - --hidden-import core.prompts \ - --hidden-import core.prompts.prompt_manager \ + "${CORE_HIDDEN[@]}" \ --hidden-import ui \ --hidden-import ui.app \ + --hidden-import ui.login_screen \ --hidden-import ui.models \ --hidden-import ui.models.message \ --hidden-import ui.models.config \ @@ -59,10 +71,28 @@ pyinstaller trulymem_entry.py \ --hidden-import ui.services \ --hidden-import ui.services.config_manager \ --hidden-import ui.services.config_service \ + --hidden-import web_api \ + --hidden-import flask \ + --hidden-import flask_cors \ --collect-all textual \ --noconfirm + +echo "Running PyInstaller for Web..." +pyinstaller web_api.py \ + --clean --onefile --console --name trulymem-web \ + --distpath "$PROJECT_ROOT/dist" \ + --workpath "$PROJECT_ROOT/build/pyinstaller_build/web" \ + --add-data "templates:templates" \ + --add-data "static:static" \ + --hidden-import flask \ + --hidden-import flask_cors \ + "${CORE_HIDDEN[@]}" \ + --noconfirm + cp "$PROJECT_ROOT/dist/TrulyMEM" "$APPDIR/usr/bin/" +cp "$PROJECT_ROOT/dist/trulymem-web" "$APPDIR/usr/bin/" cp "$PROJECT_ROOT/trulymem_entry.py" "$APPDIR/usr/share/trulymem/" + echo "===== Step 2: Create AppImage structure =====" cat > "$APPDIR/AppRun" << 'EOF' #!/bin/bash @@ -73,6 +103,7 @@ export PATH="$APPDIR/usr/bin:$PATH" exec "$APPDIR/usr/bin/TrulyMEM" "$@" EOF chmod +x "$APPDIR/AppRun" + cat > "$APPDIR/trulymem.desktop" << 'EOF' [Desktop Entry] Name=TrulyMEM @@ -83,7 +114,8 @@ Terminal=true Type=Application Categories=Utility;X-AI; EOF -# Copy icon file (try multiple possible locations) + +# Copy icon if [ -f "$PROJECT_ROOT/pic/TrulyMEM.png" ]; then cp "$PROJECT_ROOT/pic/TrulyMEM.png" "$APPDIR/trulymem.png" elif [ -f "$PROJECT_ROOT/pic/TrulyMEM.iconset/icon_256x256.png" ]; then @@ -91,13 +123,12 @@ elif [ -f "$PROJECT_ROOT/pic/TrulyMEM.iconset/icon_256x256.png" ]; then elif [ -f "$PROJECT_ROOT/pic/TrulyMEM.iconset/icon_128x128.png" ]; then cp "$PROJECT_ROOT/pic/TrulyMEM.iconset/icon_128x128.png" "$APPDIR/trulymem.png" else - echo "Warning: No icon file found, creating placeholder" - # Create a simple placeholder icon - convert -size 256x256 xc:blue "$APPDIR/trulymem.png" 2>/dev/null || \ - echo "Note: Install ImageMagick to generate placeholder icon" + echo "Warning: No icon file found" fi + APPIMAGE="$PROJECT_ROOT/TrulyMEM.AppImage" rm -f "$APPIMAGE" + echo "===== Step 3: Package as AppImage =====" cd /tmp if ! command -v appimagetool &> /dev/null; then @@ -108,30 +139,23 @@ if ! command -v appimagetool &> /dev/null; then fi cd "$PROJECT_ROOT" if [ -x /tmp/appimagetool ]; then - /tmp/appimagetool "$APPDIR" "$APPIMAGE" || { - echo "appimagetool failed, keeping AppDir for manual packaging" - } + /tmp/appimagetool "$APPDIR" "$APPIMAGE" || echo "appimagetool failed, keeping AppDir" elif command -v appimagetool &> /dev/null; then appimagetool "$APPDIR" "$APPIMAGE" else - echo "Warning: appimagetool not available" - echo "AppDir created at: $APPDIR" - echo "You can manually run: appimagetool $APPDIR $APPIMAGE" + echo "Warning: appimagetool not available, AppDir at: $APPDIR" fi + echo "===== Build Complete =====" [ -f "$APPIMAGE" ] && echo "AppImage: $APPIMAGE" && ls -la "$APPIMAGE" -[ -d "$APPDIR" ] && echo "AppDir: $APPDIR (can be packaged manually with appimagetool)" -echo "===== Cleaning up build artifacts =====" +[ -d "$APPDIR" ] && echo "AppDir: $APPDIR" + +echo "===== Cleanup =====" deactivate rm -rf "$VENV_DIR" rm -rf "$PROJECT_ROOT/build/pyinstaller_build" -rm -rf "$PROJECT_ROOT/build/pyinstaller_build" rm -f /tmp/appimagetool -# Only remove AppDir if AppImage was successfully created if [ -f "$APPIMAGE" ]; then - echo "Removing AppDir since AppImage was created successfully" rm -rf "$APPDIR" -else - echo "Keeping AppDir for manual packaging" fi -echo "Done!" \ No newline at end of file +echo "Done!" diff --git a/build/build_linux.sh b/build/build_linux.sh index 9992439..01e6756 100755 --- a/build/build_linux.sh +++ b/build/build_linux.sh @@ -6,40 +6,49 @@ echo "===== Building TrulyMEM for Linux =====" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 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 + echo "Error: python3 not found"; exit 1 fi -# 创建并激活虚拟环境 VENV_DIR="$PROJECT_ROOT/.venv_build" - echo "Creating virtual environment: $VENV_DIR" python3 -m venv "$VENV_DIR" - -echo "Activating virtual environment..." source "$VENV_DIR/bin/activate" - -echo "Upgrading pip in virtual environment..." pip install --upgrade pip - -echo "Installing dependencies in virtual environment..." pip install -r requirements.txt echo "Cleaning previous builds..." rm -rf build/dist build/__pycache__ 2>/dev/null || true -echo "Running PyInstaller..." +# 共用 hidden imports(TUI + Web 都需要的核心库) +CORE_HIDDEN=( + --hidden-import core + --hidden-import core.embedded_db + --hidden-import core.graph_client + --hidden-import core.tool_executor + --hidden-import core.tool_limiter + --hidden-import core.tools + --hidden-import core.tools.memory_tools + --hidden-import core.prompts + --hidden-import core.prompts.prompt_manager + --hidden-import core.server + --hidden-import core.client + --hidden-import core.migrate + --hidden-import core.activity_recorder +) + +echo "================================" +echo "1️⃣ Build TUI: TrulyMEM" +echo "================================" python -m PyInstaller trulymem_entry.py \ - --clean \ - --onefile \ - --console \ - --name TrulyMEM \ + --clean --onefile --console --name TrulyMEM \ --add-data "ui/styles:ui/styles" \ --add-data "core/prompts/templates:core/prompts/templates" \ + --add-data "static:static" \ + --add-data "templates:templates" \ + --add-data "web_api.py:." \ --hidden-import textual \ --hidden-import textual.app \ --hidden-import textual.widgets \ @@ -48,17 +57,10 @@ python -m PyInstaller trulymem_entry.py \ --hidden-import openai._client \ --hidden-import neo4j \ --hidden-import sqlite3 \ - --hidden-import core \ - --hidden-import core.embedded_db \ - --hidden-import core.graph_client \ - --hidden-import core.tool_executor \ - --hidden-import core.tool_limiter \ - --hidden-import core.tools \ - --hidden-import core.tools.memory_tools \ - --hidden-import core.prompts \ - --hidden-import core.prompts.prompt_manager \ + "${CORE_HIDDEN[@]}" \ --hidden-import ui \ --hidden-import ui.app \ + --hidden-import ui.login_screen \ --hidden-import ui.models \ --hidden-import ui.models.message \ --hidden-import ui.models.config \ @@ -68,16 +70,30 @@ python -m PyInstaller trulymem_entry.py \ --hidden-import ui.services \ --hidden-import ui.services.config_manager \ --hidden-import ui.services.config_service \ + --hidden-import web_api \ + --hidden-import flask \ + --hidden-import flask_cors \ --collect-all textual \ --noconfirm +echo "================================" +echo "2️⃣ Build Web: trulymem-web" +echo "================================" +python -m PyInstaller web_api.py \ + --clean --onefile --console --name trulymem-web \ + --add-data "templates:templates" \ + --add-data "static:static" \ + --hidden-import flask \ + --hidden-import flask_cors \ + "${CORE_HIDDEN[@]}" \ + --noconfirm + +echo "================================" echo "===== Build Complete =====" echo "Binary: dist/TrulyMEM" +echo "Binary: dist/trulymem-web" ls -la dist/ -# 清理虚拟环境 -echo "Cleaning up virtual environment..." deactivate rm -rf "$VENV_DIR" - -echo "Build finished successfully!" \ No newline at end of file +echo "Build finished successfully!" diff --git a/build/build_macos.sh b/build/build_macos.sh index 69afd97..11ee12d 100644 --- a/build/build_macos.sh +++ b/build/build_macos.sh @@ -6,30 +6,20 @@ echo "===== Building TrulyMEM for macOS =====" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" 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 + echo "Error: python3 not found"; exit 1 fi -# 创建并激活虚拟环境 VENV_DIR="$PROJECT_ROOT/.venv_build" - echo "Creating virtual environment: $VENV_DIR" python3 -m venv "$VENV_DIR" - -echo "Activating virtual environment..." source "$VENV_DIR/bin/activate" - -echo "Upgrading pip in virtual environment..." pip install --upgrade pip - -echo "Installing dependencies in virtual environment..." pip install -r requirements.txt -echo "Generating ICNS icon..." +# 生成图标 if [ -d "pic/TrulyMEM.iconset" ]; then iconutil -c icns pic/TrulyMEM.iconset -o pic/TrulyMEM.icns echo "ICNS icon generated: pic/TrulyMEM.icns" @@ -38,15 +28,33 @@ fi echo "Cleaning previous builds..." rm -rf build/dist build/__pycache__ 2>/dev/null || true -echo "Running PyInstaller..." +CORE_HIDDEN=( + --hidden-import core + --hidden-import core.embedded_db + --hidden-import core.graph_client + --hidden-import core.tool_executor + --hidden-import core.tool_limiter + --hidden-import core.tools + --hidden-import core.tools.memory_tools + --hidden-import core.prompts + --hidden-import core.prompts.prompt_manager + --hidden-import core.server + --hidden-import core.client + --hidden-import core.migrate + --hidden-import core.activity_recorder +) + +echo "================================" +echo "1️⃣ Build TUI: TrulyMEM" +echo "================================" python -m PyInstaller trulymem_entry.py \ - --clean \ - --onefile \ - --console \ - --name TrulyMEM \ + --clean --onefile --console --name TrulyMEM \ --icon "pic/TrulyMEM.icns" \ --add-data "ui/styles:ui/styles" \ --add-data "core/prompts/templates:core/prompts/templates" \ + --add-data "static:static" \ + --add-data "templates:templates" \ + --add-data "web_api.py:." \ --hidden-import textual \ --hidden-import textual.app \ --hidden-import textual.widgets \ @@ -55,17 +63,10 @@ python -m PyInstaller trulymem_entry.py \ --hidden-import openai._client \ --hidden-import neo4j \ --hidden-import sqlite3 \ - --hidden-import core \ - --hidden-import core.embedded_db \ - --hidden-import core.graph_client \ - --hidden-import core.tool_executor \ - --hidden-import core.tool_limiter \ - --hidden-import core.tools \ - --hidden-import core.tools.memory_tools \ - --hidden-import core.prompts \ - --hidden-import core.prompts.prompt_manager \ + "${CORE_HIDDEN[@]}" \ --hidden-import ui \ --hidden-import ui.app \ + --hidden-import ui.login_screen \ --hidden-import ui.models \ --hidden-import ui.models.message \ --hidden-import ui.models.config \ @@ -75,16 +76,30 @@ python -m PyInstaller trulymem_entry.py \ --hidden-import ui.services \ --hidden-import ui.services.config_manager \ --hidden-import ui.services.config_service \ + --hidden-import web_api \ + --hidden-import flask \ + --hidden-import flask_cors \ --collect-all textual \ --noconfirm +echo "================================" +echo "2️⃣ Build Web: trulymem-web" +echo "================================" +python -m PyInstaller web_api.py \ + --clean --onefile --console --name trulymem-web \ + --add-data "templates:templates" \ + --add-data "static:static" \ + --hidden-import flask \ + --hidden-import flask_cors \ + "${CORE_HIDDEN[@]}" \ + --noconfirm + +echo "================================" echo "===== Build Complete =====" echo "Binary: dist/TrulyMEM" +echo "Binary: dist/trulymem-web" ls -la dist/ -# 清理虚拟环境 -echo "Cleaning up virtual environment..." deactivate rm -rf "$VENV_DIR" - -echo "Build finished successfully!" \ No newline at end of file +echo "Build finished successfully!" diff --git a/build/build_windows.bat b/build/build_windows.bat index bc49619..3885e84 100644 --- a/build/build_windows.bat +++ b/build/build_windows.bat @@ -1,28 +1,56 @@ -@echo off -echo ===== Building TrulyMEM for Windows ===== +#!/bin/bash +set -e -REM 切换到脚本所在目录的上一级目录(项目根目录) -cd /d "%~dp0.." -echo Project root: %CD% +echo "===== Building TrulyMEM for Windows =====" -python --version >nul 2>&1 -if errorlevel 1 ( - echo Error: python not found - exit /b 1 -) +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +cd "$PROJECT_ROOT" +echo "Project root: $PROJECT_ROOT" -echo Installing dependencies... +if ! command -v python &> /dev/null; then + echo "Error: python not found" + exit 1 +fi + +VENV_DIR="$PROJECT_ROOT\.venv_build" +echo "Creating virtual environment: %VENV_DIR%" +python -m venv "%VENV_DIR%" +call "%VENV_DIR%\Scripts\activate.bat" +pip install --upgrade pip pip install -r requirements.txt -echo Running PyInstaller... +echo "Cleaning previous builds..." +rmdir /s /q "build\dist" 2>nul +rmdir /s /q "build\__pycache__" 2>nul +del /f /q "TrulyMEM.spec" 2>nul + +CORE_HIDDEN=( + --hidden-import core + --hidden-import core.embedded_db + --hidden-import core.graph_client + --hidden-import core.tool_executor + --hidden-import core.tool_limiter + --hidden-import core.tools + --hidden-import core.tools.memory_tools + --hidden-import core.prompts + --hidden-import core.prompts.prompt_manager + --hidden-import core.server + --hidden-import core.client + --hidden-import core.migrate + --hidden-import core.activity_recorder +) + +echo "================================" +echo "1. Build TUI: TrulyMEM.exe" +echo "================================" python -m PyInstaller trulymem_entry.py ^ - --clean ^ - --onefile ^ - --console ^ - --name TrulyMEM ^ - --icon "pic/TrulyMEM.ico" ^ + --clean --onefile --console --name TrulyMEM ^ --add-data "ui/styles;ui/styles" ^ --add-data "core/prompts/templates;core/prompts/templates" ^ + --add-data "static;static" ^ + --add-data "templates;templates" ^ + --add-data "web_api.py;." ^ --hidden-import textual ^ --hidden-import textual.app ^ --hidden-import textual.widgets ^ @@ -31,17 +59,10 @@ python -m PyInstaller trulymem_entry.py ^ --hidden-import openai._client ^ --hidden-import neo4j ^ --hidden-import sqlite3 ^ - --hidden-import core ^ - --hidden-import core.embedded_db ^ - --hidden-import core.graph_client ^ - --hidden-import core.tool_executor ^ - --hidden-import core.tool_limiter ^ - --hidden-import core.tools ^ - --hidden-import core.tools.memory_tools ^ - --hidden-import core.prompts ^ - --hidden-import core.prompts.prompt_manager ^ + %CORE_HIDDEN% ^ --hidden-import ui ^ --hidden-import ui.app ^ + --hidden-import ui.login_screen ^ --hidden-import ui.models ^ --hidden-import ui.models.message ^ --hidden-import ui.models.config ^ @@ -51,10 +72,27 @@ python -m PyInstaller trulymem_entry.py ^ --hidden-import ui.services ^ --hidden-import ui.services.config_manager ^ --hidden-import ui.services.config_service ^ + --hidden-import web_api ^ + --hidden-import flask ^ + --hidden-import flask_cors ^ --collect-all textual ^ --noconfirm -echo ===== Build Complete ===== -echo Binary: dist\TrulyMEM.exe -dir dist\TrulyMEM.exe -pause \ No newline at end of file +echo "================================" +echo "2. Build Web: trulymem-web.exe" +echo "================================" +python -m PyInstaller web_api.py ^ + --clean --onefile --console --name trulymem-web ^ + --add-data "templates;templates" ^ + --add-data "static;static" ^ + --hidden-import flask ^ + --hidden-import flask_cors ^ + %CORE_HIDDEN% ^ + --noconfirm + +echo "===== Build Complete =====" +echo "Output: dist/TrulyMEM.exe, dist/trulymem-web.exe" + +deactivate +rmdir /s /q "%VENV_DIR%" +echo "Build finished successfully!" diff --git a/build/trulymem.spec b/build/trulymem.spec index 810ca53..cbec6f3 100644 --- a/build/trulymem.spec +++ b/build/trulymem.spec @@ -8,6 +8,7 @@ project_root = os.path.dirname(os.path.abspath(SPEC)) sys.path.insert(0, project_root) datas = [] +# UI 样式 if os.path.exists(os.path.join(project_root, 'ui', 'styles')): for root, dirs, files in os.walk(os.path.join(project_root, 'ui', 'styles')): for f in files: @@ -15,6 +16,7 @@ if os.path.exists(os.path.join(project_root, 'ui', 'styles')): dst = os.path.join('ui', 'styles', os.path.relpath(src, os.path.join(project_root, 'ui', 'styles'))) datas.append((src, dst)) +# Prompt 模板 if os.path.exists(os.path.join(project_root, 'core', 'prompts', 'templates')): for root, dirs, files in os.walk(os.path.join(project_root, 'core', 'prompts', 'templates')): for f in files: @@ -22,45 +24,52 @@ if os.path.exists(os.path.join(project_root, 'core', 'prompts', 'templates')): dst = os.path.join('core', 'prompts', 'templates', os.path.relpath(src, os.path.join(project_root, 'core', 'prompts', 'templates'))) datas.append((src, dst)) +# Web 静态文件 +if os.path.exists(os.path.join(project_root, 'static')): + for root, dirs, files in os.walk(os.path.join(project_root, 'static')): + for f in files: + src = os.path.join(root, f) + dst = os.path.join('static', os.path.relpath(src, os.path.join(project_root, 'static'))) + datas.append((src, dst)) + +# Web 模板 +if os.path.exists(os.path.join(project_root, 'templates')): + for root, dirs, files in os.walk(os.path.join(project_root, 'templates')): + for f in files: + src = os.path.join(root, f) + dst = os.path.join('templates', os.path.relpath(src, os.path.join(project_root, 'templates'))) + datas.append((src, dst)) + +# Web API 脚本(以便子进程模式回退使用) +web_api_src = os.path.join(project_root, 'web_api.py') +if os.path.exists(web_api_src): + datas.append((web_api_src, '.')) + +# ——— TUI 主二进制 ——— a = Analysis( ['trulymem_entry.py'], - pathex=[], + pathex=[project_root], binaries=[], datas=datas, hiddenimports=[ - 'textual', - 'textual.app', - 'textual.widgets', - 'textual.css', - 'openai', - 'openai._client', + 'textual', 'textual.app', 'textual.widgets', 'textual.css', + 'openai', 'openai._client', 'neo4j', 'sqlite3', - 'graph_memory_tui', - 'core', - 'core.embedded_db', - 'core.graph_client', - 'core.tool_executor', - 'core.tool_limiter', - 'core.tools', - 'core.tools.memory_tools', - 'core.prompts', - 'core.prompts.prompt_manager', - 'ui', - 'ui.app', - 'ui.models', - 'ui.models.message', - 'ui.models.config', - 'ui.models.log_entry', - 'ui.widgets', - 'ui.widgets.left_panel', - 'ui.widgets.right_panel', - 'ui.widgets.input_box', - 'ui.widgets.message_history', - 'ui.widgets.status_bar', + 'core', 'core.embedded_db', 'core.graph_client', + 'core.tool_executor', 'core.tool_limiter', + 'core.tools', 'core.tools.memory_tools', + 'core.prompts', 'core.prompts.prompt_manager', + 'core.server', 'core.client', + 'core.migrate', + 'ui', 'ui.app', 'ui.login_screen', + 'ui.models', 'ui.models.message', 'ui.models.config', 'ui.models.log_entry', + 'ui.widgets', 'ui.widgets.left_panel', 'ui.widgets.right_panel', + 'ui.widgets.input_box', 'ui.widgets.message_history', 'ui.widgets.status_bar', 'ui.handlers', - 'ui.services', - 'ui.services.config_manager', + 'ui.services', 'ui.services.config_manager', 'ui.services.config_service', + 'web_api', + 'flask', 'flask_cors', ], hookspath=[], hooksconfig={}, @@ -89,4 +98,48 @@ exe = EXE( target_arch=None, codesign_identity=None, entitlements_file=None, -) \ No newline at end of file +) + +# ——— Web 服务二进制(trulymem-web)——— +web_a = Analysis( + ['web_api.py'], + pathex=[project_root], + binaries=[], + datas=[ + (os.path.join(project_root, 'templates'), 'templates'), + (os.path.join(project_root, 'static'), 'static'), + ], + hiddenimports=[ + 'flask', 'flask_cors', + 'core', 'core.server', 'core.client', + 'core.embedded_db', 'core.activity_recorder', + 'core.migrate', + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +web_pyz = PYZ(web_a.pure, block_cipher) +web_exe = EXE( + web_pyz, + web_a.scripts, + web_a.binaries, + web_a.datas, + [], + name='trulymem-web', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/core/activity_recorder.py b/core/activity_recorder.py new file mode 100644 index 0000000..bfec8b5 --- /dev/null +++ b/core/activity_recorder.py @@ -0,0 +1,41 @@ +import sqlite3 +import time +from typing import List, Dict, Optional + + +class ActivityRecorder: + """记录 AI 对图数据库的操作到内存 SQLite""" + + 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.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.commit() + + def get_all(self) -> List[Dict]: + 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] + + def clear(self) -> None: + self.conn.execute("DELETE FROM activities") + self.conn.commit() + + def get_summary(self) -> Dict[str, int]: + cursor = self.conn.execute("SELECT action, COUNT(*) FROM activities GROUP BY action") + rows = cursor.fetchall() + return {r[0]: r[1] for r in rows} + + +_recorder: Optional[ActivityRecorder] = None + + +def get_recorder() -> ActivityRecorder: + global _recorder + if _recorder is None: + _recorder = ActivityRecorder() + return _recorder diff --git a/core/client.py b/core/client.py index b7fb2f8..43ba998 100644 --- a/core/client.py +++ b/core/client.py @@ -85,6 +85,44 @@ class BackendClient: ) response = self._server.send(packet) return response.body.get("data", {}) + + def get_web_users(self) -> list: + """获取 Web 用户列表""" + packet = Packet( + id=self._next_id(), + type=PacketType.GET_WEB_USERS, + body={} + ) + return self._server.send(packet).body.get("users", []) + + def set_web_user(self, username: str, password: str) -> Dict: + """设置 Web 用户""" + packet = Packet( + id=self._next_id(), + type=PacketType.SET_WEB_USER, + body={"username": username, "password": password} + ) + return self._server.send(packet).body.get("data", {"success": False}) + + def get_full_config(self) -> Dict: + """获取完整配置""" + packet = Packet( + id=self._next_id(), + type=PacketType.GET_CONFIG, + body={} + ) + response = self._server.send(packet) + return response.body if response.body else {"api_config": {}, "tool_limits": {}} + + def report_web_status(self, running: bool, port: int = 4096) -> Dict: + """向后端报告 Web 服务运行状态""" + packet = Packet( + id=self._next_id(), + type=PacketType.GET_WEB_SERVICE_STATUS, + body={"running": running, "port": port} + ) + response = self._server.send(packet) + return response.body if response.body else {"success": False} def shutdown(self) -> None: self._server.shutdown() diff --git a/core/embedded_db.py b/core/embedded_db.py index ccaf5cb..2919cee 100644 --- a/core/embedded_db.py +++ b/core/embedded_db.py @@ -4,6 +4,7 @@ """ import sqlite3 +import hashlib import json from datetime import datetime from pathlib import Path @@ -86,6 +87,30 @@ class EmbeddedGraphDB: """) cursor.execute("CREATE INDEX idx_chat_created ON chat_records(created_at)") + # 创建 Web 用户表(支持多用户隔离) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS web_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + config_path TEXT, + db_path TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # 检查并添加新字段(用于旧数据库迁移) + cursor.execute("PRAGMA table_info(web_users)") + columns = [row[1] for row in cursor.fetchall()] + if 'config_path' not in columns: + cursor.execute("ALTER TABLE web_users ADD COLUMN config_path TEXT") + if 'db_path' not in columns: + cursor.execute("ALTER TABLE web_users ADD COLUMN db_path TEXT") + if 'role' not in columns: + cursor.execute("ALTER TABLE web_users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'") + self.conn.commit() def ensure_constraints(self): @@ -519,6 +544,122 @@ class EmbeddedGraphDB: cursor.execute("DELETE FROM chat_records") self.conn.commit() return {"cleared": True} + + def set_web_user(self, username: str, password: str, base_dir: str = None, role: str = 'user') -> Dict: + """设置或更新 Web 登录用户。password 是明文,自动哈希存储。 + 自动创建用户目录并设置 config_path 和 db_path。 + role: 'admin' 或 'user',默认 'user'""" + if not username or not password: + return {"success": False, "error": "用户名和密码不能为空"} + if role not in ('admin', 'user'): + return {"success": False, "error": "角色无效 (admin/user)"} + + import hashlib + from pathlib import Path + + password_hash = hashlib.sha256(password.encode()).hexdigest() + + # 确定基础目录 + if base_dir is None: + base_dir = Path.home() / ".trulymem" + else: + base_dir = Path(base_dir) + + # 创建用户目录 + user_dir = base_dir / username + user_dir.mkdir(parents=True, exist_ok=True) + + # 设置用户文件路径 + config_path = str(user_dir / "config.json") + db_path = str(user_dir / f"{username}_graph.db") + + cursor = self.conn.cursor() + # 如果是第一个用户,强制设为 admin + if self.get_web_users_count() == 0: + role = 'admin' + cursor.execute(""" + INSERT INTO web_users (username, password_hash, role, config_path, db_path) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(username) DO UPDATE SET + password_hash = excluded.password_hash, + role = CASE WHEN web_users.role = 'admin' THEN 'admin' ELSE excluded.role END, + config_path = COALESCE(web_users.config_path, excluded.config_path), + db_path = COALESCE(web_users.db_path, excluded.db_path), + updated_at = CURRENT_TIMESTAMP + """, (username, password_hash, role, config_path, db_path)) + self.conn.commit() + return {"success": True, "username": username, "role": role, "config_path": config_path, "db_path": db_path} + + def get_web_users(self) -> List[Dict]: + """获取所有 Web 用户列表""" + cursor = self.conn.cursor() + cursor.execute("SELECT id, username, role, config_path, db_path, created_at, updated_at FROM web_users ORDER BY created_at ASC") + users = [] + for row in cursor.fetchall(): + users.append({ + "id": row['id'], + "username": row['username'], + "role": row['role'], + "config_path": row['config_path'], + "db_path": row['db_path'], + "created_at": row['created_at'], + "updated_at": row['updated_at'] + }) + return users + + def get_web_user(self, username: str) -> Optional[Dict]: + """获取单个 Web 用户信息""" + cursor = self.conn.cursor() + cursor.execute(""" + SELECT id, username, role, config_path, db_path, created_at, updated_at + FROM web_users WHERE username = ? + """, (username,)) + row = cursor.fetchone() + if row: + return { + "id": row['id'], + "username": row['username'], + "role": row['role'], + "config_path": row['config_path'], + "db_path": row['db_path'], + "created_at": row['created_at'], + "updated_at": row['updated_at'] + } + return None + + def is_admin(self, username: str) -> bool: + """检查用户是否为管理员""" + user = self.get_web_user(username) + return user is not None and user.get('role') == 'admin' + + def delete_web_user(self, username: str) -> Dict: + """删除 Web 用户(同时保留文件目录)""" + if not username: + return {"success": False, "error": "用户名不能为空"} + cursor = self.conn.cursor() + cursor.execute("DELETE FROM web_users WHERE username = ?", (username,)) + self.conn.commit() + if cursor.rowcount > 0: + return {"success": True, "username": username} + return {"success": False, "error": "用户不存在"} + + def get_web_users_count(self) -> int: + """获取 Web 用户数量 (用于判断是否需要首次设置)""" + cursor = self.conn.cursor() + cursor.execute("SELECT COUNT(*) as cnt FROM web_users") + row = cursor.fetchone() + return row['cnt'] if row else 0 + + def verify_web_user(self, username: str, password: str) -> bool: + """验证 Web 用户登录""" + import hashlib + password_hash = hashlib.sha256(password.encode()).hexdigest() + cursor = self.conn.cursor() + cursor.execute(""" + SELECT id FROM web_users + WHERE username = ? AND password_hash = ? + """, (username, password_hash)) + return cursor.fetchone() is not None def close(self): """关闭数据库连接""" diff --git a/core/migrate.py b/core/migrate.py new file mode 100644 index 0000000..74d0727 --- /dev/null +++ b/core/migrate.py @@ -0,0 +1,135 @@ +""" +自动迁移模块 - 从旧版单用户架构迁移到多用户隔离架构 +""" + +import os +import shutil +import json +import hashlib +from pathlib import Path +from typing import Dict, Optional +from datetime import datetime + + +def _trulymem_dir() -> Path: + return Path.home() / ".trulymem" + +def _old_config_path() -> Path: + return _trulymem_dir() / "config.json" + +def _old_db_path() -> Path: + return _trulymem_dir() / "graph_memory.db" + +def _new_global_db_path() -> Path: + return _trulymem_dir() / "trulymem.db" + +def _migrated_flag() -> Path: + return _trulymem_dir() / ".migrated" + + +def need_migration() -> bool: + """检测是否需要迁移""" + # 如果已经迁移过,不需要再迁移 + if is_migrated(): + return False + + old_config_exists = _old_config_path().exists() + old_db_exists = _old_db_path().exists() + new_db_exists = _new_global_db_path().exists() + if (old_config_exists or old_db_exists) and not new_db_exists: + return True + + return False + + +def is_migrated() -> bool: + """检查是否已完成迁移""" + return _migrated_flag().exists() + + +def _mark_migrated(): + """标记迁移完成""" + _trulymem_dir().mkdir(parents=True, exist_ok=True) + with open(_migrated_flag(), 'w') as f: + f.write(datetime.now().isoformat()) + + +def run_migration(username: str, password: str) -> Dict: + """ + 执行迁移 + + Args: + username: 新用户名 + password: 新用户密码 + + Returns: + 迁移结果字典 + """ + try: + # 1. 创建用户目录 + user_dir = _trulymem_dir() / username + user_dir.mkdir(parents=True, exist_ok=True) + new_config_path = user_dir / "config.json" + if _old_config_path().exists(): + shutil.copy2(_old_config_path(), new_config_path) + new_db_path = user_dir / f"{username}_graph.db" + if _old_db_path().exists(): + shutil.copy2(_old_db_path(), new_db_path) + + # 4. 创建全局数据库并写入 web_users 表 + from .embedded_db import EmbeddedGraphDB + + global_db = EmbeddedGraphDB(db_path=str(_new_global_db_path())) + + # 设置用户(会自动创建记录) + result = global_db.set_web_user(username, password) + if not result.get("success"): + return {"success": False, "error": f"创建用户失败: {result.get('error')}"} + + # 如果用户目录已存在,更新路径(确保正确) + cursor = global_db.conn.cursor() + config_path = str(new_config_path) + db_path = str(new_db_path) + cursor.execute(""" + UPDATE web_users + SET config_path = ?, db_path = ? + WHERE username = ? + """, (config_path, db_path, username)) + global_db.conn.commit() + + # 5. 标记迁移完成 + _mark_migrated() + + global_db.close() + + return { + "success": True, + "username": username, + "config_path": config_path, + "db_path": db_path, + "message": "迁移完成" + } + + except Exception as e: + return {"success": False, "error": str(e)} + + +def rollback_migration(): + """回滚迁移(用于失败恢复)""" + try: + # 删除全局数据库 + if _new_global_db_path().exists(): + _new_global_db_path().unlink() + if _migrated_flag().exists(): + _migrated_flag().unlink() + + return {"success": True, "message": "回滚完成"} + except Exception as e: + return {"success": False, "error": str(e)} + + +if __name__ == '__main__': + # 测试 + print("Migration module test") + print(f"Need migration: {need_migration()}") + print(f"Is migrated: {is_migrated()}") diff --git a/core/server.py b/core/server.py index b0c8037..e57001f 100644 --- a/core/server.py +++ b/core/server.py @@ -9,6 +9,7 @@ from dataclasses import dataclass, field from enum import Enum from .embedded_db import EmbeddedGraphDB +from .activity_recorder import get_recorder class PacketType(Enum): @@ -17,6 +18,10 @@ class PacketType(Enum): GET_STATUS = "get_status" GET_SETTINGS = "get_settings" # 合并:获取 api_config + tool_limits SET_SETTINGS = "set_settings" # 合并:设置 api_config + tool_limits + GET_WEB_USERS = "get_web_users" # 获取 Web 用户列表 + SET_WEB_USER = "set_web_user" # 设置 Web 用户(用户名+密码) + GET_WEB_SERVICE_STATUS = "get_web_service_status" # 获取 Web 服务运行状态 + GET_CONFIG = "get_config" # 获取完整配置 GET_HISTORY = "get_history" SAVE_HISTORY = "save_history" SHUTDOWN = "shutdown" @@ -43,10 +48,11 @@ class BackendServer: DEFAULT_CONFIG_PATH = Path.home() / ".trulymem" / "config.json" - def __init__(self, db_path: str = "graph_memory.db", use_embedded_db: bool = True, config_file: str = None): + def __init__(self, db_path: str = "graph_memory.db", use_embedded_db: bool = True, config_file: str = None, username: str = ""): self._db_path = db_path self._use_embedded_db = use_embedded_db self._config_file = Path(config_file) if config_file else self.DEFAULT_CONFIG_PATH + self._username = username self._graph = None self._client = None @@ -97,9 +103,26 @@ class BackendServer: self._thread.start() def _load_config(self) -> None: - if self._config_file.exists(): + """加载配置。如果指定了用户名,从用户的 config_path 加载。""" + config_file = self._config_file + + # 如果指定了用户名,尝试从全局数据库获取用户的配置路径 + if self._username: try: - with open(self._config_file, 'r') as f: + global_db_path = Path.home() / ".trulymem" / "trulymem.db" + if global_db_path.exists(): + from .embedded_db import EmbeddedGraphDB + temp_db = EmbeddedGraphDB(db_path=str(global_db_path)) + user_info = temp_db.get_web_user(self._username) + temp_db.close() + if user_info and user_info.get('config_path'): + config_file = Path(user_info['config_path']) + except Exception: + pass + + if config_file.exists(): + try: + with open(config_file, 'r') as f: saved = json.load(f) self._config.update(saved) for key in self._tool_limits: @@ -109,9 +132,26 @@ class BackendServer: pass def _save_config(self) -> None: - self._config_file.parent.mkdir(parents=True, exist_ok=True) + """保存配置。如果指定了用户名,保存到用户的 config_path。""" + config_file = self._config_file + + # 如果指定了用户名,尝试从全局数据库获取用户的配置路径 + if self._username: + try: + global_db_path = Path.home() / ".trulymem" / "trulymem.db" + if global_db_path.exists(): + from .embedded_db import EmbeddedGraphDB + temp_db = EmbeddedGraphDB(db_path=str(global_db_path)) + user_info = temp_db.get_web_user(self._username) + temp_db.close() + if user_info and user_info.get('config_path'): + config_file = Path(user_info['config_path']) + except Exception: + pass + + config_file.parent.mkdir(parents=True, exist_ok=True) saved_data = {**self._config, **self._tool_limits} - with open(self._config_file, 'w') as f: + with open(config_file, 'w') as f: json.dump(saved_data, f, indent=2) def _create_tool_limiter(self): @@ -125,8 +165,25 @@ class BackendServer: return ToolLimiter(limits) def _init_graph(self) -> None: + """初始化图数据库。如果指定了用户名,从全局数据库获取用户的 db_path。""" + db_path = self._db_path + + # 如果指定了用户名,尝试从全局数据库获取用户的数据库路径 + if self._username: + try: + # 临时连接全局数据库获取用户信息 + global_db_path = Path.home() / ".trulymem" / "trulymem.db" + if global_db_path.exists(): + temp_db = EmbeddedGraphDB(db_path=str(global_db_path)) + user_info = temp_db.get_web_user(self._username) + temp_db.close() + if user_info and user_info.get('db_path'): + db_path = user_info['db_path'] + except Exception: + pass # 如果获取失败,使用默认路径 + if self._use_embedded_db: - self._graph = EmbeddedGraphDB(db_path=self._db_path) + self._graph = EmbeddedGraphDB(db_path=db_path) else: from .graph_client import Neo4jGraph self._graph = Neo4jGraph( @@ -158,6 +215,25 @@ class BackendServer: response_body = self._handle_get_settings() elif packet.type == PacketType.SET_SETTINGS: response_body = self._handle_set_settings(packet.body) + elif packet.type == PacketType.GET_WEB_USERS: + response_body = {"users": self._graph.get_web_users()} + elif packet.type == PacketType.SET_WEB_USER: + username = packet.body.get("username", "") + password = packet.body.get("password", "") + if not username or not password: + response_body = {"success": False, "error": "用户名和密码不能为空"} + else: + # 使用全局数据库(trulymem.db)来管理用户 + global_db_path = Path.home() / ".trulymem" / "trulymem.db" + from .embedded_db import EmbeddedGraphDB + global_db = EmbeddedGraphDB(db_path=str(global_db_path)) + response_body = global_db.set_web_user(username, password) + global_db.close() + elif packet.type == PacketType.GET_WEB_SERVICE_STATUS: + body = packet.body + response_body = {"running": body.get("running", False), "port": body.get("port", 4096)} + elif packet.type == PacketType.GET_CONFIG: + response_body = self._get_full_config() elif packet.type == PacketType.GET_HISTORY: response_body = self._handle_get_history() elif packet.type == PacketType.SAVE_HISTORY: @@ -182,6 +258,8 @@ class BackendServer: def _handle_process_message(self, body: Dict) -> Dict: from .tool_executor import execute_tool + get_recorder().clear() + user_input = body.get("user_input", "") if not self._client: @@ -332,6 +410,12 @@ class BackendServer: "tool_limits": self._tool_limits.copy() } + def _get_full_config(self) -> Dict: + return { + "api_config": self._config.copy(), + "tool_limits": self._tool_limits.copy(), + } + def _handle_set_settings(self, body: Dict) -> Dict: api_config = body.get("api_config", {}) tool_limits = body.get("tool_limits", {}) diff --git a/core/tool_executor.py b/core/tool_executor.py index 684950c..807a1c7 100644 --- a/core/tool_executor.py +++ b/core/tool_executor.py @@ -4,6 +4,8 @@ import json from typing import Any, Dict +from .activity_recorder import get_recorder + def execute_tool(graph: Any, tool_name: str, arguments: dict) -> str: """执行工具调用""" @@ -11,8 +13,12 @@ def execute_tool(graph: Any, tool_name: str, arguments: dict) -> str: print(f"[参数] {json.dumps(arguments, ensure_ascii=False, indent=2)}") try: + recorder = get_recorder() + # 基础记忆工具 if tool_name == "memory_recall": + entity = arguments.get("query_intent", "") or str(arguments.get("seed_entities", "")) + recorder.record("query", tool_name, entity) result = graph.recall( query_intent=arguments.get("query_intent", ""), seed_entities=arguments.get("seed_entities"), @@ -23,30 +29,39 @@ def execute_tool(graph: Any, tool_name: str, arguments: dict) -> str: return format_recall_result(result) elif tool_name == "memory_commit": + triplets = arguments.get("triplets", []) + entity = triplets[0].get("subject", "") if triplets else "" + recorder.record("create", tool_name, entity, f"{len(triplets)} triplets") result = graph.commit( - triplets=arguments.get("triplets", []), + triplets=triplets, entity_types=arguments.get("entity_types"), temporal_tag=arguments.get("temporal_tag") ) return json.dumps(result, ensure_ascii=False, default=str) elif tool_name == "memory_purge": + criteria = arguments.get("criteria", {}) + entity = criteria.get("subject_contains", str(criteria)) + recorder.record("delete", tool_name, entity) result = graph.purge( - criteria=arguments.get("criteria", {}), + criteria=criteria, mode=arguments.get("mode", "soft"), new_relation=arguments.get("new_relation") ) return json.dumps(result, ensure_ascii=False, default=str) elif tool_name == "memory_introspect": + recorder.record("query", tool_name, "数据库统计") result = graph.introspect(session_id=arguments.get("session_id")) return json.dumps(result, ensure_ascii=False, default=str) elif tool_name == "memory_archive": + recorder.record("archive", tool_name, "旧记忆") result = graph.archive(days=arguments.get("days", 30)) return json.dumps(result, ensure_ascii=False, default=str) elif tool_name == "memory_cleanup": + recorder.record("cleanup", tool_name, "已删除数据") result = graph.cleanup(dry_run=arguments.get("dry_run", True)) return json.dumps(result, ensure_ascii=False, default=str) @@ -56,27 +71,37 @@ def execute_tool(graph: Any, tool_name: str, arguments: dict) -> str: # 人设图管理工具 elif tool_name == "persona_update": + recorder.record("update", tool_name, "人设属性") result = execute_persona_update(graph, arguments) return json.dumps(result, ensure_ascii=False, default=str) elif tool_name == "persona_clear": + recorder.record("delete", tool_name, "所有人设") result = execute_persona_clear(graph, arguments) return json.dumps(result, ensure_ascii=False, default=str) # 工作记忆链管理工具 elif tool_name == "task_create": + desc = arguments.get("description", "") + recorder.record("create", tool_name, desc) result = execute_task_create(graph, arguments) return json.dumps(result, ensure_ascii=False, default=str) elif tool_name == "task_set_state": + desc = arguments.get("task_id", "") + recorder.record("update", tool_name, desc) result = execute_task_set_state(graph, arguments) return json.dumps(result, ensure_ascii=False, default=str) elif tool_name == "task_delete": + desc = arguments.get("task_id", "") + recorder.record("delete", tool_name, desc) result = execute_task_delete(graph, arguments) return json.dumps(result, ensure_ascii=False, default=str) elif tool_name == "task_link_info": + desc = arguments.get("task_id", "") + recorder.record("update", tool_name, desc) result = execute_task_link_info(graph, arguments) return json.dumps(result, ensure_ascii=False, default=str) diff --git a/static/graph.html b/static/graph.html new file mode 100644 index 0000000..a2bfbd0 --- /dev/null +++ b/static/graph.html @@ -0,0 +1,1501 @@ + + +
+ + +节点: 0
+边: 0
+状态: 初始化中...
+类型:
+提及次数:
+连接数:
+TrulyMEM - 登录以继续
+ + + + +TrulyMEM Web 管理界面
+创建管理员账户,用于登录 Web 管理界面
+ + + + +