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

4
ui/__init__.py Normal file
View File

@ -0,0 +1,4 @@
from .app import GraphMemoryApp
from .models.config import AppConfig
__all__ = ["GraphMemoryApp", "AppConfig"]

279
ui/app.py Normal file
View File

@ -0,0 +1,279 @@
import asyncio
from pathlib import Path
from textual.app import App, ComposeResult
from textual.binding import Binding
from datetime import datetime
from core import BackendServer, BackendClient
from .models.message import Message, ToolCall, ToolResult
from .models.config import AppConfig
from .models.log_entry import LogEntry
from .services.config_manager import ConfigManager
from core import BackendClient
class GraphMemoryApp(App[None]):
CSS_PATH = [
Path(__file__).parent / "styles" / "app.css",
Path(__file__).parent / "styles" / "messages.css",
Path(__file__).parent / "styles" / "components.css",
]
BINDINGS = [
Binding("f1", "show_help", "帮助"),
Binding("f2", "toggle_sidebar", "侧边栏"),
Binding("f3", "toggle_tool_details", "工具详情"),
Binding("f4", "focus_query", "查询"),
Binding("f5", "clear_history", "清屏"),
Binding("f6", "quit", "退出"),
]
def __init__(self, config: AppConfig | None = None, backend_server: BackendServer | None = None, **kwargs):
super().__init__(**kwargs)
self._config_manager = ConfigManager()
if config:
self._config = config
elif self._config_manager.exists():
self._config = self._config_manager.load()
else:
self._config = AppConfig.from_env()
if backend_server:
self._backend_server = backend_server
self._backend_client = BackendClient(backend_server)
else:
self._backend_server: BackendServer | None = None
self._backend_client: BackendClient | None = None
def compose(self) -> ComposeResult:
from .widgets.left_panel import LeftPanel
from .widgets.right_panel import RightPanel
from .widgets.status_bar import StatusBar
yield LeftPanel()
yield RightPanel(self._config, use_embedded_db=True)
yield StatusBar()
def on_mount(self) -> None:
from .widgets.message_history import MessageHistory
history = self.query_one(MessageHistory)
try:
self._backend_server = BackendServer(
db_path="graph_memory.db",
use_embedded_db=True
)
self._backend_server.start(
api_key=self._config.api_key,
base_url=self._config.base_url
)
self._backend_client = BackendClient(self._backend_server)
welcome = Message(
role="assistant",
content="系统初始化成功!\n\n"
f"数据库: 内嵌SQLite (graph_memory.db)\n"
f"API Key: {'已配置' if self._config.api_key else '未配置'}\n\n"
"现在可以开始对话了!",
)
history.add_message(welcome)
except Exception as e:
error = Message(
role="assistant",
content=f"初始化失败: {str(e)}\n\n"
"请检查:\n"
"1. API Key 是否配置\n"
"2. 网络连接是否正常\n\n"
"按F2打开侧边栏配置API Key",
)
history.add_message(error)
def on_unmount(self) -> None:
if self._backend_server:
self._backend_server.shutdown()
def action_show_help(self) -> None:
help_text = """
快捷键:
F1 - 帮助
F2 - 切换侧边栏
F3 - 工具详情
F4 - 查询框
F5 - 清屏
F6 - 退出
输入消<EFBFBD><EFBFBD>后按 Enter 发送
"""
self.notify(help_text, title="帮助", timeout=10)
def action_toggle_sidebar(self) -> None:
from .widgets.right_panel import RightPanel
sidebar = self.query_one(RightPanel)
sidebar.toggle()
sidebar.update_title()
def action_toggle_tool_details(self) -> None:
from .widgets.message_history import MessageHistory
history = self.query_one(MessageHistory)
history.toggle_latest_tool_details()
def action_focus_query(self) -> None:
from .widgets.right_panel import RightPanel
sidebar = self.query_one(RightPanel)
if not sidebar.has_cypher_query_box():
self.notify("查询框仅在 Neo4j 模式下可用", title="提示", timeout=3)
return
if sidebar.is_collapsed():
sidebar.toggle()
sidebar.update_title()
query_box = sidebar.get_cypher_query_box()
if query_box:
query_box.focus()
def action_clear_history(self) -> None:
from .widgets.message_history import MessageHistory
history = self.query_one(MessageHistory)
history.clear_messages()
def on_input_box_send_message(self, event) -> None:
from .widgets.input_box import InputBox
from .widgets.message_history import MessageHistory
from .widgets.right_panel import RightPanel
try:
history = self.query_one(MessageHistory)
user_message = Message(role="user", content=event.content)
history.add_message(user_message)
if not self._config.api_key:
response_msg = Message(
role="assistant",
content="请先配置API Key。\n\n按F2打开侧边栏输入API Key后按Enter保存。",
)
history.add_message(response_msg)
return
processing_msg = Message(role="assistant", content="正在处理...")
history.add_message(processing_msg)
asyncio.create_task(self._process_message_async(event.content))
except Exception as e:
error_msg = Message(role="assistant", content=f"错误: {str(e)}")
history.add_message(error_msg)
async def _process_message_async(self, user_input: str) -> None:
from .widgets.message_history import MessageHistory
from .widgets.right_panel import RightPanel
history = self.query_one(MessageHistory)
log = self.query_one(RightPanel).get_operation_log()
try:
if not self._backend_client:
raise Exception("后端未初始化")
result = await asyncio.get_event_loop().run_in_executor(
None,
lambda: self._backend_client.process_message(user_input)
)
content = result.get("content", "(无回复)")
tool_calls_data = result.get("tool_calls", [])
rejected_tools = result.get("rejected_tools", [])
tool_calls = []
tool_results = []
for tc in tool_calls_data:
tc_obj = ToolCall(
id=tc.get("id", ""),
name=tc.get("name", ""),
arguments=tc.get("arguments", {})
)
tool_calls.append(tc_obj)
tr = ToolResult(
tool_call_id=tc_obj.id,
name=tc_obj.name,
arguments=tc_obj.arguments,
result=tc.get("result", ""),
success=not tc.get("result", "").startswith("工具执行<EFBFBD><EFBFBD><EFBFBD>")
)
tool_results.append(tr)
log_entry = LogEntry(
tool_name=tc_obj.name,
arguments=tc_obj.arguments,
result=tc.get("result", ""),
)
log.add_log(log_entry)
assistant_message = Message(
role="assistant",
content=content,
tool_calls=tool_calls if tool_calls else None,
tool_results=tool_results if tool_results else None
)
history.add_message(assistant_message)
self.refresh()
except Exception as e:
error_msg = str(e)
if "Connection error" in error_msg or "connection" in error_msg.lower():
help_text = """
网络连接错误!可能的原因:
1. API Key 未配置或无效
2. 网络无法访问 API 服务器
3. API 服务器暂时不可用
解决方法:
按 F2 展开侧边栏,检查并配置 API Key
检查网络连接
"""
elif "API Key" in error_msg:
help_text = """
API Key 未配置!
请按以下步骤配置:
1. 按 F2 展开右侧边栏
2. 点击"配置"展开配置区
3. 在 API Key 输入框输入你的密钥
4. 按 Enter 键保存配置
获取 API Key: https://platform.deepseek.com/
"""
else:
help_text = f"\n详细错误: {error_msg}"
error_message = Message(role="assistant", content=f"错误: {error_msg}\n{help_text}")
history.add_message(error_message)
def on_config_section_config_changed(self, event) -> None:
from .widgets.right_panel import RightPanel
self._config = event.config
self._config_manager.save(self._config)
try:
right_panel = self.query_one(RightPanel)
right_panel._config = self._config
except Exception:
pass
if self._backend_client:
self._backend_client.update_config(
api_key=self._config.api_key,
base_url=self._config.base_url
)
self.notify("配置已保存并应用", title="配置")
else:
self.notify("配置已保存,但后端未初始化", title="警告")

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

