多用户系统 + 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

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)