refactor: restructure to core/ + ui/ with multi-threaded backend

This commit is contained in:
root
2026-04-12 18:15:55 +08:00
parent c13d3f671c
commit 83906a6985
64 changed files with 5269 additions and 571 deletions

1
ui/widgets/__init__.py Normal file
View File

@ -0,0 +1 @@
"""UI Widgets for Graph Memory TUI"""

View 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

View 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
View 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
View 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)

View 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()

View 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

View 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
View 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
View 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}")