@ -0,0 +1 @@
"""Event Handlers for Graph Memory TUI"""

View File

@ -0,0 +1,64 @@
"""焦点管理器"""
from textual.app import App
class FocusHandler:
"""焦点管理器"""
# 焦点循环顺序
FOCUS_RING = [
"input-textarea", # 左侧输入框
"api-key-input", # 右侧配置区 API Key
"model-input", # 右侧配置区 Model
"base-url-input", # 右侧配置区 Base URL
"cypher-textarea", # 右侧 Cypher 查询框
]
# 焦点名称映射
FOCUS_NAMES = {
"input-textarea": "Input",
"api-key-input": "Config-API",
"model-input": "Config-Model",
"base-url-input": "Config-URL",
"cypher-textarea": "Query",
}
def __init__(self):
self._current_index = 0
def next_focus(self, app: App) -> None:
"""切换到下一个焦点"""
self._current_index = (self._current_index + 1) % len(self.FOCUS_RING)
widget_id = self.FOCUS_RING[self._current_index]
self._focus_widget(app, widget_id)
def prev_focus(self, app: App) -> None:
"""切换到上一个焦点"""
self._current_index = (self._current_index - 1) % len(self.FOCUS_RING)
widget_id = self.FOCUS_RING[self._current_index]
self._focus_widget(app, widget_id)
def focus_input(self, app: App) -> None:
"""聚焦到输入框"""
self._current_index = 0
self._focus_widget(app, self.FOCUS_RING[0])
def focus_query(self, app: App) -> None:
"""聚焦到查询框"""
self._current_index = len(self.FOCUS_RING) - 1
self._focus_widget(app, self.FOCUS_RING[-1])
def get_current_focus_name(self) -> str:
"""获取当前焦点名称"""
widget_id = self.FOCUS_RING[self._current_index]
return self.FOCUS_NAMES.get(widget_id, "Unknown")
def _focus_widget(self, app: App, widget_id: str) -> None:
"""聚焦到指定组件"""
try:
widget = app.query_one(f"#{widget_id}")
widget.focus()
except Exception:
# 如果找不到组件,回退到输入框
self.focus_input(app)

