From b4456a9c5b37fe13ed83fedd7f2e6df5c6e9a77b Mon Sep 17 00:00:00 2001 From: root Date: Tue, 28 Apr 2026 10:37:57 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=9A=E7=94=A8=E6=88=B7=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=20+=20Admin=20=E8=A7=92=E8=89=B2=E6=9D=83=E9=99=90=20+=20Web/T?= =?UTF-8?q?UI=20=E5=90=8C=E6=AD=A5=20+=20=E6=9E=84=E5=BB=BA=E9=9B=86?= =?UTF-8?q?=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本轮实现功能: 1. 多用户隔离:每个用户独立 config.json + graph.db 2. TUI 登录页 + 旧版自动迁移(core/migrate.py) 3. Admin/User 角色体系(core/embedded_db.py) 4. Web 后台管理 API(userinfo + admin CRUD) 5. Web 设置页用户管理区(仅 admin 可见) 6. TUI 侧栏配置区权限同步(非 admin 隐藏 Web 服务设置) 7. Web 服务打包为独立二进制(trulymem-web) 8. 双入口 PyInstaller 构建脚本(TUI + Web) 9. 活动记录器(core/activity_recorder.py) 10. 静态页面模板(登录/设置/首次引导) --- .gitignore | 8 + build/build_appimage.sh | 94 ++- build/build_linux.sh | 74 +- build/build_macos.sh | 75 +- build/build_windows.bat | 98 ++- build/trulymem.spec | 117 ++- core/activity_recorder.py | 41 + core/client.py | 38 + core/embedded_db.py | 141 ++++ core/migrate.py | 135 +++ core/server.py | 96 ++- core/tool_executor.py | 29 +- static/graph.html | 1501 ++++++++++++++++++++++++++++++++++ static/index.html | 1076 ++++++++++++++++++++++++ templates/login.html | 228 ++++++ templates/settings.html | 544 ++++++++++++ templates/setup.html | 170 ++++ ui/app.py | 155 +++- ui/login_screen.py | 142 ++++ ui/models/config.py | 28 +- ui/widgets/config_section.py | 134 ++- web_api.py | 747 +++++++++++++++++ 22 files changed, 5488 insertions(+), 183 deletions(-) create mode 100644 core/activity_recorder.py create mode 100644 core/migrate.py create mode 100644 static/graph.html create mode 100644 static/index.html create mode 100644 templates/login.html create mode 100644 templates/settings.html create mode 100644 templates/setup.html create mode 100644 ui/login_screen.py create mode 100644 web_api.py 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 @@ + + + + + + 记忆星图 - TrulyMEM + + + +
+
+
正在加载星图数据...
+
+
+

🌌 记忆星图

+

节点: 0

+

边: 0

+

状态: 初始化中...

+
+
+

+

类型:

+

提及次数:

+

连接数:

+
+ +
+ +
+
+ 💬 与 TrulyMEM 对话 + +
+
+
你好!我是 TrulyMEM,你的图记忆助手~
+
+
+ + +
+
🟢 已连接
+
+ +
+ + + + + + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..483efd7 --- /dev/null +++ b/static/index.html @@ -0,0 +1,1076 @@ + + + + + +TrulyMEM - TrueHumanMEM + + + + +
+ +
+
+
+
[AI] 系统就绪
+
正在连接...
+
+
+ +
+ +
+ + +
+
+
+ + +
+ + +
+
API 配置
+ +
API Key
+ + +
Base URL
+ + +
Model
+ + +
修改后自动保存
+
+ +
+
操作日志
+
暂无操作记录
+
+ + +
+
+ + +
+ + API: 未配置 + +
+ + + + + +
+
+ + + + + + + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..0ceaf8b --- /dev/null +++ b/templates/login.html @@ -0,0 +1,228 @@ + + + + + + 登录 - TrulyMEM + + + +
+

记忆星图

+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ + + + diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..959bde6 --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,544 @@ + + + + + + 设置 - TrulyMEM + + + +
+

⚙ 设置

+ + +
🔑 修改密码
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
🖥 TUI 终端服务
+
+
+
启用终端 TUI 服务
+
控制终端文本界面是否允许连接
+
+ +
+
状态加载中...
+ + +
+
+ + + + + ← 返回星图 +
+ + + + + + + diff --git a/templates/setup.html b/templates/setup.html new file mode 100644 index 0000000..0aa1577 --- /dev/null +++ b/templates/setup.html @@ -0,0 +1,170 @@ + + + + + + 首次设置 - TrulyMEM + + + +
+

🚀 首次设置

+

TrulyMEM Web 管理界面

+

创建管理员账户,用于登录 Web 管理界面

