多用户系统 + 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:
root
2026-04-28 10:37:57 +08:00
parent 46008c14c5
commit b4456a9c5b
22 changed files with 5488 additions and 183 deletions

8
.gitignore vendored
View File

@ -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/

View File

@ -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!"
echo "Done!"

View File

@ -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 importsTUI + 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!"
echo "Build finished successfully!"

View File

@ -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!"
echo "Build finished successfully!"

View File

@ -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!"

View File

@ -8,6 +8,7 @@ project_root = os.path.dirname(os.path.abspath(SPEC))
sys.path.insert(0, project_root)
datas = []
# UI 样式
if os.path.exists(os.path.join(project_root, 'ui', 'styles')):
for root, dirs, files in os.walk(os.path.join(project_root, 'ui', 'styles')):
for f in files:
@ -15,6 +16,7 @@ if os.path.exists(os.path.join(project_root, 'ui', 'styles')):
dst = os.path.join('ui', 'styles', os.path.relpath(src, os.path.join(project_root, 'ui', 'styles')))
datas.append((src, dst))
# Prompt 模板
if os.path.exists(os.path.join(project_root, 'core', 'prompts', 'templates')):
for root, dirs, files in os.walk(os.path.join(project_root, 'core', 'prompts', 'templates')):
for f in files:
@ -22,45 +24,52 @@ if os.path.exists(os.path.join(project_root, 'core', 'prompts', 'templates')):
dst = os.path.join('core', 'prompts', 'templates', os.path.relpath(src, os.path.join(project_root, 'core', 'prompts', 'templates')))
datas.append((src, dst))
# Web 静态文件
if os.path.exists(os.path.join(project_root, 'static')):
for root, dirs, files in os.walk(os.path.join(project_root, 'static')):
for f in files:
src = os.path.join(root, f)
dst = os.path.join('static', os.path.relpath(src, os.path.join(project_root, 'static')))
datas.append((src, dst))
# Web 模板
if os.path.exists(os.path.join(project_root, 'templates')):
for root, dirs, files in os.walk(os.path.join(project_root, 'templates')):
for f in files:
src = os.path.join(root, f)
dst = os.path.join('templates', os.path.relpath(src, os.path.join(project_root, 'templates')))
datas.append((src, dst))
# Web API 脚本(以便子进程模式回退使用)
web_api_src = os.path.join(project_root, 'web_api.py')
if os.path.exists(web_api_src):
datas.append((web_api_src, '.'))
# ——— TUI 主二进制 ———
a = Analysis(
['trulymem_entry.py'],
pathex=[],
pathex=[project_root],
binaries=[],
datas=datas,
hiddenimports=[
'textual',
'textual.app',
'textual.widgets',
'textual.css',
'openai',
'openai._client',
'textual', 'textual.app', 'textual.widgets', 'textual.css',
'openai', 'openai._client',
'neo4j',
'sqlite3',
'graph_memory_tui',
'core',
'core.embedded_db',
'core.graph_client',
'core.tool_executor',
'core.tool_limiter',
'core.tools',
'core.tools.memory_tools',
'core.prompts',
'core.prompts.prompt_manager',
'ui',
'ui.app',
'ui.models',
'ui.models.message',
'ui.models.config',
'ui.models.log_entry',
'ui.widgets',
'ui.widgets.left_panel',
'ui.widgets.right_panel',
'ui.widgets.input_box',
'ui.widgets.message_history',
'ui.widgets.status_bar',
'core', 'core.embedded_db', 'core.graph_client',
'core.tool_executor', 'core.tool_limiter',
'core.tools', 'core.tools.memory_tools',
'core.prompts', 'core.prompts.prompt_manager',
'core.server', 'core.client',
'core.migrate',
'ui', 'ui.app', 'ui.login_screen',
'ui.models', 'ui.models.message', 'ui.models.config', 'ui.models.log_entry',
'ui.widgets', 'ui.widgets.left_panel', 'ui.widgets.right_panel',
'ui.widgets.input_box', 'ui.widgets.message_history', 'ui.widgets.status_bar',
'ui.handlers',
'ui.services',
'ui.services.config_manager',
'ui.services', 'ui.services.config_manager', 'ui.services.config_service',
'web_api',
'flask', 'flask_cors',
],
hookspath=[],
hooksconfig={},
@ -89,4 +98,48 @@ exe = EXE(
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
)
# ——— 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
View 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

View File

@ -85,6 +85,44 @@ class BackendClient:
)
response = self._server.send(packet)
return response.body.get("data", {})
def get_web_users(self) -> list:
"""获取 Web 用户列表"""
packet = Packet(
id=self._next_id(),
type=PacketType.GET_WEB_USERS,
body={}
)
return self._server.send(packet).body.get("users", [])
def set_web_user(self, username: str, password: str) -> Dict:
"""设置 Web 用户"""
packet = Packet(
id=self._next_id(),
type=PacketType.SET_WEB_USER,
body={"username": username, "password": password}
)
return self._server.send(packet).body.get("data", {"success": False})
def get_full_config(self) -> Dict:
"""获取完整配置"""
packet = Packet(
id=self._next_id(),
type=PacketType.GET_CONFIG,
body={}
)
response = self._server.send(packet)
return response.body if response.body else {"api_config": {}, "tool_limits": {}}
def report_web_status(self, running: bool, port: int = 4096) -> Dict:
"""向后端报告 Web 服务运行状态"""
packet = Packet(
id=self._next_id(),
type=PacketType.GET_WEB_SERVICE_STATUS,
body={"running": running, "port": port}
)
response = self._server.send(packet)
return response.body if response.body else {"success": False}
def shutdown(self) -> None:
self._server.shutdown()