View File

@ -0,0 +1,68 @@
"""快捷键处理器"""
from textual.app import App
from textual.message import Message
from .focus_handler import FocusHandler
class KeyHandler:
"""快捷键处理器"""
class ShowHelp(Message):
"""显示帮助事件"""
pass
class ToggleSidebar(Message):
"""切换侧边栏事件"""
pass
class ToggleToolDetails(Message):
"""切换工具详情事件"""
pass
class FocusQuery(Message):
"""聚焦查询框事件"""
pass
class ClearHistory(Message):
"""清屏事件"""
pass
class QuitApp(Message):
"""退出应用事件"""
pass
def __init__(self, focus_handler: FocusHandler):
self._focus_handler = focus_handler
def handle_f1(self, app: App) -> None:
"""处理 F1 键 - 显示帮助"""
app.post_message(self.ShowHelp())
def handle_f2(self, app: App) -> None:
"""处理 F2 键 - 切换侧边栏"""
app.post_message(self.ToggleSidebar())
def handle_f3(self, app: App) -> None:
"""处理 F3 键 - 切换工具详情"""
app.post_message(self.ToggleToolDetails())
def handle_f4(self, app: App) -> None:
"""处理 F4 键 - 聚焦查询框"""
app.post_message(self.FocusQuery())
def handle_f5(self, app: App) -> None:
"""处理 F5 键 - 清屏"""
app.post_message(self.ClearHistory())
def handle_f6(self, app: App) -> None:
"""处理 F6 键 - 退出"""
app.post_message(self.QuitApp())
def handle_tab(self, app: App) -> None:
"""处理 Tab 键 - 焦点循环"""
self._focus_handler.next_focus(app)
def handle_shift_tab(self, app: App) -> None:
"""处理 Shift+Tab 键 - 反向焦点循环"""
self._focus_handler.prev_focus(app)

View File

