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

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