mirror of
https://gitcode.com/JianFeeeee/TrulyMEM-TrueHumanMEM.git
synced 2026-10-15 12:15:32 +00:00
refactor: restructure to core/ + ui/ with multi-threaded backend
This commit is contained in:
1
ui/widgets/__init__.py
Normal file
1
ui/widgets/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""UI Widgets for Graph Memory TUI"""
|
||||
128
ui/widgets/config_section.py
Normal file
128
ui/widgets/config_section.py
Normal file
@ -0,0 +1,128 @@
|
||||
"""配置区组件"""
|
||||
|
||||
from textual.containers import Vertical
|
||||
from textual.widgets import Static, Input, Collapsible
|
||||
from textual.app import ComposeResult
|
||||
from textual.message import Message
|
||||
from ..models.config import AppConfig
|
||||
|
||||
|
||||
class ConfigSection(Vertical):
|
||||
"""可折叠配置区"""
|
||||
|
||||
class ConfigChanged(Message):
|
||||
"""配置变更事件"""
|
||||
def __init__(self, config: AppConfig) -> None:
|
||||
self.config = config
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, config: AppConfig | None = None, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._config = config or AppConfig()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""构建配置区"""
|
||||
# 直接显示配置,不使用Collapsible
|
||||
title = Static("━━ 配置 ━━", classes="config-title")
|
||||
title.can_focus = False
|
||||
yield title
|
||||
|
||||
label1 = Static("API Key:", classes="config-label")
|
||||
label1.can_focus = False
|
||||
yield label1
|
||||
|
||||
yield Input(
|
||||
value=self._config.api_key,
|
||||
placeholder="sk-xxxxxxxxxxxxx",
|
||||
id="api-key-input",
|
||||
password=True
|
||||
)
|
||||
|
||||
label2 = Static("模型:", classes="config-label")
|
||||
label2.can_focus = False
|
||||
yield label2
|
||||
|
||||
yield Input(
|
||||
value=self._config.model,
|
||||
placeholder="deepseek-chat",
|
||||
id="model-input"
|
||||
)
|
||||
|
||||
label3 = Static("Base URL:", classes="config-label")
|
||||
label3.can_focus = False
|
||||
yield label3
|
||||
|
||||
yield Input(
|
||||
value=self._config.base_url,
|
||||
placeholder="https://api.deepseek.com",
|
||||
id="base-url-input"
|
||||
)
|
||||
|
||||
hint = Static("按Enter保存配置", classes="config-hint")
|
||||
hint.can_focus = False
|
||||
yield hint
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""组件挂载时设置Tab顺序并加载配置"""
|
||||
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)
|
||||
|
||||
# 设置Tab索引
|
||||
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
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_input_changed(self, event: Input.Changed) -> None:
|
||||
"""处理输入变更事件"""
|
||||
# 防抖:只在用户停止输入时更新
|
||||
pass # 不在输入时实时更新,避免卡顿
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
"""处理输入提交事件(按Enter或Tab)"""
|
||||
# 只在提交时更新配置
|
||||
try:
|
||||
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)
|
||||
|
||||
# 更新配置
|
||||
self._config = AppConfig(
|
||||
api_key=api_key_input.value,
|
||||
model=model_input.value,
|
||||
base_url=base_url_input.value
|
||||
)
|
||||
|
||||
# 发送配置变更事件
|
||||
self.post_message(self.ConfigChanged(self._config))
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
def get_config(self) -> AppConfig:
|
||||
"""获取当前配置"""
|
||||
return self._config
|
||||
|
||||
def set_config(self, config: AppConfig) -> None:
|
||||
"""设置配置"""
|
||||
self._config = config
|
||||
try:
|
||||
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)
|
||||
|
||||
api_key_input.value = config.api_key
|
||||
model_input.value = config.model
|
||||
base_url_input.value = config.base_url
|
||||
except Exception:
|
||||
pass
|
||||
57
ui/widgets/cypher_query_box.py
Normal file
57
ui/widgets/cypher_query_box.py
Normal file
@ -0,0 +1,57 @@
|
||||
"""Cypher查询框组件"""
|
||||
|
||||
from textual.containers import Container, Horizontal
|
||||
from textual.widgets import Static, TextArea, Button
|
||||
from textual.app import ComposeResult
|
||||
from textual.message import Message
|
||||
|
||||
|
||||
class CypherQueryBox(Container):
|
||||
"""快捷Cypher查询输入框"""
|
||||
|
||||
class ExecuteQuery(Message):
|
||||
"""执行查询事件"""
|
||||
def __init__(self, query: str) -> None:
|
||||
self.query = query
|
||||
super().__init__()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""构建查询框"""
|
||||
yield Static("F4:执行Cypher查询", classes="query-title")
|
||||
yield TextArea(
|
||||
placeholder="输入Cypher查询语句...",
|
||||
id="cypher-textarea"
|
||||
)
|
||||
with Horizontal(classes="query-buttons"):
|
||||
yield Button("执行", id="execute-button", variant="primary")
|
||||
yield Button("清空", id="clear-button")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""处理按钮点击"""
|
||||
if event.button.id == "execute-button":
|
||||
self._execute_query()
|
||||
elif event.button.id == "clear-button":
|
||||
self._clear_query()
|
||||
|
||||
def on_key(self, event) -> None:
|
||||
"""处理按键事件"""
|
||||
if event.key == "enter" and event.ctrl:
|
||||
event.stop()
|
||||
self._execute_query()
|
||||
|
||||
def _execute_query(self) -> None:
|
||||
"""执行查询"""
|
||||
textarea = self.query_one("#cypher-textarea", TextArea)
|
||||
query = textarea.text.strip()
|
||||
if query:
|
||||
self.post_message(self.ExecuteQuery(query))
|
||||
|
||||
def _clear_query(self) -> None:
|
||||
"""清空查询"""
|
||||
textarea = self.query_one("#cypher-textarea", TextArea)
|
||||
textarea.clear()
|
||||
|
||||
def focus(self) -> None:
|
||||
"""聚焦查询框"""
|
||||
textarea = self.query_one("#cypher-textarea", TextArea)
|
||||
textarea.focus()
|
||||
50
ui/widgets/input_box.py
Normal file
50
ui/widgets/input_box.py
Normal file
@ -0,0 +1,50 @@
|
||||
"""输入框组件"""
|
||||
|
||||
from textual.containers import Container
|
||||
from textual.widgets import Input
|
||||
from textual.message import Message
|
||||
|
||||
|
||||
class InputBox(Container):
|
||||
"""输入框组件"""
|
||||
|
||||
class SendMessage(Message):
|
||||
"""发送消息事件"""
|
||||
def __init__(self, content: str) -> None:
|
||||
self.content = content
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._history: list[str] = []
|
||||
self._history_index: int = -1
|
||||
|
||||
def compose(self):
|
||||
"""构建输入框"""
|
||||
yield Input(
|
||||
placeholder="输入消息... (Enter发送)",
|
||||
id="input-textarea"
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""组件挂载时"""
|
||||
# 设置焦点
|
||||
input_widget = self.query_one(Input)
|
||||
input_widget.focus()
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
"""处理输入提交事件"""
|
||||
content = event.value.strip()
|
||||
if content:
|
||||
# 保存到历史
|
||||
self._history.append(content)
|
||||
self._history_index = len(self._history)
|
||||
# 发送消息
|
||||
self.post_message(self.SendMessage(content))
|
||||
# 清空输入框
|
||||
event.input.value = ""
|
||||
|
||||
def focus(self) -> None:
|
||||
"""聚焦输入框"""
|
||||
input_widget = self.query_one(Input)
|
||||
input_widget.focus()
|
||||
23
ui/widgets/left_panel.py
Normal file
23
ui/widgets/left_panel.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""左侧面板"""
|
||||
|
||||
from textual.containers import Container
|
||||
from textual.app import ComposeResult
|
||||
from .message_history import MessageHistory
|
||||
from .input_box import InputBox
|
||||
|
||||
|
||||
class LeftPanel(Container):
|
||||
"""左侧主面板"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""构建左侧面板"""
|
||||
yield MessageHistory()
|
||||
yield InputBox()
|
||||
|
||||
def get_message_history(self) -> MessageHistory:
|
||||
"""获取消息历史组件"""
|
||||
return self.query_one(MessageHistory)
|
||||
|
||||
def get_input_box(self) -> InputBox:
|
||||
"""获取输入框组件"""
|
||||
return self.query_one(InputBox)
|
||||
57
ui/widgets/message_history.py
Normal file
57
ui/widgets/message_history.py
Normal file
@ -0,0 +1,57 @@
|
||||
"""消息历史组件"""
|
||||
|
||||
from textual.containers import ScrollableContainer
|
||||
from textual.message import Message
|
||||
from .message_widget import MessageWidget
|
||||
from ..models.message import Message as MessageModel
|
||||
|
||||
|
||||
class MessageHistory(ScrollableContainer):
|
||||
"""消息历史区域"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._messages: list[MessageModel] = []
|
||||
|
||||
def compose(self):
|
||||
"""构建消息历史"""
|
||||
for message in self._messages:
|
||||
yield MessageWidget(message)
|
||||
|
||||
def add_message(self, message: MessageModel) -> None:
|
||||
"""添加新消息"""
|
||||
self._messages.append(message)
|
||||
# 添加新组件
|
||||
message_widget = MessageWidget(message)
|
||||
self.mount(message_widget)
|
||||
# 滚动到最新消息
|
||||
self.scroll_to_widget(message_widget, animate=False)
|
||||
|
||||
def update_latest_message(self, content: str) -> None:
|
||||
"""更新最新消息的内容"""
|
||||
if self.children:
|
||||
latest_widget = self.children[-1]
|
||||
if isinstance(latest_widget, MessageWidget):
|
||||
latest_widget.update_content(content)
|
||||
# 确保滚动到最新消息
|
||||
self.scroll_to_widget(latest_widget, animate=False)
|
||||
|
||||
def clear_messages(self) -> None:
|
||||
"""清空消息历史"""
|
||||
self._messages.clear()
|
||||
# 移除所有子组件
|
||||
for child in self.children:
|
||||
child.remove()
|
||||
|
||||
def get_latest_message(self) -> MessageModel | None:
|
||||
"""获取最新消息"""
|
||||
if self._messages:
|
||||
return self._messages[-1]
|
||||
return None
|
||||
|
||||
def toggle_latest_tool_details(self) -> None:
|
||||
"""切换最新消息的工具详情"""
|
||||
if self.children:
|
||||
latest_widget = self.children[-1]
|
||||
if isinstance(latest_widget, MessageWidget):
|
||||
latest_widget.toggle_tool_details()
|
||||
108
ui/widgets/message_widget.py
Normal file
108
ui/widgets/message_widget.py
Normal file
@ -0,0 +1,108 @@
|
||||
"""消息组件"""
|
||||
|
||||
from textual.containers import Container, Vertical
|
||||
from textual.widgets import Static
|
||||
from textual.message import Message
|
||||
from textual.css.query import NoMatches
|
||||
from ..models.message import Message as MessageModel
|
||||
|
||||
|
||||
class MessageWidget(Container):
|
||||
"""单条消息组件"""
|
||||
|
||||
def __init__(self, message: MessageModel, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._message = message
|
||||
self._show_tool_details = False
|
||||
self._content_widget = None # 保存内容组件的引用
|
||||
self._tool_details_container = None # 保存工具详情容器引用
|
||||
|
||||
def compose(self):
|
||||
"""构建消息组件"""
|
||||
# 消息头
|
||||
role_emoji = "🟠" if self._message.role == "user" else "🔵"
|
||||
timestamp_str = self._message.timestamp.strftime("%H:%M:%S")
|
||||
yield Static(
|
||||
f"{role_emoji} {timestamp_str}",
|
||||
classes="message-header"
|
||||
)
|
||||
|
||||
# 消息内容 - 保存引用以便后续更新
|
||||
self._content_widget = Static(
|
||||
self._message.content,
|
||||
classes="message-content"
|
||||
)
|
||||
yield self._content_widget
|
||||
|
||||
# 工具调用指示器
|
||||
if self._message.tool_calls:
|
||||
tool_count = len(self._message.tool_calls)
|
||||
toggle_hint = "(F3折叠)" if self._show_tool_details else "(F3展开)"
|
||||
yield Static(
|
||||
f"[工具:{tool_count}次] {toggle_hint}",
|
||||
classes="tool-indicator"
|
||||
)
|
||||
|
||||
# 工具调用详情容器 - 始终创建,但根据状态显示/隐藏
|
||||
self._tool_details_container = Vertical(classes="tool-details")
|
||||
with self._tool_details_container:
|
||||
for i, tool_call in enumerate(self._message.tool_calls, 1):
|
||||
yield Static(
|
||||
f"工具 {i}: {tool_call.name}",
|
||||
classes="tool-name"
|
||||
)
|
||||
yield Static(
|
||||
f"参数: {tool_call.arguments}",
|
||||
classes="tool-args"
|
||||
)
|
||||
|
||||
# 显示执行结果
|
||||
if self._message.tool_results:
|
||||
for result in self._message.tool_results:
|
||||
if result.tool_call_id == tool_call.id:
|
||||
# 显示完整结果,不截断
|
||||
result_text = result.result
|
||||
# 如果结果太长,只显示前1000字符,但提供完整信息
|
||||
if len(result_text) > 1000:
|
||||
result_text = result_text[:1000] + f"\n... (共{len(result.result)}字符,按F3查看完整内容)"
|
||||
yield Static(
|
||||
f"结果: {result_text}",
|
||||
classes="tool-result"
|
||||
)
|
||||
|
||||
# 根据状态设置初始显示/隐藏
|
||||
if not self._show_tool_details:
|
||||
self._tool_details_container.styles.display = "none"
|
||||
|
||||
def update_content(self, new_content: str) -> None:
|
||||
"""更新消息内容"""
|
||||
self._message.content = new_content
|
||||
if self._content_widget:
|
||||
self._content_widget.update(new_content)
|
||||
|
||||
def toggle_tool_details(self) -> None:
|
||||
"""切换工具详情显示状态"""
|
||||
if self._message.tool_calls and self._tool_details_container:
|
||||
self._show_tool_details = not self._show_tool_details
|
||||
|
||||
# 切换显示/隐藏
|
||||
if self._show_tool_details:
|
||||
self._tool_details_container.styles.display = "block"
|
||||
else:
|
||||
self._tool_details_container.styles.display = "none"
|
||||
|
||||
# 更新指示器文字
|
||||
self._update_indicator()
|
||||
|
||||
# 刷新布局
|
||||
self.refresh(layout=True)
|
||||
|
||||
def _update_indicator(self) -> None:
|
||||
"""更新工具调用指示器文字"""
|
||||
try:
|
||||
indicator = self.query_one(".tool-indicator", Static)
|
||||
tool_count = len(self._message.tool_calls)
|
||||
toggle_hint = "(F3折叠)" if self._show_tool_details else "(F3展开)"
|
||||
indicator.update(f"[工具:{tool_count}次] {toggle_hint}")
|
||||
except NoMatches:
|
||||
pass
|
||||
66
ui/widgets/operation_log.py
Normal file
66
ui/widgets/operation_log.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""操作日志组件"""
|
||||
|
||||
from datetime import datetime
|
||||
from textual.containers import ScrollableContainer
|
||||
from textual.widgets import Static
|
||||
from ..models.log_entry import LogEntry
|
||||
|
||||
|
||||
class OperationLog(ScrollableContainer):
|
||||
"""图操作日志区域"""
|
||||
|
||||
def __init__(self, max_entries: int = 100, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._logs: list[LogEntry] = []
|
||||
self._max_entries = max_entries
|
||||
|
||||
def compose(self):
|
||||
"""构建日志区域"""
|
||||
if not self._logs:
|
||||
yield Static("暂无操作日志", classes="log-empty")
|
||||
|
||||
def add_log(self, entry: LogEntry) -> None:
|
||||
"""添加日志(插入到顶部)"""
|
||||
# 限制日志数量
|
||||
if len(self._logs) >= self._max_entries:
|
||||
self._logs.pop()
|
||||
# 移除最旧的组件
|
||||
if self.children:
|
||||
self.children[-1].remove()
|
||||
|
||||
# 插入到列表开头
|
||||
self._logs.insert(0, entry)
|
||||
|
||||
# 创建日志显示组件
|
||||
log_widget = self._create_log_widget(entry)
|
||||
|
||||
# 挂载到顶部
|
||||
self.mount(log_widget, before=0 if self.children else None)
|
||||
|
||||
# 滚动到顶部
|
||||
self.scroll_to(0, animate=False)
|
||||
|
||||
def _create_log_widget(self, entry: LogEntry) -> Static:
|
||||
"""创建日志显示组件"""
|
||||
timestamp_str = entry.timestamp.strftime("%H:%M:%S")
|
||||
text = (
|
||||
f"[{timestamp_str}] {entry.tool_name}\n"
|
||||
f" 参数: {entry.args_summary}\n"
|
||||
f" 结果: {entry.result_summary}\n"
|
||||
f" 耗时: {entry.duration:.2f}s"
|
||||
)
|
||||
return Static(text, classes="log-entry")
|
||||
|
||||
def clear_logs(self) -> None:
|
||||
"""清空日志"""
|
||||
self._logs.clear()
|
||||
for child in self.children:
|
||||
child.remove()
|
||||
# 显示空状态
|
||||
self.mount(Static("暂无操作日志", classes="log-empty"))
|
||||
|
||||
def get_latest_log(self) -> LogEntry | None:
|
||||
"""获取最新日志"""
|
||||
if self._logs:
|
||||
return self._logs[0]
|
||||
return None
|
||||
67
ui/widgets/right_panel.py
Normal file
67
ui/widgets/right_panel.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""右侧面板"""
|
||||
|
||||
from textual.containers import Container, ScrollableContainer
|
||||
from textual.css.query import NoMatches
|
||||
from textual.widgets import Static
|
||||
from textual.app import ComposeResult
|
||||
from .config_section import ConfigSection
|
||||
from .operation_log import OperationLog
|
||||
from .cypher_query_box import CypherQueryBox
|
||||
from ..models.config import AppConfig
|
||||
|
||||
|
||||
class RightPanel(Container):
|
||||
"""右侧边栏"""
|
||||
|
||||
def __init__(self, config: AppConfig | None = None, use_embedded_db: bool = True, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._is_collapsed = False
|
||||
self._config = config or AppConfig()
|
||||
self._use_embedded_db = use_embedded_db
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""构建右侧面板"""
|
||||
yield Static("F2:隐藏侧边栏", classes="sidebar-title")
|
||||
with ScrollableContainer():
|
||||
yield ConfigSection(self._config)
|
||||
yield OperationLog()
|
||||
if not self._use_embedded_db:
|
||||
yield CypherQueryBox()
|
||||
|
||||
def toggle(self) -> None:
|
||||
"""切换折叠/展开"""
|
||||
self._is_collapsed = not self._is_collapsed
|
||||
if self._is_collapsed:
|
||||
self.styles.width = 0
|
||||
self.styles.display = "none"
|
||||
else:
|
||||
self.styles.width = 70
|
||||
self.styles.display = "block"
|
||||
|
||||
def is_collapsed(self) -> bool:
|
||||
"""检查是否折叠"""
|
||||
return self._is_collapsed
|
||||
|
||||
def get_config_section(self) -> ConfigSection:
|
||||
"""获取配置区组件"""
|
||||
return self.query_one(ConfigSection)
|
||||
|
||||
def get_operation_log(self) -> OperationLog:
|
||||
"""获取操作日志组件"""
|
||||
return self.query_one(OperationLog)
|
||||
|
||||
def get_cypher_query_box(self) -> CypherQueryBox | None:
|
||||
"""获取Cypher查询框组件(可能不存在)"""
|
||||
try:
|
||||
return self.query_one(CypherQueryBox)
|
||||
except NoMatches:
|
||||
return None
|
||||
|
||||
def has_cypher_query_box(self) -> bool:
|
||||
"""检查是否存在Cypher查询框"""
|
||||
return not self._use_embedded_db
|
||||
|
||||
def update_title(self) -> None:
|
||||
"""更新标题"""
|
||||
title = self.query_one(Static)
|
||||
title.update("F2:展开侧边栏" if self._is_collapsed else "F2:隐藏侧边栏")
|
||||
27
ui/widgets/status_bar.py
Normal file
27
ui/widgets/status_bar.py
Normal file
@ -0,0 +1,27 @@
|
||||
"""状态栏组件"""
|
||||
|
||||
from textual.widgets import Static
|
||||
from textual.message import Message
|
||||
|
||||
|
||||
class StatusBar(Static):
|
||||
"""底部状态栏"""
|
||||
|
||||
class FocusChanged(Message):
|
||||
"""焦点变更事件"""
|
||||
def __init__(self, focus_name: str) -> None:
|
||||
self.focus_name = focus_name
|
||||
super().__init__()
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._shortcuts = "F1:帮助 F2:侧边栏 F3:工具详情 F4:查询 F5:清屏 F6:退出"
|
||||
self._license_info = "本项目由jianf设计,以GPLv3形式开源"
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""组件挂载时"""
|
||||
self._update_display()
|
||||
|
||||
def _update_display(self) -> None:
|
||||
"""更新显示"""
|
||||
self.update(f"{self._license_info} | {self._shortcuts}")
|
||||
Reference in New Issue
Block a user