@ -0,0 +1,98 @@
"""消息处理器"""
from datetime import datetime
from typing import TYPE_CHECKING
from ..models.message import Message, ToolCall, ToolResult
if TYPE_CHECKING:
from ..services.chat_service import ChatService
from ..widgets.message_history import MessageHistory
from ..widgets.operation_log import OperationLog
class MessageHandler:
"""消息处理器"""
def __init__(
self,
chat_service: "ChatService",
message_history: "MessageHistory",
operation_log: "OperationLog"
):
self._chat_service = chat_service
self._message_history = message_history
self._operation_log = operation_log
async def handle_user_message(self, content: str) -> None:
"""处理用户消息"""
# 创建用户消息
user_message = Message(
role="user",
content=content,
timestamp=datetime.now()
)
# 添加到历史
self._message_history.add_message(user_message)
# 发送到聊天服务
await self._process_response(content)
async def _process_response(self, user_input: str) -> None:
"""处理响应"""
streaming_message = None
async for event in self._chat_service.send_message(user_input):
if event["type"] == "user_message":
# 用户消息已处理
pass
elif event["type"] == "content_delta":
# 流式内容更新
if streaming_message is None:
# 创建流式消息
streaming_message = Message(
role="assistant",
content="",
timestamp=datetime.now()
)
self._message_history.add_message(streaming_message)
# 更新消息内容
self._message_history.update_latest_message(event["content"])
elif event["type"] == "assistant_message":
# 模型消息完成
if streaming_message:
# 更新最终消息(包含工具调用信息)
streaming_message.content = event["content"]
streaming_message.tool_calls = event.get("tool_calls")
streaming_message.tool_results = event.get("tool_results")
else:
# 如果没有流式消息,直接添加
message = Message(
role="assistant",
content=event["content"],
timestamp=datetime.now(),
tool_calls=event.get("tool_calls"),
tool_results=event.get("tool_results")
)
self._message_history.add_message(message)
elif event["type"] == "tool_call":
# 工具调用开始
pass
elif event["type"] == "tool_result":
# 工具执行结果
log_entry = event["log_entry"]
self._operation_log.add_log(log_entry)
elif event["type"] == "error":
# 错误处理
error_message = Message(
role="assistant",
content=f"错误: {event['error']}",
timestamp=datetime.now()
)
self._message_history.add_message(error_message)

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

@ -0,0 +1 @@
"""Data Models for Graph Memory TUI"""

46
ui/models/config.py Normal file
View File

@ -0,0 +1,46 @@
"""配置数据模型"""
import json
import os
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Optional
@dataclass
class AppConfig:
"""应用配置"""
api_key: str = ""
model: str = "deepseek-chat"
base_url: str = "https://api.deepseek.com"
@classmethod
def from_env(cls) -> "AppConfig":
"""从环境变量加载配置"""
return cls(
api_key=os.getenv("DEEPSEEK_API_KEY", ""),
model=os.getenv("MODEL_NAME", "deepseek-chat"),
base_url=os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com"),
)
@classmethod
def from_file(cls, path: Path) -> "AppConfig":
"""从文件加载配置"""
if not path.exists():
return cls()
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return cls(
api_key=data.get("api_key", ""),
model=data.get("model", "deepseek-chat"),
base_url=data.get("base_url", "https://api.deepseek.com"),
)
def save(self, path: Path) -> None:
"""保存配置到文件"""
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(asdict(self), f, indent=2, ensure_ascii=False)

30
ui/models/log_entry.py Normal file
View File

@ -0,0 +1,30 @@
"""日志条目数据模型"""
from dataclasses import dataclass
from datetime import datetime
from typing import Any, Dict
@dataclass
class LogEntry:
"""日志条目"""
timestamp: datetime
tool_name: str
arguments: Dict[str, Any]
result: str
duration: float
@property
def args_summary(self) -> str:
"""参数摘要截断到50字符"""
args_str = str(self.arguments)
if len(args_str) > 50:
return args_str[:50] + "..."
return args_str
@property
def result_summary(self) -> str:
"""结果摘要截断到100字符"""
if len(self.result) > 100:
return self.result[:100] + "..."
return self.result

33
ui/models/message.py Normal file
View File

@ -0,0 +1,33 @@
"""消息数据模型"""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Dict, List, Literal, Optional, Any
@dataclass
class ToolCall:
"""工具调用"""
id: str
name: str
arguments: Dict[str, Any]
@dataclass
class ToolResult:
"""工具执行结果"""
tool_call_id: str
name: str
arguments: Dict[str, Any]
result: str
success: bool
@dataclass
class Message:
"""消息"""
role: Literal["user", "assistant", "system"]
content: str
timestamp: datetime = field(default_factory=datetime.now)
tool_calls: Optional[List[ToolCall]] = None
tool_results: Optional[List[ToolResult]] = None

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

@ -0,0 +1 @@
"""Business Services for Graph Memory TUI"""

226
ui/services/chat_service.py Normal file
View File

