mirror of
https://gitcode.com/JianFeeeee/TrulyMEM-TrueHumanMEM.git
synced 2026-09-20 00:48:52 +00:00
多用户系统 + Admin 角色权限 + Web/TUI 同步 + 构建集成
本轮实现功能: 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. 静态页面模板(登录/设置/首次引导)
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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!"
|
||||
@ -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!"
|
||||
@ -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!"
|
||||
@ -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
|
||||
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!"
|
||||
|
||||
@ -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={},
|
||||
@ -90,3 +99,47 @@ exe = EXE(
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
|
||||
# ——— 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,
|
||||
)
|
||||
|
||||
41
core/activity_recorder.py
Normal file
41
core/activity_recorder.py
Normal file
@ -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
|
||||
@ -86,5 +86,43 @@ 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()
|
||||
|
||||
@ -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):
|
||||
@ -520,6 +545,122 @@ class EmbeddedGraphDB:
|
||||
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):
|
||||
"""关闭数据库连接"""
|
||||
if self.conn:
|
||||
|
||||
135
core/migrate.py
Normal file
135
core/migrate.py
Normal file
@ -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()}")
|
||||
@ -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", {})
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
1501
static/graph.html
Normal file
1501
static/graph.html
Normal file
File diff suppressed because it is too large
Load Diff
1076
static/index.html
Normal file
1076
static/index.html
Normal file
File diff suppressed because it is too large
Load Diff
228
templates/login.html
Normal file
228
templates/login.html
Normal file
@ -0,0 +1,228 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 - TrulyMEM</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: #0a0a1a;
|
||||
color: #ffffff;
|
||||
overflow: hidden;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:
|
||||
radial-gradient(2px 2px at 20px 30px, #eee, transparent),
|
||||
radial-gradient(2px 2px at 40px 70px, rgba(255,255,255,0.8), transparent),
|
||||
radial-gradient(1px 1px at 90px 40px, #fff, transparent),
|
||||
radial-gradient(1px 1px at 130px 80px, rgba(255,255,255,0.6), transparent),
|
||||
radial-gradient(2px 2px at 160px 30px, #ddd, transparent);
|
||||
background-repeat: repeat;
|
||||
background-size: 200px 100px;
|
||||
animation: twinkle 5s ease-in-out infinite alternate;
|
||||
z-index: 0;
|
||||
}
|
||||
@keyframes twinkle {
|
||||
0% { opacity: 0.5; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
.login-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 400px;
|
||||
padding: 40px;
|
||||
background: rgba(10, 10, 26, 0.9);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(100, 100, 255, 0.3);
|
||||
box-shadow:
|
||||
0 0 20px rgba(68, 136, 255, 0.2),
|
||||
0 0 60px rgba(68, 136, 255, 0.1),
|
||||
inset 0 0 20px rgba(68, 136, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.login-title {
|
||||
text-align: center;
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
color: #4488ff;
|
||||
text-shadow: 0 0 10px rgba(68, 136, 255, 0.5);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.login-subtitle {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #8888aa;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #aaaacc;
|
||||
font-size: 14px;
|
||||
}
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: rgba(20, 20, 40, 0.8);
|
||||
border: 1px solid rgba(100, 100, 255, 0.3);
|
||||
border-radius: 6px;
|
||||
color: #ffffff;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #4488ff;
|
||||
box-shadow: 0 0 10px rgba(68, 136, 255, 0.3);
|
||||
}
|
||||
.form-group input::placeholder {
|
||||
color: #555577;
|
||||
}
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: linear-gradient(135deg, rgba(68, 136, 255, 0.3), rgba(68, 136, 255, 0.1));
|
||||
border: 1px solid rgba(68, 136, 255, 0.5);
|
||||
border-radius: 6px;
|
||||
color: #ffffff;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.login-btn:hover {
|
||||
background: linear-gradient(135deg, rgba(68, 136, 255, 0.5), rgba(68, 136, 255, 0.3));
|
||||
box-shadow: 0 0 15px rgba(68, 136, 255, 0.4);
|
||||
}
|
||||
.login-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.login-btn .spinner {
|
||||
display: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: #ffffff;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.login-btn.loading .spinner {
|
||||
display: block;
|
||||
}
|
||||
.login-btn.loading span {
|
||||
visibility: hidden;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: translate(-50%, -50%) rotate(360deg); }
|
||||
}
|
||||
.error-message {
|
||||
display: none;
|
||||
margin-top: 15px;
|
||||
padding: 12px;
|
||||
background: rgba(255, 68, 68, 0.15);
|
||||
border: 1px solid rgba(255, 68, 68, 0.4);
|
||||
border-radius: 6px;
|
||||
color: #ff6b6b;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
.error-message.show {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<h1 class="login-title">记忆星图</h1>
|
||||
<p class="login-subtitle">TrulyMEM - 登录以继续</p>
|
||||
|
||||
<form id="loginForm">
|
||||
<div class="form-group">
|
||||
<label for="username">用户名</label>
|
||||
<input type="text" id="username" name="username" placeholder="请输入用户名" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<input type="password" id="password" name="password" placeholder="请输入密码" required>
|
||||
</div>
|
||||
<button type="submit" class="login-btn" id="loginBtn">
|
||||
<span>登 录</span>
|
||||
<div class="spinner"></div>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="error-message" id="errorMsg"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const loginForm = document.getElementById('loginForm');
|
||||
const loginBtn = document.getElementById('loginBtn');
|
||||
const errorMsg = document.getElementById('errorMsg');
|
||||
|
||||
loginForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
errorMsg.classList.remove('show');
|
||||
loginBtn.classList.add('loading');
|
||||
loginBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
window.location.href = '/graph.html';
|
||||
} else {
|
||||
errorMsg.textContent = data.error || '登录失败';
|
||||
errorMsg.classList.add('show');
|
||||
}
|
||||
} catch (err) {
|
||||
errorMsg.textContent = '网络错误,请重试';
|
||||
errorMsg.classList.add('show');
|
||||
} finally {
|
||||
loginBtn.classList.remove('loading');
|
||||
loginBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
544
templates/settings.html
Normal file
544
templates/settings.html
Normal file
@ -0,0 +1,544 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>设置 - TrulyMEM</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: #0a0a1a;
|
||||
color: #ffffff;
|
||||
min-height: 100vh;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
body::before {
|
||||
content: ''; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background:
|
||||
radial-gradient(2px 2px at 20px 30px, #eee, transparent),
|
||||
radial-gradient(2px 2px at 40px 70px, rgba(255,255,255,0.8), transparent),
|
||||
radial-gradient(1px 1px at 90px 40px, #fff, transparent);
|
||||
background-repeat: repeat;
|
||||
background-size: 200px 100px;
|
||||
animation: twinkle 5s ease-in-out infinite alternate;
|
||||
z-index: 0;
|
||||
}
|
||||
@keyframes twinkle { 0% { opacity: 0.5; } 100% { opacity: 1; } }
|
||||
.settings-container {
|
||||
position: relative; z-index: 1; width: 480px; padding: 40px;
|
||||
background: rgba(10, 10, 26, 0.9);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(100, 100, 255, 0.3);
|
||||
box-shadow: 0 0 20px rgba(68, 136, 255, 0.2), 0 0 60px rgba(68, 136, 255, 0.1), inset 0 0 20px rgba(68, 136, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.settings-title {
|
||||
text-align: center; font-size: 26px; margin-bottom: 25px;
|
||||
color: #4488ff; text-shadow: 0 0 10px rgba(68, 136, 255, 0.5);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 16px; color: #8888cc; margin: 20px 0 15px;
|
||||
border-bottom: 1px solid rgba(100, 100, 255, 0.2);
|
||||
padding-bottom: 6px; letter-spacing: 1px;
|
||||
}
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-group label { display: block; margin-bottom: 6px; color: #aaaacc; font-size: 14px; }
|
||||
.form-group input {
|
||||
width: 100%; padding: 10px 14px;
|
||||
background: rgba(20, 20, 40, 0.8);
|
||||
border: 1px solid rgba(100, 100, 255, 0.3);
|
||||
border-radius: 6px; color: #ffffff;
|
||||
font-family: 'Courier New', monospace; font-size: 14px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.form-group input:focus {
|
||||
outline: none; border-color: #4488ff; box-shadow: 0 0 10px rgba(68, 136, 255, 0.3);
|
||||
}
|
||||
.form-group input::placeholder { color: #555577; }
|
||||
.toggle-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 0; border-bottom: 1px solid rgba(100, 100, 255, 0.1);
|
||||
}
|
||||
.toggle-label { color: #ccccee; font-size: 14px; }
|
||||
.toggle-desc { color: #7777aa; font-size: 12px; margin-top: 2px; }
|
||||
.toggle-switch {
|
||||
position: relative; width: 48px; height: 26px; cursor: pointer; flex-shrink: 0;
|
||||
}
|
||||
.toggle-switch input { display: none; }
|
||||
.toggle-slider {
|
||||
position: absolute; inset: 0;
|
||||
background: rgba(60, 60, 80, 0.8);
|
||||
border-radius: 13px; transition: all 0.3s;
|
||||
border: 1px solid rgba(100, 100, 255, 0.2);
|
||||
}
|
||||
.toggle-slider::after {
|
||||
content: ''; position: absolute; width: 20px; height: 20px;
|
||||
left: 2px; bottom: 2px; background: #6666aa;
|
||||
border-radius: 50%; transition: all 0.3s;
|
||||
}
|
||||
.toggle-switch input:checked + .toggle-slider {
|
||||
background: rgba(68, 136, 255, 0.4);
|
||||
border-color: rgba(68, 136, 255, 0.6);
|
||||
}
|
||||
.toggle-switch input:checked + .toggle-slider::after {
|
||||
left: 24px; background: #4488ff;
|
||||
}
|
||||
.btn {
|
||||
width: 100%; padding: 12px;
|
||||
background: linear-gradient(135deg, rgba(68, 136, 255, 0.3), rgba(68, 136, 255, 0.1));
|
||||
border: 1px solid rgba(68, 136, 255, 0.5);
|
||||
border-radius: 6px; color: #ffffff;
|
||||
font-family: 'Courier New', monospace; font-size: 14px;
|
||||
cursor: pointer; transition: all 0.3s; margin-top: 8px;
|
||||
}
|
||||
.btn:hover {
|
||||
background: linear-gradient(135deg, rgba(68, 136, 255, 0.5), rgba(68, 136, 255, 0.3));
|
||||
box-shadow: 0 0 15px rgba(68, 136, 255, 0.4);
|
||||
}
|
||||
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.back-link {
|
||||
display: block; text-align: center; margin-top: 20px; color: #6666aa;
|
||||
text-decoration: none; font-size: 13px; transition: color 0.3s;
|
||||
}
|
||||
.back-link:hover { color: #4488ff; }
|
||||
.success-msg, .error-msg {
|
||||
display: none; margin-top: 12px; padding: 10px;
|
||||
border-radius: 6px; font-size: 13px; text-align: center;
|
||||
}
|
||||
.success-msg { background: rgba(68, 255, 136, 0.15); border: 1px solid rgba(68, 255, 136, 0.4); color: #6bff9b; }
|
||||
.error-msg { background: rgba(255, 68, 68, 0.15); border: 1px solid rgba(255, 68, 68, 0.4); color: #ff6b6b; }
|
||||
.success-msg.show, .error-msg.show { display: block; }
|
||||
.tui-status {
|
||||
text-align: center; font-size: 12px; color: #666688; margin-top: 5px;
|
||||
}
|
||||
/* 用户管理样式 */
|
||||
.user-section { margin-top: 20px; }
|
||||
.user-table {
|
||||
width: 100%; border-collapse: collapse; margin-top: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.user-table th {
|
||||
text-align: left; padding: 8px; color: #8888cc;
|
||||
border-bottom: 1px solid rgba(100, 100, 255, 0.2);
|
||||
}
|
||||
.user-table td {
|
||||
padding: 8px; border-bottom: 1px solid rgba(100, 100, 255, 0.1);
|
||||
color: #ccccee;
|
||||
}
|
||||
.btn-small {
|
||||
padding: 4px 10px; font-size: 12px;
|
||||
background: rgba(255, 68, 68, 0.2);
|
||||
border: 1px solid rgba(255, 68, 68, 0.4);
|
||||
border-radius: 4px; color: #ff6b6b;
|
||||
cursor: pointer; transition: all 0.3s;
|
||||
}
|
||||
.btn-small:hover {
|
||||
background: rgba(255, 68, 68, 0.4);
|
||||
}
|
||||
.btn-small:disabled {
|
||||
opacity: 0.4; cursor: not-allowed;
|
||||
}
|
||||
.btn-add {
|
||||
margin-top: 10px; padding: 8px 16px;
|
||||
background: rgba(68, 136, 255, 0.2);
|
||||
border: 1px solid rgba(68, 136, 255, 0.4);
|
||||
border-radius: 6px; color: #4488ff;
|
||||
cursor: pointer; font-size: 13px; transition: all 0.3s;
|
||||
}
|
||||
.btn-add:hover {
|
||||
background: rgba(68, 136, 255, 0.4);
|
||||
}
|
||||
/* 弹窗样式 */
|
||||
.modal-overlay {
|
||||
display: none; position: fixed; top: 0; left: 0;
|
||||
width: 100%; height: 100%; background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 1000; align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-overlay.show { display: flex; }
|
||||
.modal {
|
||||
background: rgba(10, 10, 26, 0.95);
|
||||
border: 1px solid rgba(100, 100, 255, 0.3);
|
||||
border-radius: 12px; padding: 30px; width: 400px;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 18px; color: #4488ff; margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.modal .form-group { margin-bottom: 15px; }
|
||||
.modal .btn {
|
||||
margin-top: 15px;
|
||||
}
|
||||
.modal .btn-cancel {
|
||||
background: rgba(100, 100, 100, 0.2);
|
||||
border-color: rgba(100, 100, 100, 0.4);
|
||||
margin-top: 10px;
|
||||
}
|
||||
.modal .btn-cancel:hover {
|
||||
background: rgba(100, 100, 100, 0.4);
|
||||
}
|
||||
.current-user {
|
||||
color: #4488ff; font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="settings-container">
|
||||
<h1 class="settings-title">⚙ 设置</h1>
|
||||
|
||||
<!-- 修改密码 -->
|
||||
<div class="section-title">🔑 修改密码</div>
|
||||
<form id="passwordForm">
|
||||
<div class="form-group">
|
||||
<label for="current_password">当前密码</label>
|
||||
<input type="password" id="current_password" placeholder="输入当前密码" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new_password">新密码</label>
|
||||
<input type="password" id="new_password" placeholder="至少 6 位" required minlength="6">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">确认新密码</label>
|
||||
<input type="password" id="confirm_password" placeholder="再次输入新密码" required>
|
||||
</div>
|
||||
<button type="submit" class="btn" id="changePwdBtn">更 新 密 码</button>
|
||||
</form>
|
||||
|
||||
<!-- TUI 服务控制 -->
|
||||
<div class="section-title">🖥 TUI 终端服务</div>
|
||||
<div class="toggle-row">
|
||||
<div>
|
||||
<div class="toggle-label">启用终端 TUI 服务</div>
|
||||
<div class="toggle-desc">控制终端文本界面是否允许连接</div>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="enableTuiToggle">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="tui-status" id="tuiStatus">状态加载中...</div>
|
||||
|
||||
<!-- 保存结果提示 -->
|
||||
<div class="success-msg" id="successMsg"></div>
|
||||
<div class="error-msg" id="errorMsg"></div>
|
||||
|
||||
<!-- 用户管理(仅管理员可见) -->
|
||||
<div id="adminSection" style="display:none;">
|
||||
<div class="section-title">👥 用户管理</div>
|
||||
<div class="user-section">
|
||||
<table class="user-table" id="userTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th>
|
||||
<th>角色</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="userTableBody">
|
||||
<!-- 用户列表将在这里动态生成 -->
|
||||
</tbody>
|
||||
</table>
|
||||
<button class="btn-add" id="addUserBtn">+ 添加用户</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/graph.html" class="back-link">← 返回星图</a>
|
||||
</div>
|
||||
|
||||
<!-- 添加用户弹窗 -->
|
||||
<div class="modal-overlay" id="addUserModal">
|
||||
<div class="modal">
|
||||
<div class="modal-title">添加用户</div>
|
||||
<form id="addUserForm">
|
||||
<div class="form-group">
|
||||
<label for="new_username">用户名</label>
|
||||
<input type="text" id="new_username" placeholder="输入新用户名" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new_user_password">密码</label>
|
||||
<input type="password" id="new_user_password" placeholder="至少 6 位" required minlength="6">
|
||||
</div>
|
||||
<button type="submit" class="btn" id="confirmAddBtn">添加</button>
|
||||
<button type="button" class="btn btn-cancel" id="cancelAddBtn">取消</button>
|
||||
</form>
|
||||
<div class="success-msg" id="addUserSuccess"></div>
|
||||
<div class="error-msg" id="addUserError"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentUser = '';
|
||||
let isAdmin = false;
|
||||
|
||||
// 初始化:获取用户信息和配置
|
||||
async function init() {
|
||||
// 获取当前用户信息(含角色)
|
||||
try {
|
||||
const resp = await fetch('/api/userinfo');
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
currentUser = data.username;
|
||||
isAdmin = data.is_admin;
|
||||
|
||||
// 仅管理员显示用户管理区域
|
||||
if (isAdmin) {
|
||||
document.getElementById('adminSection').style.display = 'block';
|
||||
loadUsers();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取用户信息失败:', e);
|
||||
}
|
||||
|
||||
// 加载配置
|
||||
loadConfig();
|
||||
}
|
||||
|
||||
// 加载当前配置
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const resp = await fetch('/api/settings/config');
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
document.getElementById('enableTuiToggle').checked = data.enable_tui !== false;
|
||||
document.getElementById('tuiStatus').textContent =
|
||||
data.enable_tui ? '✅ TUI 服务已启用' : '⏹️ TUI 服务已禁用';
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('tuiStatus').textContent = '⚠️ 无法加载配置';
|
||||
}
|
||||
}
|
||||
|
||||
// 加载用户列表
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const resp = await fetch('/api/admin/users');
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
const tbody = document.getElementById('userTableBody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
data.users.forEach(user => {
|
||||
const tr = document.createElement('tr');
|
||||
const isCurrentUser = user.username === currentUser;
|
||||
const roleBadge = user.role === 'admin'
|
||||
? '<span style="color:#ffaa44;font-weight:bold;">管理员</span>'
|
||||
: '<span style="color:#8888cc;">用户</span>';
|
||||
tr.innerHTML = `
|
||||
<td class="${isCurrentUser ? 'current-user' : ''}">
|
||||
${user.username} ${isCurrentUser ? '(当前)' : ''}
|
||||
</td>
|
||||
<td>${roleBadge}</td>
|
||||
<td>${new Date(user.created_at).toLocaleString('zh-CN')}</td>
|
||||
<td>
|
||||
<button class="btn-small"
|
||||
onclick="deleteUser(${user.id}, '${user.username}')"
|
||||
${isCurrentUser ? 'disabled' : ''}>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载用户列表失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
async function deleteUser(userId, username) {
|
||||
if (!confirm(`确定要删除用户 "${username}" 吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/admin/users/${userId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
showSuccess('用户已删除');
|
||||
loadUsers();
|
||||
} else {
|
||||
showError(data.error || '删除失败');
|
||||
}
|
||||
} catch (e) {
|
||||
showError('网络错误');
|
||||
}
|
||||
}
|
||||
|
||||
// 显示添加用户弹窗
|
||||
document.getElementById('addUserBtn').addEventListener('click', () => {
|
||||
document.getElementById('addUserModal').classList.add('show');
|
||||
hideAddUserMessages();
|
||||
});
|
||||
|
||||
// 隐藏弹窗
|
||||
document.getElementById('cancelAddBtn').addEventListener('click', () => {
|
||||
document.getElementById('addUserModal').classList.remove('show');
|
||||
document.getElementById('addUserForm').reset();
|
||||
});
|
||||
|
||||
// 添加用户
|
||||
document.getElementById('addUserForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const username = document.getElementById('new_username').value.trim();
|
||||
const password = document.getElementById('new_user_password').value;
|
||||
|
||||
if (!username || !password) {
|
||||
showAddUserError('用户名和密码不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
showAddUserError('密码长度至少 6 位');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('confirmAddBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '添加中...';
|
||||
hideAddUserMessages();
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
showAddUserSuccess('✅ 用户添加成功');
|
||||
setTimeout(() => {
|
||||
document.getElementById('addUserModal').classList.remove('show');
|
||||
document.getElementById('addUserForm').reset();
|
||||
loadUsers();
|
||||
}, 1500);
|
||||
} else {
|
||||
showAddUserError(data.error || '添加失败');
|
||||
}
|
||||
} catch (e) {
|
||||
showAddUserError('网络错误');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '添加';
|
||||
}
|
||||
});
|
||||
|
||||
function showAddUserSuccess(msg) {
|
||||
const el = document.getElementById('addUserSuccess');
|
||||
el.textContent = msg;
|
||||
el.classList.add('show');
|
||||
document.getElementById('addUserError').classList.remove('show');
|
||||
}
|
||||
|
||||
function showAddUserError(msg) {
|
||||
const el = document.getElementById('addUserError');
|
||||
el.textContent = msg;
|
||||
el.classList.add('show');
|
||||
document.getElementById('addUserSuccess').classList.remove('show');
|
||||
}
|
||||
|
||||
function hideAddUserMessages() {
|
||||
document.getElementById('addUserSuccess').classList.remove('show');
|
||||
document.getElementById('addUserError').classList.remove('show');
|
||||
}
|
||||
|
||||
init();
|
||||
|
||||
// TUI 开关
|
||||
document.getElementById('enableTuiToggle').addEventListener('change', async function() {
|
||||
const enable = this.checked;
|
||||
document.getElementById('tuiStatus').textContent = '🔄 更新中...';
|
||||
try {
|
||||
const resp = await fetch('/api/settings/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enable_tui: enable })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
document.getElementById('tuiStatus').textContent =
|
||||
enable ? '✅ TUI 服务已启用' : '⏹️ TUI 服务已禁用';
|
||||
showSuccess('设置已保存');
|
||||
} else {
|
||||
document.getElementById('tuiStatus').textContent = '⚠️ 更新失败';
|
||||
this.checked = !enable;
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('tuiStatus').textContent = '⚠️ 网络错误';
|
||||
this.checked = !enable;
|
||||
}
|
||||
});
|
||||
|
||||
// 修改密码
|
||||
document.getElementById('passwordForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const current = document.getElementById('current_password').value;
|
||||
const newPwd = document.getElementById('new_password').value;
|
||||
const confirm = document.getElementById('confirm_password').value;
|
||||
|
||||
if (newPwd !== confirm) {
|
||||
showError('两次密码输入不一致');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('changePwdBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '更新中...';
|
||||
|
||||
hideMessages();
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/change-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
current_password: current,
|
||||
new_password: newPwd,
|
||||
confirm_password: confirm
|
||||
})
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
showSuccess('✅ 密码已更新');
|
||||
document.getElementById('current_password').value = '';
|
||||
document.getElementById('new_password').value = '';
|
||||
document.getElementById('confirm_password').value = '';
|
||||
} else {
|
||||
showError(data.error || '修改失败');
|
||||
}
|
||||
} catch (e) {
|
||||
showError('网络错误');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '更 新 密 码';
|
||||
}
|
||||
});
|
||||
|
||||
function showSuccess(msg) {
|
||||
const el = document.getElementById('successMsg');
|
||||
el.textContent = msg;
|
||||
el.classList.add('show');
|
||||
document.getElementById('errorMsg').classList.remove('show');
|
||||
setTimeout(() => el.classList.remove('show'), 3000);
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
const el = document.getElementById('errorMsg');
|
||||
el.textContent = msg;
|
||||
el.classList.add('show');
|
||||
document.getElementById('successMsg').classList.remove('show');
|
||||
}
|
||||
|
||||
function hideMessages() {
|
||||
document.getElementById('successMsg').classList.remove('show');
|
||||
document.getElementById('errorMsg').classList.remove('show');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
170
templates/setup.html
Normal file
170
templates/setup.html
Normal file
@ -0,0 +1,170 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>首次设置 - TrulyMEM</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Courier New', monospace;
|
||||
background: #0a0a1a;
|
||||
color: #ffffff;
|
||||
overflow: hidden;
|
||||
width: 100vw; height: 100vh;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
body::before {
|
||||
content: '';
|
||||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background:
|
||||
radial-gradient(2px 2px at 20px 30px, #eee, transparent),
|
||||
radial-gradient(2px 2px at 40px 70px, rgba(255,255,255,0.8), transparent),
|
||||
radial-gradient(1px 1px at 90px 40px, #fff, transparent),
|
||||
radial-gradient(1px 1px at 130px 80px, rgba(255,255,255,0.6), transparent),
|
||||
radial-gradient(2px 2px at 160px 30px, #ddd, transparent);
|
||||
background-repeat: repeat;
|
||||
background-size: 200px 100px;
|
||||
animation: twinkle 5s ease-in-out infinite alternate;
|
||||
z-index: 0;
|
||||
}
|
||||
@keyframes twinkle { 0% { opacity: 0.5; } 100% { opacity: 1; } }
|
||||
.setup-container {
|
||||
position: relative; z-index: 1; width: 420px; padding: 40px;
|
||||
background: rgba(10, 10, 26, 0.9);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(100, 100, 255, 0.3);
|
||||
box-shadow: 0 0 20px rgba(68, 136, 255, 0.2), 0 0 60px rgba(68, 136, 255, 0.1), inset 0 0 20px rgba(68, 136, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.setup-title {
|
||||
text-align: center; font-size: 26px; margin-bottom: 8px;
|
||||
color: #4488ff; text-shadow: 0 0 10px rgba(68, 136, 255, 0.5);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
.setup-subtitle {
|
||||
text-align: center; font-size: 14px; color: #8888aa; margin-bottom: 8px;
|
||||
}
|
||||
.setup-hint {
|
||||
text-align: center; font-size: 12px; color: #666688; margin-bottom: 25px;
|
||||
}
|
||||
.form-group { margin-bottom: 18px; }
|
||||
.form-group label { display: block; margin-bottom: 6px; color: #aaaacc; font-size: 14px; }
|
||||
.form-group input {
|
||||
width: 100%; padding: 12px 16px;
|
||||
background: rgba(20, 20, 40, 0.8);
|
||||
border: 1px solid rgba(100, 100, 255, 0.3);
|
||||
border-radius: 6px; color: #ffffff;
|
||||
font-family: 'Courier New', monospace; font-size: 14px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.form-group input:focus {
|
||||
outline: none; border-color: #4488ff; box-shadow: 0 0 10px rgba(68, 136, 255, 0.3);
|
||||
}
|
||||
.form-group input::placeholder { color: #555577; }
|
||||
.setup-btn {
|
||||
width: 100%; padding: 14px;
|
||||
background: linear-gradient(135deg, rgba(68, 136, 255, 0.3), rgba(68, 136, 255, 0.1));
|
||||
border: 1px solid rgba(68, 136, 255, 0.5);
|
||||
border-radius: 6px; color: #ffffff;
|
||||
font-family: 'Courier New', monospace; font-size: 16px;
|
||||
cursor: pointer; transition: all 0.3s;
|
||||
letter-spacing: 1px; margin-top: 5px;
|
||||
}
|
||||
.setup-btn:hover {
|
||||
background: linear-gradient(135deg, rgba(68, 136, 255, 0.5), rgba(68, 136, 255, 0.3));
|
||||
box-shadow: 0 0 15px rgba(68, 136, 255, 0.4);
|
||||
}
|
||||
.setup-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.setup-btn .spinner {
|
||||
display: none; width: 16px; height: 16px;
|
||||
border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff;
|
||||
border-radius: 50%; animation: spin 0.6s linear infinite;
|
||||
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
|
||||
}
|
||||
.setup-btn.loading .spinner { display: block; }
|
||||
.setup-btn.loading span { visibility: hidden; }
|
||||
@keyframes spin { to { transform: translate(-50%, -50%) rotate(360deg); } }
|
||||
.error-message {
|
||||
display: none; margin-top: 12px; padding: 10px;
|
||||
background: rgba(255, 68, 68, 0.15);
|
||||
border: 1px solid rgba(255, 68, 68, 0.4);
|
||||
border-radius: 6px; color: #ff6b6b; font-size: 13px; text-align: center;
|
||||
}
|
||||
.error-message.show { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="setup-container">
|
||||
<h1 class="setup-title">🚀 首次设置</h1>
|
||||
<p class="setup-subtitle">TrulyMEM Web 管理界面</p>
|
||||
<p class="setup-hint">创建管理员账户,用于登录 Web 管理界面</p>
|
||||
|
||||
<form id="setupForm">
|
||||
<div class="form-group">
|
||||
<label for="username">用户名</label>
|
||||
<input type="text" id="username" name="username" placeholder="设置管理员用户名" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<input type="password" id="password" name="password" placeholder="至少 6 位密码" required minlength="6">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirm">确认密码</label>
|
||||
<input type="password" id="confirm" name="confirm_password" placeholder="再次输入密码" required>
|
||||
</div>
|
||||
<button type="submit" class="setup-btn" id="setupBtn">
|
||||
<span>创 建 账 户</span>
|
||||
<div class="spinner"></div>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="error-message" id="errorMsg"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const setupForm = document.getElementById('setupForm');
|
||||
const setupBtn = document.getElementById('setupBtn');
|
||||
const errorMsg = document.getElementById('errorMsg');
|
||||
|
||||
setupForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
const confirm = document.getElementById('confirm').value;
|
||||
|
||||
if (password !== confirm) {
|
||||
errorMsg.textContent = '两次密码输入不一致';
|
||||
errorMsg.classList.add('show');
|
||||
return;
|
||||
}
|
||||
|
||||
errorMsg.classList.remove('show');
|
||||
setupBtn.classList.add('loading');
|
||||
setupBtn.disabled = true;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, confirm_password: confirm })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
window.location.href = '/graph.html';
|
||||
} else {
|
||||
errorMsg.textContent = data.error || '创建失败';
|
||||
errorMsg.classList.add('show');
|
||||
}
|
||||
} catch (err) {
|
||||
errorMsg.textContent = '网络错误,请重试';
|
||||
errorMsg.classList.add('show');
|
||||
} finally {
|
||||
setupBtn.classList.remove('loading');
|
||||
setupBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
147
ui/app.py
147
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,
|
||||
@ -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", "未知错误")
|
||||
|
||||
142
ui/login_screen.py
Normal file
142
ui/login_screen.py
Normal file
@ -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)}")
|
||||
@ -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:
|
||||
|
||||
@ -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,10 +84,75 @@ class ConfigSection(Vertical):
|
||||
yield l6
|
||||
yield Input(value=str(self._config.memory_update_max), placeholder="10", id="memory-update-max")
|
||||
|
||||
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)
|
||||
@ -103,6 +169,19 @@ class ConfigSection(Vertical):
|
||||
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
|
||||
|
||||
@ -117,6 +196,13 @@ class ConfigSection(Vertical):
|
||||
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
|
||||
|
||||
@ -152,5 +260,15 @@ class ConfigSection(Vertical):
|
||||
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
|
||||
|
||||
747
web_api.py
Normal file
747
web_api.py
Normal file
@ -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/<int:user_id>', 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)
|
||||
Reference in New Issue
Block a user