View File

@ -4,6 +4,7 @@
"""
import sqlite3
import hashlib
import json
from datetime import datetime
from pathlib import Path
@ -86,6 +87,30 @@ class EmbeddedGraphDB:
""")
cursor.execute("CREATE INDEX idx_chat_created ON chat_records(created_at)")
# 创建 Web 用户表(支持多用户隔离)
cursor.execute("""
CREATE TABLE IF NOT EXISTS web_users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
config_path TEXT,
db_path TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# 检查并添加新字段(用于旧数据库迁移)
cursor.execute("PRAGMA table_info(web_users)")
columns = [row[1] for row in cursor.fetchall()]
if 'config_path' not in columns:
cursor.execute("ALTER TABLE web_users ADD COLUMN config_path TEXT")
if 'db_path' not in columns:
cursor.execute("ALTER TABLE web_users ADD COLUMN db_path TEXT")
if 'role' not in columns:
cursor.execute("ALTER TABLE web_users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'")
self.conn.commit()
def ensure_constraints(self):
@ -519,6 +544,122 @@ class EmbeddedGraphDB:
cursor.execute("DELETE FROM chat_records")
self.conn.commit()
return {"cleared": True}
def set_web_user(self, username: str, password: str, base_dir: str = None, role: str = 'user') -> Dict:
"""设置或更新 Web 登录用户。password 是明文,自动哈希存储。
自动创建用户目录并设置 config_path 和 db_path。
role: 'admin''user',默认 'user'"""
if not username or not password:
return {"success": False, "error": "用户名和密码不能为空"}
if role not in ('admin', 'user'):
return {"success": False, "error": "角色无效 (admin/user)"}
import hashlib
from pathlib import Path
password_hash = hashlib.sha256(password.encode()).hexdigest()
# 确定基础目录
if base_dir is None:
base_dir = Path.home() / ".trulymem"
else:
base_dir = Path(base_dir)
# 创建用户目录
user_dir = base_dir / username
user_dir.mkdir(parents=True, exist_ok=True)
# 设置用户文件路径
config_path = str(user_dir / "config.json")
db_path = str(user_dir / f"{username}_graph.db")
cursor = self.conn.cursor()
# 如果是第一个用户,强制设为 admin
if self.get_web_users_count() == 0:
role = 'admin'
cursor.execute("""
INSERT INTO web_users (username, password_hash, role, config_path, db_path)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(username) DO UPDATE SET
password_hash = excluded.password_hash,
role = CASE WHEN web_users.role = 'admin' THEN 'admin' ELSE excluded.role END,
config_path = COALESCE(web_users.config_path, excluded.config_path),
db_path = COALESCE(web_users.db_path, excluded.db_path),
updated_at = CURRENT_TIMESTAMP
""", (username, password_hash, role, config_path, db_path))
self.conn.commit()
return {"success": True, "username": username, "role": role, "config_path": config_path, "db_path": db_path}
def get_web_users(self) -> List[Dict]:
"""获取所有 Web 用户列表"""
cursor = self.conn.cursor()
cursor.execute("SELECT id, username, role, config_path, db_path, created_at, updated_at FROM web_users ORDER BY created_at ASC")
users = []
for row in cursor.fetchall():
users.append({
"id": row['id'],
"username": row['username'],
"role": row['role'],
"config_path": row['config_path'],
"db_path": row['db_path'],
"created_at": row['created_at'],
"updated_at": row['updated_at']
})
return users
def get_web_user(self, username: str) -> Optional[Dict]:
"""获取单个 Web 用户信息"""
cursor = self.conn.cursor()
cursor.execute("""
SELECT id, username, role, config_path, db_path, created_at, updated_at
FROM web_users WHERE username = ?
""", (username,))
row = cursor.fetchone()
if row:
return {
"id": row['id'],
"username": row['username'],
"role": row['role'],
"config_path": row['config_path'],
"db_path": row['db_path'],
"created_at": row['created_at'],
"updated_at": row['updated_at']
}
return None
def is_admin(self, username: str) -> bool:
"""检查用户是否为管理员"""
user = self.get_web_user(username)
return user is not None and user.get('role') == 'admin'
def delete_web_user(self, username: str) -> Dict:
"""删除 Web 用户(同时保留文件目录)"""
if not username:
return {"success": False, "error": "用户名不能为空"}
cursor = self.conn.cursor()
cursor.execute("DELETE FROM web_users WHERE username = ?", (username,))
self.conn.commit()
if cursor.rowcount > 0:
return {"success": True, "username": username}
return {"success": False, "error": "用户不存在"}
def get_web_users_count(self) -> int:
"""获取 Web 用户数量 (用于判断是否需要首次设置)"""
cursor = self.conn.cursor()
cursor.execute("SELECT COUNT(*) as cnt FROM web_users")
row = cursor.fetchone()
return row['cnt'] if row else 0
def verify_web_user(self, username: str, password: str) -> bool:
"""验证 Web 用户登录"""
import hashlib
password_hash = hashlib.sha256(password.encode()).hexdigest()
cursor = self.conn.cursor()
cursor.execute("""
SELECT id FROM web_users
WHERE username = ? AND password_hash = ?
""", (username, password_hash))
return cursor.fetchone() is not None
def close(self):
"""关闭数据库连接"""

135
core/migrate.py Normal file
View 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()}")

View File

@ -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", {})

View File

@ -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

File diff suppressed because it is too large Load Diff

1076
static/index.html Normal file

File diff suppressed because it is too large Load Diff

228
templates/login.html Normal file
View 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
View 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
View 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>

155
ui/app.py
View File

@ -1,9 +1,13 @@
import asyncio
import sys
import subprocess
import signal
from pathlib import Path
from textual.app import App, ComposeResult
from textual.binding import Binding
from core import BackendServer, BackendClient
from core import BackendServer
from core.client import BackendClient
from .models.message import Message
@ -27,6 +31,10 @@ class GraphMemoryApp(App):
self._backend_server = backend_server
self._backend_client = BackendClient(backend_server) if backend_server else None
self._api_configured = False
self._web_process: subprocess.Popen | None = None
self._web_running = False
self.login_user = None # 当前登录用户
self.login_user_info = None # 当前登录用户信息
def compose(self) -> ComposeResult:
from .widgets.left_panel import LeftPanel
@ -56,18 +64,59 @@ class GraphMemoryApp(App):
yield StatusBar()
def on_mount(self) -> None:
from .widgets.status_bar import StatusBar
from .widgets.message_history import MessageHistory
from .login_screen import LoginScreen
from core.migrate import need_migration, is_migrated
# 检查是否需要登录
migrated = is_migrated()
need_login = migrated or not need_migration()
if need_login and not self.login_user:
# 显示登录界面
self.push_screen(LoginScreen())
return
# 已登录或无需登录,继续初始化
self._init_after_login()
def on_login_success(self, username: str, user_info: dict) -> None:
"""登录成功后调用"""
self.login_user = username
self.login_user_info = user_info
# 更新 config section 的 admin 权限
from .widgets.config_section import ConfigSection
try:
is_admin = user_info.get('role') == 'admin'
config_section = self.query_one(ConfigSection)
config_section.set_admin(is_admin)
except Exception:
pass
# 重新初始化后端
self._init_after_login()
def _init_after_login(self) -> None:
"""登录后初始化"""
from .widgets.status_bar import StatusBar
from .widgets.message_history import MessageHistory
status_bar = self.query_one(StatusBar)
if not self._backend_server:
from .widgets.message_history import MessageHistory
history = self.query_one(MessageHistory)
error = Message(role="assistant", content="后端未初始化")
history.add_message(error)
status_bar.set_api_status(False)
return
# 如果已登录,重新初始化后端服务器以使用用户的数据库
if self.login_user:
from core.server import BackendServer
# 创建新的后端服务器(使用用户的数据库)
self._backend_server = BackendServer(username=self.login_user)
self._backend_client = BackendClient(self._backend_server)
self._backend_server.start()
status = self._backend_client.get_status()
data = status.get("data", {})
self._api_configured = data.get("config", {}).get("api_key", "") != ""
@ -82,13 +131,80 @@ class GraphMemoryApp(App):
message = Message(role=msg["role"], content=msg["content"])
history.add_message(message)
welcome = Message(
role="assistant",
content=f"系统就绪\nAPI Key: {'已配置' if self._api_configured else '未配置'}\n\n输入消息开始对话"
)
role_label = "管理员" if self.login_user_info and self.login_user_info.get('role') == 'admin' else "用户"
welcome_msg = f"系统就绪\n用户: {self.login_user or '默认'} ({role_label})\n"
welcome_msg += f"API Key: {'已配置' if self._api_configured else '未配置'}\n\n输入消息开始对话"
welcome = Message(role="assistant", content=welcome_msg)
history.add_message(welcome)
def _start_web_server(self, port: int = 4096) -> None:
"""启动 Web 服务器子进程(支持打包和开发模式)"""
if self._web_process and self._web_process.poll() is None:
self.notify("Web 服务已在运行", title="提示")
return
def _find_web_binary() -> str:
"""查找 Web 二进制或脚本路径"""
# 1. PyInstaller 打包环境下查找同目录的 trulymem-web 二进制
if getattr(sys, 'frozen', False):
base = Path(sys._MEIPASS).parent
for name in ['trulymem-web', 'trulymem-web.exe']:
candidate = base / name
if candidate.exists():
return str(candidate)
# 2. 开发模式:同目录下的 web_api.py
web_script = Path(__file__).parent.parent / "web_api.py"
if web_script.exists():
return str(web_script)
# 3. 打包环境回退:从 MEIPASS 读取 web_api.py 数据文件
if getattr(sys, 'frozen', False):
bundled = Path(sys._MEIPASS) / "web_api.py"
if bundled.exists():
return str(bundled)
return ""
target = _find_web_binary()
if not target:
self.notify("找不到 Web 服务文件web_api.py", severity="error")
return
try:
if target.endswith('.py'):
cmd = [sys.executable, target, "--port", str(port)]
else:
cmd = [target, "--port", str(port)]
self._web_process = subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
self._web_running = True
if self._backend_client:
self._backend_client.report_web_status(True, port)
self.notify(f"Web 服务已启动 → http://0.0.0.0:{port}", title="Web 服务")
except Exception as e:
self.notify(f"启动 Web 服务失败: {e}", severity="error")
def _stop_web_server(self) -> None:
"""停止 Web 服务器子进程"""
if self._web_process:
try:
self._web_process.terminate()
self._web_process.wait(timeout=5)
except:
try:
self._web_process.kill()
except:
pass
self._web_process = None
self._web_running = False
if self._backend_client:
self._backend_client.report_web_status(False, 0)
self.notify("Web 服务已停止", title="Web 服务")
def on_unmount(self) -> None:
# 停止 Web 服务
self._stop_web_server()
if self._backend_client:
self._backend_client.shutdown()
@ -226,6 +342,16 @@ class GraphMemoryApp(App):
"memory_update_max": config.memory_update_max,
}
# 保存 Web 用户(如果用户名和密码都不为空)
if config.web_username and config.web_password:
try:
await asyncio.get_event_loop().run_in_executor(
None, self._backend_client.set_web_user,
config.web_username, config.web_password
)
except Exception as e:
self.notify(f"保存 Web 用户失败: {e}", severity="warning")
try:
result = await asyncio.get_event_loop().run_in_executor(
None,
@ -234,17 +360,17 @@ class GraphMemoryApp(App):
tool_limits=tool_limits
)
)
if result.get("success"):
self._api_configured = bool(config.api_key)
status_bar.set_api_status(self._api_configured)
settings_result = await asyncio.get_event_loop().run_in_executor(
None,
lambda: self._backend_client.get_settings()
)
settings_data = settings_result.get("data", {})
try:
config_section = self.query_one(ConfigSection)
api_cfg = settings_data.get("api_config", {})
@ -257,10 +383,19 @@ class GraphMemoryApp(App):
task_update_max=tool_lmts.get("task_update_max", 5),
memory_query_max=tool_lmts.get("memory_query_max", 20),
memory_update_max=tool_lmts.get("memory_update_max", 10),
enable_web=api_cfg.get("enable_web", False),
web_port=api_cfg.get("web_port", 4096),
enable_tui=api_cfg.get("enable_tui", True),
))
except Exception:
pass
# 管理 Web 服务
if config.enable_web:
self._start_web_server(config.web_port)
else:
self._stop_web_server()
self.notify("✅ 配置已保存并生效", title="配置成功", severity="information")
else:
error = result.get("error", "未知错误")

142
ui/login_screen.py Normal file
View 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)}")

View File

@ -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:

View File

@ -15,9 +15,10 @@ class ConfigSection(Vertical):
self.is_tool_limits = is_tool_limits
super().__init__()
def __init__(self, config: AppConfig | None = None, **kwargs):
def __init__(self, config: AppConfig | None = None, is_admin: bool = True, **kwargs):
super().__init__(**kwargs)
self._config = config or AppConfig()
self._is_admin = is_admin
def compose(self) -> ComposeResult:
title = Static("━━ 配置 ━━", classes="config-title")
@ -83,26 +84,104 @@ class ConfigSection(Vertical):
yield l6
yield Input(value=str(self._config.memory_update_max), placeholder="10", id="memory-update-max")
hint = Static("按Enter保存配置", classes="config-hint")
hint.can_focus = False
yield hint
sep2 = Static("", classes="config-sep")
sep2.can_focus = False
yield sep2
from textual.containers import Vertical
# Web 登录 — 仅 admin 可见
with Vertical(id="admin-web-login-section"):
web_title = Static("━━ Web 登录 ━━", classes="config-title")
web_title.can_focus = False
yield web_title
label_web_user = Static("用户名:", classes="config-label")
label_web_user.can_focus = False
yield label_web_user
yield Input(
value=self._config.web_username,
placeholder="admin",
id="web-username-input"
)
label_web_pwd = Static("密码:", classes="config-label")
label_web_pwd.can_focus = False
yield label_web_pwd
yield Input(
value=self._config.web_password,
placeholder="修改密码",
id="web-password-input",
password=True
)
hint = Static("按Enter保存配置", classes="config-hint")
hint.can_focus = False
yield hint
sep3 = Static("", classes="config-sep")
sep3.can_focus = False
yield sep3
# Web 服务 — 仅 admin 可见
with Vertical(id="admin-web-service-section"):
ws_title = Static("━━ Web 服务 ━━", classes="config-title")
ws_title.can_focus = False
yield ws_title
ws_hint = Static("在侧边栏启用后将自动启动 Web 管理界面", classes="config-hint")
ws_hint.can_focus = False
yield ws_hint
from textual.widgets import Checkbox
yield Checkbox(
"启用 Web 服务",
value=self._config.enable_web,
id="enable-web-checkbox"
)
label_web_port = Static("端口:", classes="config-label")
label_web_port.can_focus = False
yield label_web_port
yield Input(
value=str(self._config.web_port),
placeholder="4096",
id="web-port-input",
type="integer"
)
# 默认隐藏 admin 区域,等 login 后决定是否显示
self._apply_admin_visibility()
def on_mount(self) -> None:
try:
api_key = self.query_one("#api-key-input", Input)
model = self.query_one("#model-input", Input)
base_url = self.query_one("#base-url-input", Input)
api_key.tab_index = 0
model.tab_index = 1
base_url.tab_index = 2
if self._config.api_key:
api_key.value = self._config.api_key
if self._config.model:
model.value = self._config.model
if self._config.base_url:
base_url.value = self._config.base_url
web_user = self.query_one("#web-username-input", Input)
web_pwd = self.query_one("#web-password-input", Input)
web_user.tab_index = 7
web_pwd.tab_index = 8
from textual.widgets import Checkbox
web_port = self.query_one("#web-port-input", Input)
try:
web_checkbox = self.query_one("#enable-web-checkbox", Checkbox)
except:
pass
web_port.tab_index = 9
except Exception:
pass
@ -111,12 +190,19 @@ class ConfigSection(Vertical):
api_key_input = self.query_one("#api-key-input", Input)
model_input = self.query_one("#model-input", Input)
base_url_input = self.query_one("#base-url-input", Input)
persona_update = self.query_one("#persona-update-max", Input)
task_update = self.query_one("#task-update-max", Input)
memory_query = self.query_one("#memory-query-max", Input)
memory_update = self.query_one("#memory-update-max", Input)
web_username = self.query_one("#web-username-input", Input)
web_password = self.query_one("#web-password-input", Input)
from textual.widgets import Checkbox
web_checkbox = self.query_one("#enable-web-checkbox", Checkbox)
web_port_input = self.query_one("#web-port-input", Input)
self._config = AppConfig(
api_key=api_key_input.value,
model=model_input.value,
@ -125,6 +211,10 @@ class ConfigSection(Vertical):
task_update_max=int(task_update.value or 5),
memory_query_max=int(memory_query.value or 20),
memory_update_max=int(memory_update.value or 10),
web_username=web_username.value,
web_password=web_password.value,
enable_web=web_checkbox.value,
web_port=int(web_port_input.value) if web_port_input.value else 4096,
)
# 先发送 API 配置更新is_tool_limits=False
@ -134,6 +224,24 @@ class ConfigSection(Vertical):
except Exception:
pass
def set_admin(self, is_admin: bool) -> None:
"""设置是否 admin 模式,动态显示/隐藏 admin 区域"""
self._is_admin = is_admin
self._apply_admin_visibility()
def _apply_admin_visibility(self) -> None:
"""根据 _is_admin 显示/隐藏 admin 专用区域"""
try:
login_section = self.query_one("#admin-web-login-section")
login_section.styles.display = "block" if self._is_admin else "none"
except Exception:
pass
try:
service_section = self.query_one("#admin-web-service-section")
service_section.styles.display = "block" if self._is_admin else "none"
except Exception:
pass
def get_config(self) -> AppConfig:
return self._config
@ -147,10 +255,20 @@ class ConfigSection(Vertical):
api_key_input.value = config.api_key
model_input.value = config.model
base_url_input.value = config.base_url
self.query_one("#persona-update-max", Input).value = str(config.persona_update_max)
self.query_one("#task-update-max", Input).value = str(config.task_update_max)
self.query_one("#memory-query-max", Input).value = str(config.memory_query_max)
self.query_one("#memory-update-max", Input).value = str(config.memory_update_max)
self.query_one("#web-username-input", Input).value = config.web_username
self.query_one("#web-password-input", Input).value = config.web_password
from textual.widgets import Checkbox
try:
self.query_one("#enable-web-checkbox", Checkbox).value = config.enable_web
except:
pass
self.query_one("#web-port-input", Input).value = str(config.web_port)
except Exception:
pass

747
web_api.py Normal file
View 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)