@ -0,0 +1,226 @@
"""聊天服务"""
import asyncio
import json
from datetime import datetime
from typing import AsyncIterator, TYPE_CHECKING, List, Dict, Any
from ..core.imports import GraphMemoryClient
from ..models.message import ToolCall, ToolResult
from .tool_service import ToolService
if TYPE_CHECKING:
from ..core.imports import Neo4jGraph
class ChatService:
"""聊天业务服务"""
def __init__(
self,
graph: "Neo4jGraph",
client: GraphMemoryClient,
tool_service: ToolService
):
self._graph = graph
self._client = client
self._tool_service = tool_service
self._messages: List[Dict[str, Any]] = []
async def send_message(self, user_input: str) -> AsyncIterator[dict]:
"""发送消息并流式返回事件"""
# 1. 发送用户消息事件
yield {
"type": "user_message",
"content": user_input
}
try:
# 2. 第一次API调用
accumulated_content = ""
tool_calls_data = []
# 流式处理响应
async for chunk in self._call_api_stream_async(user_input):
if chunk.get("content_delta"):
accumulated_content += chunk["content_delta"]
yield {
"type": "content_delta",
"content": accumulated_content
}
if chunk.get("tool_calls"):
tool_calls_data = chunk["tool_calls"]
# 3. 如果有工具调用执行并继续调用API
tool_calls = None
tool_results = None
if tool_calls_data:
tool_calls = []
tool_results = []
# 执行所有工具
for tool_call_data in tool_calls_data:
tool_call = ToolCall(
id=tool_call_data["id"],
name=tool_call_data["function"]["name"],
arguments=tool_call_data["function"]["arguments"]
)
tool_calls.append(tool_call)
yield {
"type": "tool_call",
"tool_call": tool_call
}
result = await self._tool_service.execute(tool_call)
tool_results.append(result)
log_entry = ToolService._create_log_entry(tool_call, result)
yield {
"type": "tool_result",
"tool_result": result,
"log_entry": log_entry
}
# 构建工具结果消息
tool_messages = []
for tc, tr in zip(tool_calls, tool_results):
tool_messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": tr.content
})
# 构建assistant消息包含tool_calls
assistant_message = {
"role": "assistant",
"content": accumulated_content,
"tool_calls": [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": tc.arguments
}
} for tc in tool_calls
]
}
# 第二次API调用传入工具结果
final_content = ""
async for chunk in self._call_api_stream_with_tools(
user_input,
assistant_message,
tool_messages
):
if chunk.get("content_delta"):
final_content += chunk["content_delta"]
yield {
"type": "content_delta",
"content": final_content
}
accumulated_content = final_content
# 4. 返回最终回复
yield {
"type": "assistant_message",
"content": accumulated_content,
"tool_calls": tool_calls,
"tool_results": tool_results
}
except Exception as e:
yield {
"type": "error",
"error": str(e)
}
async def _call_api_stream_async(self, message: str) -> AsyncIterator[dict]:
"""异步流式调用 API"""
loop = asyncio.get_event_loop()
def process_stream():
stream = self._client.send_message_stream(message)
tool_calls_accumulated = []
for chunk in stream:
delta = chunk.choices[0].delta
if delta.content:
yield {"content_delta": delta.content}
if delta.tool_calls:
for tc in delta.tool_calls:
if tc.index >= len(tool_calls_accumulated):
tool_calls_accumulated.append({
"id": tc.id,
"type": "function",
"function": {
"name": "",
"arguments": ""
}
})
if tc.function:
if tc.function.name:
tool_calls_accumulated[tc.index]["function"]["name"] = tc.function.name
if tc.function.arguments:
tool_calls_accumulated[tc.index]["function"]["arguments"] += tc.function.arguments
if tool_calls_accumulated:
yield {"tool_calls": tool_calls_accumulated}
for result in await loop.run_in_executor(None, lambda: list(process_stream())):
yield result
async def _call_api_stream_with_tools(
self,
user_input: str,
assistant_message: dict,
tool_messages: list
) -> AsyncIterator[dict]:
"""带工具结果的流式调用"""
loop = asyncio.get_event_loop()
def process_stream():
# 构建完整的消息列表
messages = [
{"role": "system", "content": self._client.system_prompt},
{"role": "user", "content": user_input},
assistant_message
]
messages.extend(tool_messages)
# 调用API
response = self._client.client.chat.completions.create(
model="deepseek-chat",
messages=messages,
tools=self._client.tools,
tool_choice="auto",
stream=True
)
for chunk in response:
delta = chunk.choices[0].delta
if delta.content:
yield {"content_delta": delta.content}
# 处理可能的工具调用
if delta.tool_calls:
# 如果还有工具调用说明AI想继续调用工具
# 但我们限制只调用一次,所以忽略
pass
for result in await loop.run_in_executor(None, lambda: list(process_stream())):
yield result
def clear_history(self) -> None:
"""清空消息历史"""
self._messages.clear()
def get_history(self) -> list[dict]:
"""获取消息历史"""
return self._messages.copy()