+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+
+ + + + diff --git a/ui/app.py b/ui/app.py index 4b7e858..fc8a6aa 100644 --- a/ui/app.py +++ b/ui/app.py @@ -1,9 +1,13 @@ import asyncio +import sys +import subprocess +import signal from pathlib import Path from textual.app import App, ComposeResult from textual.binding import Binding -from core import BackendServer, BackendClient +from core import BackendServer +from core.client import BackendClient from .models.message import Message @@ -27,6 +31,10 @@ class GraphMemoryApp(App): self._backend_server = backend_server self._backend_client = BackendClient(backend_server) if backend_server else None self._api_configured = False + self._web_process: subprocess.Popen | None = None + self._web_running = False + self.login_user = None # 当前登录用户 + self.login_user_info = None # 当前登录用户信息 def compose(self) -> ComposeResult: from .widgets.left_panel import LeftPanel @@ -56,18 +64,59 @@ class GraphMemoryApp(App): yield StatusBar() def on_mount(self) -> None: + from .widgets.status_bar import StatusBar + from .widgets.message_history import MessageHistory + from .login_screen import LoginScreen + from core.migrate import need_migration, is_migrated + + # 检查是否需要登录 + migrated = is_migrated() + need_login = migrated or not need_migration() + + if need_login and not self.login_user: + # 显示登录界面 + self.push_screen(LoginScreen()) + return + + # 已登录或无需登录,继续初始化 + self._init_after_login() + + def on_login_success(self, username: str, user_info: dict) -> None: + """登录成功后调用""" + self.login_user = username + self.login_user_info = user_info + # 更新 config section 的 admin 权限 + from .widgets.config_section import ConfigSection + try: + is_admin = user_info.get('role') == 'admin' + config_section = self.query_one(ConfigSection) + config_section.set_admin(is_admin) + except Exception: + pass + # 重新初始化后端 + self._init_after_login() + + def _init_after_login(self) -> None: + """登录后初始化""" from .widgets.status_bar import StatusBar from .widgets.message_history import MessageHistory status_bar = self.query_one(StatusBar) if not self._backend_server: - from .widgets.message_history import MessageHistory history = self.query_one(MessageHistory) error = Message(role="assistant", content="后端未初始化") history.add_message(error) status_bar.set_api_status(False) return + # 如果已登录,重新初始化后端服务器以使用用户的数据库 + if self.login_user: + from core.server import BackendServer + # 创建新的后端服务器(使用用户的数据库) + self._backend_server = BackendServer(username=self.login_user) + self._backend_client = BackendClient(self._backend_server) + self._backend_server.start() + status = self._backend_client.get_status() data = status.get("data", {}) self._api_configured = data.get("config", {}).get("api_key", "") != "" @@ -82,13 +131,80 @@ class GraphMemoryApp(App): message = Message(role=msg["role"], content=msg["content"]) history.add_message(message) - welcome = Message( - role="assistant", - content=f"系统就绪\nAPI Key: {'已配置' if self._api_configured else '未配置'}\n\n输入消息开始对话" - ) + role_label = "管理员" if self.login_user_info and self.login_user_info.get('role') == 'admin' else "用户" + welcome_msg = f"系统就绪\n用户: {self.login_user or '默认'} ({role_label})\n" + welcome_msg += f"API Key: {'已配置' if self._api_configured else '未配置'}\n\n输入消息开始对话" + welcome = Message(role="assistant", content=welcome_msg) history.add_message(welcome) + def _start_web_server(self, port: int = 4096) -> None: + """启动 Web 服务器子进程(支持打包和开发模式)""" + if self._web_process and self._web_process.poll() is None: + self.notify("Web 服务已在运行", title="提示") + return + + def _find_web_binary() -> str: + """查找 Web 二进制或脚本路径""" + # 1. PyInstaller 打包环境下查找同目录的 trulymem-web 二进制 + if getattr(sys, 'frozen', False): + base = Path(sys._MEIPASS).parent + for name in ['trulymem-web', 'trulymem-web.exe']: + candidate = base / name + if candidate.exists(): + return str(candidate) + # 2. 开发模式:同目录下的 web_api.py + web_script = Path(__file__).parent.parent / "web_api.py" + if web_script.exists(): + return str(web_script) + # 3. 打包环境回退:从 MEIPASS 读取 web_api.py 数据文件 + if getattr(sys, 'frozen', False): + bundled = Path(sys._MEIPASS) / "web_api.py" + if bundled.exists(): + return str(bundled) + return "" + + target = _find_web_binary() + if not target: + self.notify("找不到 Web 服务文件(web_api.py)", severity="error") + return + + try: + if target.endswith('.py'): + cmd = [sys.executable, target, "--port", str(port)] + else: + cmd = [target, "--port", str(port)] + self._web_process = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + self._web_running = True + if self._backend_client: + self._backend_client.report_web_status(True, port) + self.notify(f"Web 服务已启动 → http://0.0.0.0:{port}", title="Web 服务") + except Exception as e: + self.notify(f"启动 Web 服务失败: {e}", severity="error") + + def _stop_web_server(self) -> None: + """停止 Web 服务器子进程""" + if self._web_process: + try: + self._web_process.terminate() + self._web_process.wait(timeout=5) + except: + try: + self._web_process.kill() + except: + pass + self._web_process = None + self._web_running = False + if self._backend_client: + self._backend_client.report_web_status(False, 0) + self.notify("Web 服务已停止", title="Web 服务") + def on_unmount(self) -> None: + # 停止 Web 服务 + self._stop_web_server() if self._backend_client: self._backend_client.shutdown() @@ -226,6 +342,16 @@ class GraphMemoryApp(App): "memory_update_max": config.memory_update_max, } + # 保存 Web 用户(如果用户名和密码都不为空) + if config.web_username and config.web_password: + try: + await asyncio.get_event_loop().run_in_executor( + None, self._backend_client.set_web_user, + config.web_username, config.web_password + ) + except Exception as e: + self.notify(f"保存 Web 用户失败: {e}", severity="warning") + try: result = await asyncio.get_event_loop().run_in_executor( None, @@ -234,17 +360,17 @@ class GraphMemoryApp(App): tool_limits=tool_limits ) ) - + if result.get("success"): self._api_configured = bool(config.api_key) status_bar.set_api_status(self._api_configured) - + settings_result = await asyncio.get_event_loop().run_in_executor( None, lambda: self._backend_client.get_settings() ) settings_data = settings_result.get("data", {}) - + try: config_section = self.query_one(ConfigSection) api_cfg = settings_data.get("api_config", {}) @@ -257,10 +383,19 @@ class GraphMemoryApp(App): task_update_max=tool_lmts.get("task_update_max", 5), memory_query_max=tool_lmts.get("memory_query_max", 20), memory_update_max=tool_lmts.get("memory_update_max", 10), + enable_web=api_cfg.get("enable_web", False), + web_port=api_cfg.get("web_port", 4096), + enable_tui=api_cfg.get("enable_tui", True), )) except Exception: pass - + + # 管理 Web 服务 + if config.enable_web: + self._start_web_server(config.web_port) + else: + self._stop_web_server() + self.notify("✅ 配置已保存并生效", title="配置成功", severity="information") else: error = result.get("error", "未知错误") diff --git a/ui/login_screen.py b/ui/login_screen.py new file mode 100644 index 0000000..3fbbd18 --- /dev/null +++ b/ui/login_screen.py @@ -0,0 +1,142 @@ +""" +TUI 登录页面 +""" + +import asyncio +from pathlib import Path +from textual.app import ComposeResult +from textual.containers import Center, Middle, Vertical +from textual.widgets import Input, Button, Static, Label +from textual.screen import Screen +from core.embedded_db import EmbeddedGraphDB +from core.migrate import need_migration, run_migration, is_migrated + + +class LoginScreen(Screen): + """登录界面""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + self._migrating = False + self._migration_username = "" + self._migration_password = "" + + def compose(self) -> ComposeResult: + # 检测是否需要迁移 + migrating = need_migration() + migrated = is_migrated() + + if migrating and not migrated: + yield from self._compose_migration() + else: + yield from self._compose_login() + + def _compose_login(self) -> ComposeResult: + with Center(): + with Middle(): + with Vertical(id="login_container"): + yield Static("🔐 TrulyMEM 登录", id="login_title") + yield Label("用户名:") + yield Input(placeholder="请输入用户名", id="username_input") + yield Label("密码:") + yield Input(placeholder="请输入密码", password=True, id="password_input") + yield Button("登录", id="login_button", variant="primary") + yield Static("", id="login_message") + + def _compose_migration(self) -> ComposeResult: + with Center(): + with Middle(): + with Vertical(id="migration_container"): + yield Static("🔄 检测到旧版数据,需要迁移", id="migration_title") + yield Static("请设置管理员账号以完成迁移", id="migration_subtitle") + yield Label("用户名:") + yield Input(placeholder="请输入管理员用户名", id="mig_username_input") + yield Label("密码:") + yield Input(placeholder="请输入管理员密码", password=True, id="mig_password_input") + yield Button("开始迁移", id="migrate_button", variant="primary") + yield Static("", id="migration_message") + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "login_button": + self._handle_login() + elif event.button.id == "migrate_button": + self._handle_migration() + + def on_input_submitted(self, event: Input.Submitted) -> None: + if event.input.id == "username_input": + self.query_one("#password_input", Input).focus() + elif event.input.id == "password_input": + self._handle_login() + elif event.input.id == "mig_username_input": + self.query_one("#mig_password_input", Input).focus() + elif event.input.id == "mig_password_input": + self._handle_migration() + + def _handle_login(self) -> None: + username = self.query_one("#username_input", Input).value.strip() + password = self.query_one("#password_input", Input).value + + if not username or not password: + self.query_one("#login_message", Static).update("❌ 用户名和密码不能为空") + return + + # 验证用户 + try: + global_db_path = Path.home() / ".trulymem" / "trulymem.db" + if not global_db_path.exists(): + self.query_one("#login_message", Static).update("❌ 全局数据库不存在,请先完成迁移") + return + + db = EmbeddedGraphDB(db_path=str(global_db_path)) + user_info = db.get_web_user(username) + + if not user_info: + self.query_one("#login_message", Static).update("❌ 用户不存在") + db.close() + return + + # 验证密码 + import hashlib + password_hash = hashlib.sha256(password.encode()).hexdigest() + + if db.verify_web_user(username, password): + db.close() + # 登录成功,通知应用 + self.app.on_login_success(username, user_info) + else: + self.query_one("#login_message", Static).update("❌ 密码错误") + db.close() + + except Exception as e: + self.query_one("#login_message", Static).update(f"❌ 登录失败: {str(e)}") + + def _handle_migration(self) -> None: + username = self.query_one("#mig_username_input", Input).value.strip() + password = self.query_one("#mig_password_input", Input).value + + if not username or not password: + self.query_one("#migration_message", Static).update("❌ 用户名和密码不能为空") + return + + self.query_one("#migration_message", Static).update("⏳ 正在迁移...") + + # 执行迁移(异步) + asyncio.create_task(self._do_migration(username, password)) + + async def _do_migration(self, username: str, password: str) -> None: + try: + loop = asyncio.get_event_loop() + result = await loop.run_in_executor( + None, run_migration, username, password + ) + + if result.get("success"): + self.query_one("#migration_message", Static).update("✅ 迁移成功!请登录") + # 重新加载界面为登录界面 + await asyncio.sleep(1) + self.app.pop_screen() + self.app.push_screen(LoginScreen()) + else: + self.query_one("#migration_message", Static).update(f"❌ 迁移失败: {result.get('error')}") + except Exception as e: + self.query_one("#migration_message", Static).update(f"❌ 迁移异常: {str(e)}") diff --git a/ui/models/config.py b/ui/models/config.py index 6acb5d8..d7700df 100644 --- a/ui/models/config.py +++ b/ui/models/config.py @@ -17,9 +17,25 @@ class AppConfig: task_update_max: int = 5 memory_query_max: int = 20 memory_update_max: int = 10 + web_username: str = "" + web_password: str = "" + enable_web: bool = False + web_port: int = 4096 + enable_tui: bool = True @classmethod - def from_env(cls) -> "AppConfig": + def from_env(cls, username: str = "") -> "AppConfig": + """ + 从环境变量加载配置。 + 如果指定了 username,尝试从用户的配置文件加载。 + """ + # 如果指定了用户名,尝试从用户的配置文件加载 + if username: + from pathlib import Path + user_config_path = Path.home() / ".trulymem" / username / "config.json" + if user_config_path.exists(): + return cls.from_file(user_config_path) + return cls( api_key=os.getenv("DEEPSEEK_API_KEY", ""), model=os.getenv("MODEL_NAME", "deepseek-chat"), @@ -28,6 +44,11 @@ class AppConfig: task_update_max=int(os.getenv("TASK_UPDATE_MAX", 5)), memory_query_max=int(os.getenv("MEMORY_QUERY_MAX", 20)), memory_update_max=int(os.getenv("MEMORY_UPDATE_MAX", 10)), + web_username=os.getenv("WEB_USERNAME", ""), + web_password=os.getenv("WEB_PASSWORD", ""), + enable_web=os.getenv("ENABLE_WEB", "false").lower() == "true", + web_port=int(os.getenv("WEB_PORT", 4096)), + enable_tui=os.getenv("ENABLE_TUI", "true").lower() == "true", ) @classmethod @@ -46,6 +67,11 @@ class AppConfig: task_update_max=data.get("task_update_max", 5), memory_query_max=data.get("memory_query_max", 20), memory_update_max=data.get("memory_update_max", 10), + web_username=data.get("web_username", ""), + web_password=data.get("web_password", ""), + enable_web=data.get("enable_web", False), + web_port=data.get("web_port", 4096), + enable_tui=data.get("enable_tui", True), ) def save(self, path: Path) -> None: diff --git a/ui/widgets/config_section.py b/ui/widgets/config_section.py index cd7ef6e..33d366c 100644 --- a/ui/widgets/config_section.py +++ b/ui/widgets/config_section.py @@ -15,9 +15,10 @@ class ConfigSection(Vertical): self.is_tool_limits = is_tool_limits super().__init__() - def __init__(self, config: AppConfig | None = None, **kwargs): + def __init__(self, config: AppConfig | None = None, is_admin: bool = True, **kwargs): super().__init__(**kwargs) self._config = config or AppConfig() + self._is_admin = is_admin def compose(self) -> ComposeResult: title = Static("━━ 配置 ━━", classes="config-title") @@ -83,26 +84,104 @@ class ConfigSection(Vertical): yield l6 yield Input(value=str(self._config.memory_update_max), placeholder="10", id="memory-update-max") - hint = Static("按Enter保存配置", classes="config-hint") - hint.can_focus = False - yield hint + sep2 = Static("", classes="config-sep") + sep2.can_focus = False + yield sep2 + + from textual.containers import Vertical + + # Web 登录 — 仅 admin 可见 + with Vertical(id="admin-web-login-section"): + web_title = Static("━━ Web 登录 ━━", classes="config-title") + web_title.can_focus = False + yield web_title + + label_web_user = Static("用户名:", classes="config-label") + label_web_user.can_focus = False + yield label_web_user + yield Input( + value=self._config.web_username, + placeholder="admin", + id="web-username-input" + ) + + label_web_pwd = Static("密码:", classes="config-label") + label_web_pwd.can_focus = False + yield label_web_pwd + yield Input( + value=self._config.web_password, + placeholder="修改密码", + id="web-password-input", + password=True + ) + + hint = Static("按Enter保存配置", classes="config-hint") + hint.can_focus = False + yield hint + + sep3 = Static("", classes="config-sep") + sep3.can_focus = False + yield sep3 + + # Web 服务 — 仅 admin 可见 + with Vertical(id="admin-web-service-section"): + ws_title = Static("━━ Web 服务 ━━", classes="config-title") + ws_title.can_focus = False + yield ws_title + + ws_hint = Static("在侧边栏启用后将自动启动 Web 管理界面", classes="config-hint") + ws_hint.can_focus = False + yield ws_hint + + from textual.widgets import Checkbox + yield Checkbox( + "启用 Web 服务", + value=self._config.enable_web, + id="enable-web-checkbox" + ) + + label_web_port = Static("端口:", classes="config-label") + label_web_port.can_focus = False + yield label_web_port + yield Input( + value=str(self._config.web_port), + placeholder="4096", + id="web-port-input", + type="integer" + ) + + # 默认隐藏 admin 区域,等 login 后决定是否显示 + self._apply_admin_visibility() def on_mount(self) -> None: try: api_key = self.query_one("#api-key-input", Input) model = self.query_one("#model-input", Input) base_url = self.query_one("#base-url-input", Input) - + api_key.tab_index = 0 model.tab_index = 1 base_url.tab_index = 2 - + if self._config.api_key: api_key.value = self._config.api_key if self._config.model: model.value = self._config.model if self._config.base_url: base_url.value = self._config.base_url + + web_user = self.query_one("#web-username-input", Input) + web_pwd = self.query_one("#web-password-input", Input) + web_user.tab_index = 7 + web_pwd.tab_index = 8 + + from textual.widgets import Checkbox + web_port = self.query_one("#web-port-input", Input) + try: + web_checkbox = self.query_one("#enable-web-checkbox", Checkbox) + except: + pass + web_port.tab_index = 9 except Exception: pass @@ -111,12 +190,19 @@ class ConfigSection(Vertical): api_key_input = self.query_one("#api-key-input", Input) model_input = self.query_one("#model-input", Input) base_url_input = self.query_one("#base-url-input", Input) - + persona_update = self.query_one("#persona-update-max", Input) task_update = self.query_one("#task-update-max", Input) memory_query = self.query_one("#memory-query-max", Input) memory_update = self.query_one("#memory-update-max", Input) + web_username = self.query_one("#web-username-input", Input) + web_password = self.query_one("#web-password-input", Input) + + from textual.widgets import Checkbox + web_checkbox = self.query_one("#enable-web-checkbox", Checkbox) + web_port_input = self.query_one("#web-port-input", Input) + self._config = AppConfig( api_key=api_key_input.value, model=model_input.value, @@ -125,6 +211,10 @@ class ConfigSection(Vertical): task_update_max=int(task_update.value or 5), memory_query_max=int(memory_query.value or 20), memory_update_max=int(memory_update.value or 10), + web_username=web_username.value, + web_password=web_password.value, + enable_web=web_checkbox.value, + web_port=int(web_port_input.value) if web_port_input.value else 4096, ) # 先发送 API 配置更新(is_tool_limits=False) @@ -134,6 +224,24 @@ class ConfigSection(Vertical): except Exception: pass + def set_admin(self, is_admin: bool) -> None: + """设置是否 admin 模式,动态显示/隐藏 admin 区域""" + self._is_admin = is_admin + self._apply_admin_visibility() + + def _apply_admin_visibility(self) -> None: + """根据 _is_admin 显示/隐藏 admin 专用区域""" + try: + login_section = self.query_one("#admin-web-login-section") + login_section.styles.display = "block" if self._is_admin else "none" + except Exception: + pass + try: + service_section = self.query_one("#admin-web-service-section") + service_section.styles.display = "block" if self._is_admin else "none" + except Exception: + pass + def get_config(self) -> AppConfig: return self._config @@ -147,10 +255,20 @@ class ConfigSection(Vertical): api_key_input.value = config.api_key model_input.value = config.model base_url_input.value = config.base_url - + self.query_one("#persona-update-max", Input).value = str(config.persona_update_max) self.query_one("#task-update-max", Input).value = str(config.task_update_max) self.query_one("#memory-query-max", Input).value = str(config.memory_query_max) self.query_one("#memory-update-max", Input).value = str(config.memory_update_max) + + self.query_one("#web-username-input", Input).value = config.web_username + self.query_one("#web-password-input", Input).value = config.web_password + + from textual.widgets import Checkbox + try: + self.query_one("#enable-web-checkbox", Checkbox).value = config.enable_web + except: + pass + self.query_one("#web-port-input", Input).value = str(config.web_port) except Exception: pass diff --git a/web_api.py b/web_api.py new file mode 100644 index 0000000..59ea929 --- /dev/null +++ b/web_api.py @@ -0,0 +1,747 @@ +""" +Web API 服务 - 将 Packet 协议映射为 RESTful API +""" +import sys +import os +import argparse +import threading +import time +import hashlib +from datetime import timedelta +from flask import Flask, request, jsonify, session, redirect, url_for, render_template +from flask_cors import CORS + +# 添加项目路径以便导入 core 模块 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from core.server import BackendServer, Packet, PacketType +from core.client import BackendClient +from core.activity_recorder import get_recorder +from core.embedded_db import EmbeddedGraphDB + + +# Web 服务配置(仅 SECRET_KEY 保留在 json 文件,用户信息在数据库) +def load_secret_key(): + import json + config_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'web_config.json') + defaults = {"SECRET_KEY": "trulymem-secret-key-2026"} + if os.path.exists(config_path): + with open(config_path, 'r', encoding='utf-8') as f: + file_config = json.load(f) + if "SECRET_KEY" in file_config: + defaults["SECRET_KEY"] = file_config["SECRET_KEY"] + return defaults + +WEB_CONFIG = load_secret_key() + + +app = Flask(__name__, static_folder='static', static_url_path='', template_folder='templates') +app.secret_key = WEB_CONFIG["SECRET_KEY"] +app.permanent_session_lifetime = timedelta(days=7) +CORS(app, supports_credentials=True) # 启用跨域支持,支持 session cookies + + +def login_required(f): + """登录验证装饰器""" + from functools import wraps + @wraps(f) + def decorated_function(*args, **kwargs): + if not session.get('authenticated'): + return redirect('/login') + return f(*args, **kwargs) + return decorated_function + + +def api_login_required(f): + """API 登录验证装饰器""" + from functools import wraps + @wraps(f) + def decorated_function(*args, **kwargs): + if not session.get('authenticated'): + return jsonify({"success": False, "error": "未登录"}), 401 + return f(*args, **kwargs) + return decorated_function + + +def admin_required(f): + """管理员权限验证装饰器""" + from functools import wraps + @wraps(f) + def decorated_function(*args, **kwargs): + username = session.get('username', '') + g_db = get_global_db() + if not g_db or not g_db.is_admin(username): + return jsonify({"success": False, "error": "权限不足,需要管理员权限"}), 403 + return f(*args, **kwargs) + return decorated_function + + +@app.route('/') +@login_required +def index(): + """返回星图页面(默认首页)""" + return app.send_static_file('graph.html') + + +@app.route('/graph.html') +@login_required +def graph_html(): + """返回星图页面""" + return app.send_static_file('graph.html') + + +@app.route('/chat') +@login_required +def chat(): + """返回聊天页面""" + return app.send_static_file('index.html') + +# 全局服务器和客户端实例 +backend_server: BackendServer = None +backend_client: BackendClient = None +server_thread: threading.Thread = None +graph_db: EmbeddedGraphDB = None +global_db: EmbeddedGraphDB = None # 全局数据库(用于用户管理) + + +def get_global_db(): + """获取全局数据库实例""" + global global_db + if global_db is None: + global_db_path = os.path.join(os.path.expanduser("~"), ".trulymem", "trulymem.db") + if os.path.exists(global_db_path): + global_db = EmbeddedGraphDB(db_path=global_db_path) + return global_db + + +def create_server(username: str = ""): + """创建并启动 BackendServer 后台线程""" + global backend_server, backend_client, server_thread, graph_db + + backend_server = BackendServer(username=username) + backend_server.start() + + backend_client = BackendClient(backend_server) + + # 创建图数据库实例(连接用户的数据库文件) + graph_db = EmbeddedGraphDB(db_path=backend_server._db_path) + + # 等待服务器初始化完成 + time.sleep(0.5) + + +def reload_server_for_user(username: str): + """为指定用户重新加载服务器""" + global backend_server, backend_client, graph_db + + # 关闭旧的服务器 + if backend_server: + backend_server.shutdown() + + # 创建新的服务器(使用用户的数据库) + create_server(username=username) + + +@app.route('/login') +def login_page(): + """登录页面 - 如果没有用户则重定向到设置页""" + # 如果没有用户,重定向到首次设置页 + users_count = 0 + if graph_db: + users_count = graph_db.get_web_users_count() + if users_count == 0: + return redirect('/setup') + return render_template('login.html') + + +@app.route('/setup') +def setup_page(): + """首次设置页面 - 如果已有用户则跳转到登录页""" + has_users = False + if graph_db: + has_users = graph_db.get_web_users_count() > 0 + if has_users: + return redirect('/login') + return render_template('setup.html') + + +@app.route('/settings') +@api_login_required +def settings_page(): + """Web 设置页面""" + return render_template('settings.html') + + +@app.route('/api/login', methods=['POST']) +def api_login(): + """登录接口""" + data = request.get_json() or {} + username = data.get('username', '') + password = data.get('password', '') + + # 从全局数据库验证 + g_db = get_global_db() + if g_db and g_db.verify_web_user(username, password): + session['authenticated'] = True + session['username'] = username # 存储用户名 + session.permanent = True + + # 重新加载服务器使用该用户的数据库 + reload_server_for_user(username) + + return jsonify({"success": True}) + + return jsonify({"success": False, "error": "用户名或密码错误"}) + + +@app.route('/api/logout', methods=['POST']) +def api_logout(): + """登出接口""" + session.clear() + return jsonify({"success": True}) + + +@app.route('/api/check-auth', methods=['GET']) +def check_auth(): + """检查登录状态""" + return jsonify({"authenticated": bool(session.get('authenticated'))}) + + +@app.route('/api/userinfo', methods=['GET']) +def userinfo(): + """获取当前登录用户信息(含角色)""" + if not session.get('authenticated'): + return jsonify({"success": False, "error": "未登录"}), 401 + username = session.get('username', '') + g_db = get_global_db() + if not g_db: + return jsonify({"success": False, "error": "数据库未初始化"}), 500 + user = g_db.get_web_user(username) + if not user: + return jsonify({"success": False, "error": "用户不存在"}), 404 + return jsonify({ + "success": True, + "username": user['username'], + "role": user.get('role', 'user'), + "is_admin": user.get('role') == 'admin', + "created_at": user.get('created_at') + }) + + +@app.route('/api/web-check', methods=['GET']) +def web_check(): + """检查是否需要首次设置,返回是否配置完成""" + users_count = 0 + if graph_db: + users_count = graph_db.get_web_users_count() + + return jsonify({ + "needs_setup": users_count == 0, + "users_count": users_count + }) + + +@app.route('/api/setup', methods=['POST']) +def api_setup(): + """首次设置 - 创建初始管理员用户""" + # 只有没有任何用户时才允许设置 + g_db = get_global_db() + if g_db and g_db.get_web_users_count() > 0: + return jsonify({"success": False, "error": "用户已存在,不允许重复设置"}), 400 + + data = request.get_json() or {} + username = data.get('username', '') + password = data.get('password', '') + confirm = data.get('confirm_password', '') + + if not username or not password: + return jsonify({"success": False, "error": "用户名和密码不能为空"}), 400 + + if password != confirm: + return jsonify({"success": False, "error": "两次密码输入不一致"}), 400 + + if len(password) < 6: + return jsonify({"success": False, "error": "密码长度至少 6 位"}), 400 + + # 使用全局数据库创建用户 + if g_db is None: + # 如果全局数据库不存在,创建它 + global_db_path = os.path.join(os.path.expanduser("~"), ".trulymem", "trulymem.db") + g_db = EmbeddedGraphDB(db_path=global_db_path) + global global_db + global_db = g_db + + result = g_db.set_web_user(username, password) + if result.get("success"): + # 设置完成后自动登录 + session['authenticated'] = True + session['username'] = username + session.permanent = True + return jsonify({"success": True, "message": "用户创建成功"}) + + return jsonify({"success": False, "error": "创建用户失败"}), 500 + + +@app.route('/api/change-password', methods=['POST']) +@api_login_required +def api_change_password(): + """修改 Web 登录密码""" + data = request.get_json() or {} + current_password = data.get('current_password', '') + new_password = data.get('new_password', '') + confirm_password = data.get('confirm_password', '') + + if not new_password: + return jsonify({"success": False, "error": "新密码不能为空"}), 400 + + if new_password != confirm_password: + return jsonify({"success": False, "error": "两次密码输入不一致"}), 400 + + if len(new_password) < 6: + return jsonify({"success": False, "error": "密码长度至少 6 位"}), 400 + + # 获取当前登录用户 + current_username = session.get('username', '') + if not current_username: + return jsonify({"success": False, "error": "无法识别当前用户"}), 400 + + # 验证当前密码(使用全局数据库) + g_db = get_global_db() + if not g_db or not g_db.verify_web_user(current_username, current_password): + return jsonify({"success": False, "error": "当前密码错误"}), 400 + + result = g_db.set_web_user(current_username, new_password) + if result.get("success"): + return jsonify({"success": True, "message": "密码已更新"}) + + return jsonify({"success": False, "error": "修改密码失败"}), 500 + + +# ========== 管理员 API ========== + +@app.route('/api/admin/users', endpoint='api_admin_get_users', methods=['GET']) +@api_login_required +@admin_required +def api_admin_get_users(): + """获取用户列表""" + g_db = get_global_db() + if not g_db: + return jsonify({"success": False, "error": "全局数据库未初始化"}), 500 + + users = g_db.get_web_users() + return jsonify({"success": True, "users": users}) + + +@app.route('/api/admin/users', endpoint='api_admin_add_user', methods=['POST']) +@api_login_required +@admin_required +def api_admin_add_user(): + """管理员添加用户""" + data = request.get_json() or {} + username = data.get('username', '') + password = data.get('password', '') + + if not username or not password: + return jsonify({"success": False, "error": "用户名和密码不能为空"}), 400 + + if len(password) < 6: + return jsonify({"success": False, "error": "密码长度至少 6 位"}), 400 + + g_db = get_global_db() + if not g_db: + return jsonify({"success": False, "error": "全局数据库未初始化"}), 500 + + result = g_db.set_web_user(username, password) + if result.get("success"): + return jsonify({"success": True, "message": "用户添加成功", "user": result}) + + return jsonify({"success": False, "error": "添加用户失败"}), 500 + + +@app.route('/api/admin/users/', methods=['DELETE']) +@api_login_required +@admin_required +def api_admin_delete_user(user_id): + """管理员删除用户""" + g_db = get_global_db() + if not g_db: + return jsonify({"success": False, "error": "全局数据库未初始化"}), 500 + + # 获取所有用户 + users = g_db.get_web_users() + + # 查找要删除的用户 + target_user = None + for u in users: + if u['id'] == user_id: + target_user = u + break + + if not target_user: + return jsonify({"success": False, "error": "用户不存在"}), 404 + + # 不能删除自己 + current_username = session.get('username', '') + if target_user['username'] == current_username: + return jsonify({"success": False, "error": "不能删除当前登录的用户"}), 400 + + # 不能删除最后一个 admin + admin_count = sum(1 for u in users if u.get('role') == 'admin') + if target_user.get('role') == 'admin' and admin_count <= 1: + return jsonify({"success": False, "error": "不能删除最后一个管理员"}), 400 + + # 删除用户(保留文件目录) + result = g_db.delete_web_user(target_user['username']) + if not result.get('success'): + return jsonify({"success": False, "error": result.get('error', '删除失败')}), 500 + + return jsonify({"success": True, "message": "用户已删除"}) + + +@app.route('/api/admin/migrate-check', methods=['GET']) +def api_admin_migrate_check(): + """检测系统是否需要迁移""" + from core.migrate import need_migration, is_migrated + + return jsonify({ + "success": True, + "need_migration": need_migration(), + "is_migrated": is_migrated() + }) + + +@app.route('/api/admin/migrate', methods=['POST']) +def api_admin_migrate(): + """执行迁移+创建首个用户""" + from core.migrate import need_migration, run_migration + + if not need_migration(): + return jsonify({"success": False, "error": "不需要迁移"}), 400 + + data = request.get_json() or {} + username = data.get('username', '') + password = data.get('password', '') + + if not username or not password: + return jsonify({"success": False, "error": "用户名和密码不能为空"}), 400 + + if len(password) < 6: + return jsonify({"success": False, "error": "密码长度至少 6 位"}), 400 + + result = run_migration(username, password) + + if result.get("success"): + # 自动登录 + session['authenticated'] = True + session['username'] = username + session.permanent = True + + # 重新加载服务器 + reload_server_for_user(username) + + return jsonify({"success": True, "message": "迁移完成", "data": result}) + + return jsonify({"success": False, "error": result.get("error", "迁移失败")}), 500 + + +@app.route('/api/settings/config', methods=['GET', 'POST', 'PUT']) +@api_login_required +def web_settings_config(): + """获取/更新当前登录用户的配置""" + if request.method == 'GET': + settings = {} + if backend_client: + result = backend_client.get_settings() + settings = result.get("data", {}) if isinstance(result, dict) else result + if isinstance(settings, dict): + settings = settings.get("api_config", {}) if "api_config" in settings else settings + # 从 settings 中提取相关字段 + return jsonify({ + "success": True, + "enable_web": settings.get("enable_web", False), + "web_port": settings.get("web_port", 4096), + "enable_tui": settings.get("enable_tui", True), + }) + + # PUT/POST 更新 + data = request.get_json() or {} + enable_tui = data.get('enable_tui') + + if enable_tui is not None and backend_client: + # 获取当前配置,合并更新 + current = backend_client.get_settings() + current_data = current.get("data", {}) if isinstance(current, dict) else {} + + tool_limits = current_data.get("tool_limits", {}) + api_config = current_data.get("api_config", {}) + api_config["enable_tui"] = bool(enable_tui) + + result = backend_client.update_settings(api_config, tool_limits) + return jsonify({"success": True, "enable_tui": bool(enable_tui)}) + + return jsonify({"success": False, "error": "没有需要更新的配置"}), 400 + + +@app.errorhandler(404) +def not_found(e): + """404 处理""" + return jsonify({ + "success": False, + "error": "404 Not Found" + }), 404 + + +@app.errorhandler(500) +def server_error(e): + """500 处理""" + return jsonify({ + "success": False, + "error": str(e.original_exception if hasattr(e, 'original_exception') else e) + }), 500 + + +@app.route('/api/message', methods=['POST']) +@api_login_required +def process_message(): + """发送消息给 AI - PROCESS_MESSAGE""" + data = request.get_json() or {} + user_input = data.get('message', '') + + if not user_input: + return jsonify({ + "success": False, + "error": "message 参数不能为空" + }), 400 + + result = backend_client.process_message(user_input) + return jsonify(result) + + +@app.route('/api/tools/execute', methods=['POST']) +@api_login_required +def execute_tool(): + """直接执行工具 - EXECUTE_TOOL""" + data = request.get_json() or {} + tool_name = data.get('tool_name', '') + arguments = data.get('arguments', {}) + + if not tool_name: + return jsonify({ + "success": False, + "error": "tool_name 参数不能为空" + }), 400 + + result = backend_client.execute_tool(tool_name, arguments) + return jsonify(result) + + +@app.route('/api/status', methods=['GET']) +@api_login_required +def get_status(): + """获取状态 - GET_STATUS""" + result = backend_client.get_status() + return jsonify(result) + + +@app.route('/api/settings', methods=['GET']) +@api_login_required +def get_settings(): + """获取配置 - GET_SETTINGS""" + result = backend_client.get_settings() + return jsonify(result) + + +@app.route('/api/settings', methods=['PUT']) +@api_login_required +def set_settings(): + """更新配置 - SET_SETTINGS""" + data = request.get_json() or {} + + api_config = data.get('api_config', {}) + tool_limits = data.get('tool_limits', {}) + + result = backend_client.update_settings(api_config, tool_limits) + return jsonify(result) + + +@app.route('/api/history', methods=['GET']) +@api_login_required +def get_history(): + """获取历史 - GET_HISTORY""" + result = backend_client.get_history() + return jsonify({"success": True, "history": result}) + + +@app.route('/api/history', methods=['DELETE']) +@api_login_required +def clear_history(): + """清空历史 - SAVE_HISTORY(空)""" + result = backend_client.clear_history() + return jsonify(result) + + +@app.route('/api/shutdown', methods=['POST']) +@api_login_required +def shutdown(): + """关闭服务器 - SHUTDOWN""" + backend_client.shutdown() + return jsonify({"success": True, "status": "shutdown"}) + + +@app.route('/api/activity', methods=['GET']) +@api_login_required +def get_activity(): + """获取当前轮的数据库操作记录""" + recorder = get_recorder() + records = recorder.get_all() + summary = recorder.get_summary() + return jsonify({ + "success": True, + "data": { + "records": records, + "summary": summary + } + }) + + +@app.route('/api/graph', methods=['GET']) +@api_login_required +def get_graph(): + """返回全量图数据""" + global graph_db + + if graph_db is None: + return jsonify({ + "success": False, + "error": "图数据库未初始化" + }), 500 + + cursor = graph_db.conn.cursor() + + # 查询实体(节点) + cursor.execute(""" + SELECT id, name, type, mention_count + FROM entities + ORDER BY mention_count DESC + LIMIT 200 + """) + + nodes = [] + for row in cursor.fetchall(): + nodes.append({ + "id": row['id'], + "name": row['name'], + "type": str(row['type'] or 'unknown'), + "mention_count": row['mention_count'] + }) + + # 查询关系(边) + cursor.execute(""" + SELECT r.id, r.source_id, r.target_id, r.relation_type, r.confidence, r.status + FROM relations r + WHERE r.status = 'active' + """) + + edges = [] + for row in cursor.fetchall(): + edges.append({ + "id": row['id'], + "source": row['source_id'], + "target": row['target_id'], + "relation_type": row['relation_type'], + "confidence": row['confidence'], + "status": row['status'] + }) + + return jsonify({ + "success": True, + "nodes": nodes, + "edges": edges, + "stats": { + "node_count": len(nodes), + "edge_count": len(edges) + } + }) + + +@app.route('/api/graph/highlight', methods=['GET']) +@api_login_required +def get_graph_highlight(): + """返回需要高亮的节点ID列表""" + recorder = get_recorder() + records = recorder.get_all() + + highlight_ids = [] + new_node_id = None + new_edge = None + + if graph_db is None: + return jsonify({ + "success": False, + "data": { + "highlight_ids": [], + "new_node_id": None, + "new_edge": None + } + }) + + # 从最近的记录中提取实体ID + for record in records[-10:]: # 只看最近10条记录 + entity_name = record.get('entity', '') + if entity_name: + cursor = graph_db.conn.cursor() + cursor.execute("SELECT id FROM entities WHERE name = ?", (entity_name,)) + row = cursor.fetchone() + if row: + highlight_ids.append(row['id']) + + # 检查是否有新创建的节点 + if record.get('action') == 'create' and entity_name: + cursor = graph_db.conn.cursor() + cursor.execute("SELECT id FROM entities WHERE name = ?", (entity_name,)) + row = cursor.fetchone() + if row: + new_node_id = row['id'] + + # 检查是否有删除的节点 + deleted_node_ids = [] + for record in records[-10:]: + if record.get('action') == 'delete': + entity_name = record.get('entity', '') + if entity_name: + try: + cursor = graph_db.conn.cursor() + cursor.execute("SELECT id FROM entities WHERE name = ?", (entity_name,)) + row = cursor.fetchone() + if row: + deleted_node_ids.append(row['id']) + except Exception: + pass # 实体可能已被删除,忽略错误 + + # 去重 + highlight_ids = list(set(highlight_ids)) + deleted_node_ids = list(set(deleted_node_ids)) + + return jsonify({ + "success": True, + "highlight_ids": highlight_ids, + "new_node_id": new_node_id, + "new_edge": new_edge, + "deleted_node_ids": deleted_node_ids + }) + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='TrulyMEM Web API 服务') + parser.add_argument('--port', type=int, default=5000, help='服务端口 (默认: 5000)') + + args = parser.parse_args() + + # 启动后端服务器 + print("正在启动 BackendServer...") + create_server() + print("BackendServer 已启动") + + # 启动 Flask 应用 + print(f"Web API 服务启动在 http://0.0.0.0:{args.port}") + app.run(host='0.0.0.0', port=args.port, debug=False)