View File

@ -0,0 +1,46 @@
"""
配置管理 - 支持持久化
"""
import json
from pathlib import Path
from ..models.config import AppConfig
class ConfigManager:
"""配置管理器 - 支持持久化"""
def __init__(self, config_file: str = "config.json"):
self.config_file = Path(config_file)
def save(self, config: AppConfig) -> None:
"""保存配置到文件"""
data = {
"api_key": config.api_key,
"model": config.model,
"base_url": config.base_url
}
with open(self.config_file, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
def load(self) -> AppConfig:
"""从文件加载配置"""
if not self.config_file.exists():
return AppConfig()
try:
with open(self.config_file, 'r', encoding='utf-8') as f:
data = json.load(f)
return AppConfig(
api_key=data.get("api_key", ""),
model=data.get("model", "deepseek-chat"),
base_url=data.get("base_url", "https://api.deepseek.com")
)
except Exception:
return AppConfig()
def exists(self) -> bool:
"""检查配置文件是否存在"""
return self.config_file.exists()

View File

@ -0,0 +1,51 @@
"""配置服务"""
from pathlib import Path
from typing import TYPE_CHECKING
from ..models.config import AppConfig
if TYPE_CHECKING:
from ..core.imports import GraphMemoryClient
class ConfigService:
"""配置服务"""
DEFAULT_CONFIG_FILE = Path.home() / ".graph_memory_tui" / "config.json"
def __init__(self, config_file: Path | None = None):
self._config_file = config_file or self.DEFAULT_CONFIG_FILE
self._config = self._load_config()
def _load_config(self) -> AppConfig:
"""加载配置"""
# 优先从文件加载
if self._config_file.exists():
return AppConfig.from_file(self._config_file)
# 否则从环境变量加载
return AppConfig.from_env()
def get_config(self) -> AppConfig:
"""获取当前配置"""
return self._config
def set_config(self, config: AppConfig) -> None:
"""设置配置"""
self._config = config
self._save_config()
def _save_config(self) -> None:
"""保存配置"""
self._config.save(self._config_file)
def apply_to_client(self, client: "GraphMemoryClient") -> None:
"""应用配置到 API 客户端"""
# 更新客户端配置
client.api_key = self._config.api_key
client.base_url = self._config.base_url
client.model = self._config.model
def get_config_file(self) -> Path:
"""获取配置文件路径"""
return self._config_file

View File

@ -0,0 +1,88 @@
"""工具服务"""
import asyncio
import time
from datetime import datetime
from typing import Callable, TYPE_CHECKING
from ..core.imports import execute_tool
from ..models.log_entry import LogEntry
from ..models.message import ToolCall, ToolResult
if TYPE_CHECKING:
from ..core.imports import Neo4jGraph
class ToolService:
"""工具执行服务"""
def __init__(
self,
graph: "Neo4jGraph",
log_callback: Callable[[LogEntry], None] | None = None
):
self._graph = graph
self._log_callback = log_callback
async def execute(self, tool_call: ToolCall) -> ToolResult:
"""异步执行工具"""
start_time = time.time()
try:
# 在线程池中执行同步工具
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
lambda: execute_tool(self._graph, tool_call.name, tool_call.arguments)
)
duration = time.time() - start_time
# 创建日志条目
log_entry = LogEntry(
timestamp=datetime.now(),
tool_name=tool_call.name,
arguments=tool_call.arguments,
result=result,
duration=duration
)
# 回调日志
if self._log_callback:
self._log_callback(log_entry)
# 返回结果
return ToolResult(
tool_call_id=tool_call.id,
name=tool_call.name,
arguments=tool_call.arguments,
result=result,
success=not result.startswith("工具执行错误")
)
except Exception as e:
duration = time.time() - start_time
error_msg = f"工具执行异常: {str(e)}"
# 创建错误日志
log_entry = LogEntry(
timestamp=datetime.now(),
tool_name=tool_call.name,
arguments=tool_call.arguments,
result=error_msg,
duration=duration
)
if self._log_callback:
self._log_callback(log_entry)
return ToolResult(
tool_call_id=tool_call.id,
name=tool_call.name,
arguments=tool_call.arguments,
result=error_msg,
success=False
)
def set_log_callback(self, callback: Callable[[LogEntry], None]) -> None:
"""设置日志回调"""
self._log_callback = callback

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

@ -0,0 +1 @@
"""Styles for Graph Memory TUI"""

41
ui/styles/app.css Normal file
View File

@ -0,0 +1,41 @@
/* Global Styles for Graph Memory TUI */
GraphMemoryApp {
background: $surface;
color: $text;
}
/* 全局Input样式 - 确保可见 */
Input {
background: $surface-lighten-1;
color: $text;
border: solid $primary;
}
Input:focus {
border: double $accent;
}
LeftPanel {
width: 1fr;
dock: left;
}
RightPanel {
width: 70;
dock: right;
background: $panel;
overflow-y: auto;
}
RightPanel ScrollableContainer {
height: 1fr;
overflow-y: auto;
}
StatusBar {
dock: bottom;
height: 1;
background: $primary;
color: $text-primary;
}

129
ui/styles/components.css Normal file
View File

@ -0,0 +1,129 @@
/* Component Styles for Graph Memory TUI */
/* Input Box - 最重要 */
InputBox {
background: $surface;
padding: 1 2;
height: auto;
border: solid $primary;
}
InputBox Input {
width: 100%;
background: $surface-lighten-1;
color: $text;
border: none;
}
/* Config Section */
ConfigSection {
background: $surface;
padding: 1;
margin: 0 0 1 0;
height: auto;
}
ConfigSection .config-title {
color: $primary;
text-style: bold;
margin: 0 0 1 0;
}
ConfigSection .config-label {
color: $text;
margin: 0;
padding: 1 0 0 0;
}
ConfigSection .config-hint {
color: $text-muted;
text-style: italic;
margin: 1 0 0 0;
}
ConfigSection Input {
width: 1fr;
height: 3;
margin: 0 0 1 0;
padding: 0 1;
background: $surface-lighten-1;
border: solid $primary;
color: $text;
}
/* Other Components */
OperationLog {
background: $surface-darken-1;
height: 1fr;
margin: 1;
overflow-y: auto;
padding: 1;
}
OperationLog .log-entry {
color: $text;
margin: 0 0 1 0;
height: auto;
}
OperationLog .log-empty {
color: $text-muted;
text-style: italic;
}
CypherQueryBox {
border: solid green;
margin: 1;
height: auto;
}
MessageHistory {
height: 1fr;
margin: 1;
overflow-y: auto;
}
/* Message Widget */
MessageWidget {
margin: 1 0;
height: auto;
}
MessageWidget .message-header {
color: $text-muted;
text-style: bold;
margin: 0 0 0 0;
}
MessageWidget .message-content {
color: $text;
margin: 0 0 0 2;
height: auto;
}
MessageWidget .tool-indicator {
color: $warning;
text-style: bold;
margin: 1 0 0 2;
}
MessageWidget .tool-details {
background: $surface-darken-1;
margin: 1 0 0 2;
padding: 1;
}
MessageWidget .tool-name {
color: $accent;
text-style: bold;
}
MessageWidget .tool-args {
color: $text-muted;
margin: 0 0 0 2;
}
MessageWidget .tool-result {
color: $success;
margin: 0 0 0 2;
}

24
ui/styles/messages.css Normal file
View File

@ -0,0 +1,24 @@
/* Message Styles for Graph Memory TUI */
UserMessage {
border: solid orange;
margin: 1 0;
padding: 1;
}
ModelMessage {
border: solid blue;
margin: 1 0;
padding: 1;
}
ToolCallIndicator {
color: yellow;
text-style: bold;
}
ToolCallDetails {
background: $surface-darken-1;
margin: 1 0 0 2;
padding: 1;
}

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