refactor: packet-based IPC with core/ui separation

- All logic in core layer (BackendServer runs independently)
- Packet protocol: {id, type, body} for all communications
- UI simplified: just input→backend→display
- Config, history, tools all handled by core
- BackendServer can run standalone without UI
This commit is contained in:
root
2026-04-12 20:47:07 +08:00
parent a820afada9
commit 5c76b19624
4 changed files with 294 additions and 247 deletions

View File

@ -1,5 +1,219 @@
from .server import BackendServer
from .client import BackendClient
import threading
import queue
import time
from typing import Any, Dict
from dataclasses import dataclass, field
from enum import Enum
class PacketType(Enum):
MESSAGE = "message"
CONFIG = "config"
TOOL = "tool"
STATUS = "status"
HISTORY = "history"
@dataclass
class Packet:
id: str
type: PacketType
body: Dict[str, Any]
created_at: float = field(default_factory=time.time)
from .embedded_db import EmbeddedGraphDB
__all__ = ["BackendServer", "BackendClient", "EmbeddedGraphDB"]
class BackendServer:
def __init__(self, db_path: str = "graph_memory.db", use_embedded_db: bool = True):
self._db_path = db_path
self._use_embedded_db = use_embedded_db
self._graph = None
self._client = None
self._running = False
self._thread = None
self._input_queue: queue.Queue[Packet] = queue.Queue()
self._response_queues: Dict[str, queue.Queue] = {}
self._lock = threading.Lock()
self._config = {"api_key": "", "base_url": "https://api.deepseek.com"}
def start(self, api_key: str = "", base_url: str = "https://api.deepseek.com") -> None:
if self._running:
return
self._init_graph()
self._config = {"api_key": api_key, "base_url": base_url}
if api_key:
from .graph_client import GraphMemoryClient
self._client = GraphMemoryClient(api_key=api_key, base_url=base_url, graph=self._graph)
self._running = True
self._thread = threading.Thread(target=self._run_loop, daemon=True)
self._thread.start()
def _init_graph(self) -> None:
if self._use_embedded_db:
self._graph = EmbeddedGraphDB(db_path=self._db_path)
else:
from .graph_client import Neo4jGraph
self._graph = Neo4jGraph(uri="bolt://localhost:7687", user="neo4j", password="graphmemory123")
def _run_loop(self) -> None:
while self._running:
try:
packet = self._input_queue.get(timeout=0.1)
except queue.Empty:
continue
self._process_packet(packet)
def _process_packet(self, packet: Packet) -> None:
body = {"success": False, "error": "not implemented"}
try:
if packet.type == PacketType.MESSAGE:
body = self._handle_message(packet.body)
elif packet.type == PacketType.CONFIG:
body = self._handle_config(packet.body)
elif packet.type == PacketType.TOOL:
body = self._handle_tool(packet.body)
elif packet.type == PacketType.STATUS:
body = self._handle_status()
elif packet.type == PacketType.HISTORY:
body = self._handle_history(packet.body)
body["success"] = True
except Exception as e:
body["error"] = str(e)
finally:
self._send_response(packet.id, Packet(id=packet.id, type=packet.type, body=body))
def _handle_message(self, body: Dict) -> Dict:
from .tool_executor import execute_tool
from .tool_limiter import ToolLimiter
limiter = ToolLimiter()
user_input = body.get("message", "")
if not self._client:
return {"error": "API Key not configured", "content": "请先配置 API Key"}
messages = [{"role": "user", "content": user_input}]
response = self._client.send_message_with_history(messages)
message = response.choices[0].message
content = message.content or "(无<><E697A0>复)"
tool_calls = []
while message.tool_calls:
args = {}
try:
import json
args = json.loads(message.tool_calls[0].function.arguments)
except:
pass
allowed, reason = limiter.can_call(message.tool_calls[0].function.name, args)
if not allowed:
tool_calls.append({"name": message.tool_calls[0].function.name, "result": f"工具调用被拒绝: {reason}"})
continue
limiter.record_call(message.tool_calls[0].function.name, args)
result = execute_tool(self._graph, message.tool_calls[0].function.name, args)
tool_calls.append({"name": message.tool_calls[0].function.name, "result": result})
messages.append({"role": "assistant", "tool_calls": [{"id": "1", "function": {"name": message.tool_calls[0].function.name, "arguments": message.tool_calls[0].function.arguments}}]})
messages.append({"role": "tool", "tool_call_id": "1", "content": result})
response = self._client.send_message_with_history(messages)
message = response.choices[0].message
content = message.content or content
return {"content": content, "tool_calls": tool_calls}
def _handle_config(self, body: Dict) -> Dict:
api_key = body.get("api_key", "")
base_url = body.get("base_url", "https://api.deepseek.com")
self._config = {"api_key": api_key, "base_url": base_url}
if api_key and self._graph:
from .graph_client import GraphMemoryClient
self._client = GraphMemoryClient(api_key=api_key, base_url=base_url, graph=self._graph)
return {"status": "config_updated"}
def _handle_tool(self, body: Dict) -> Dict:
from .tool_executor import execute_tool
name = body.get("name", "")
args = body.get("arguments", {})
result = execute_tool(self._graph, name, args)
return {"result": result}
def _handle_status(self) -> Dict:
return {"running": self._running, "config": self._config, "client_ready": self._client is not None}
def _handle_history(self, body: Dict) -> Dict:
action = body.get("action", "get")
if action == "save":
self._message_history = body.get("messages", [])
return {"status": "saved"}
return {"history": getattr(self, "_message_history", [])}
def send(self, packet: Packet) -> Packet:
resp_q = queue.Queue()
with self._lock:
self._response_queues[packet.id] = resp_q
self._input_queue.put(packet)
try:
return resp_q.get(timeout=30.0)
except queue.Empty:
return Packet(id=packet.id, type=packet.type, body={"success": False, "error": "timeout"})
finally:
with self._lock:
self._response_queues.pop(packet.id, None)
def _send_response(self, request_id: str, packet: Packet) -> None:
with self._lock:
q = self._response_queues.pop(request_id, None)
if q:
q.put(packet)
def shutdown(self) -> None:
self._running = False
if self._thread:
self._thread.join(timeout=2.0)
if self._graph:
self._graph.close()
self._graph = None
class BackendClient:
def __init__(self, server: BackendServer):
self._server = server
self._counter = 0
self._lock = threading.Lock()
def _next_id(self) -> str:
with self._lock:
self._counter += 1
return f"{time.time()}_{self._counter}"
def send_message(self, message: str) -> Dict:
packet = Packet(id=self._next_id(), type=PacketType.MESSAGE, body={"message": message})
return self._server.send(packet).body
def update_config(self, api_key: str, base_url: str = "https://api.deepseek.com") -> Dict:
packet = Packet(id=self._next_id(), type=PacketType.CONFIG, body={"api_key": api_key, "base_url": base_url})
return self._server.send(packet).body
def execute_tool(self, name: str, arguments: Dict) -> str:
packet = Packet(id=self._next_id(), type=PacketType.TOOL, body={"name": name, "arguments": arguments})
return self._server.send(packet).body.get("result", "")
def get_status(self) -> Dict:
packet = Packet(id=self._next_id(), type=PacketType.STATUS, body={})
return self._server.send(packet).body
def save_history(self, messages: list) -> None:
packet = Packet(id=self._next_id(), type=PacketType.HISTORY, body={"action": "save", "messages": messages})
self._server.send(packet)
def get_history(self) -> list:
packet = Packet(id=self._next_id(), type=PacketType.HISTORY, body={"action": "get"})
return self._server.send(packet).body.get("history", [])
def shutdown(self) -> None:
self._server.shutdown()
__all__ = ["BackendServer", "BackendClient", "EmbeddedGraphDB", "Packet", "PacketType"]

View File

@ -10,28 +10,15 @@ class TestUIImport:
from ui import GraphMemoryApp
assert GraphMemoryApp is not None
def test_import_config(self):
from ui import AppConfig
assert AppConfig is not None
class TestUIApp:
def test_create_app(self):
def test_create_app_without_backend(self):
from ui import GraphMemoryApp
app = GraphMemoryApp()
assert app is not None
assert app._backend_server is None
assert app._backend_client is None
def test_create_app_with_config(self):
from ui import GraphMemoryApp, AppConfig
config = AppConfig(api_key="test-key", base_url="https://api.test.com")
app = GraphMemoryApp(config=config)
assert app._config is config
assert app._config.api_key == "test-key"
def test_create_app_with_backend(self):
from ui import GraphMemoryApp
from core import BackendServer
@ -57,14 +44,26 @@ class TestUIApp:
class TestAppConfig:
def test_config_from_env(self):
from ui import AppConfig
config = AppConfig.from_env()
assert config is not None
def test_config_default_values(self):
from ui import AppConfig
config = AppConfig()
assert config.api_key == ""
assert config.model == "deepseek-chat"
assert config.base_url == "https://api.deepseek.com"
assert config.base_url == "https://api.deepseek.com"
class TestPacketProtocol:
def test_import_packet(self):
from core import Packet, PacketType
assert Packet is not None
assert PacketType is not None
def test_packet_creation(self):
from core import Packet, PacketType
packet = Packet(id="test-1", type=PacketType.MESSAGE, body={"message": "hello"})
assert packet.id == "test-1"
assert packet.type == PacketType.MESSAGE
assert packet.body["message"] == "hello"

View File

@ -1,6 +1,5 @@
#!/usr/bin/env python3
import sys
import os
from pathlib import Path
if getattr(sys, 'frozen', False):
@ -8,37 +7,26 @@ if getattr(sys, 'frozen', False):
else:
application_path = Path(__file__).parent
sys.path.insert(0, str(application_path))
import os
os.chdir(application_path)
if str(application_path) not in sys.path:
sys.path.insert(0, str(application_path))
from core import BackendServer
from ui import GraphMemoryApp, AppConfig
from ui import GraphMemoryApp
def main():
backend_server = BackendServer(db_path="graph_memory.db", use_embedded_db=True)
try:
config = AppConfig.from_env()
backend_server.start(api_key=config.api_key, base_url=config.base_url)
except Exception as e:
print(f"后端启动失败: {e}")
backend_server.start(api_key="", base_url="https://api.deepseek.com")
app = GraphMemoryApp(backend_server=backend_server)
try:
app.run()
except KeyboardInterrupt:
print("\n应用已退出")
except Exception as e:
print(f"应用启动失败: {e}")
import traceback
traceback.print_exc()
print("\n退出")
finally:
backend_server.shutdown()
sys.exit(0)
if __name__ == "__main__":

266
ui/app.py
View File

@ -2,17 +2,12 @@ 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
from .models.message import Message
class GraphMemoryApp(App[None]):
class GraphMemoryApp(App):
CSS_PATH = [
Path(__file__).parent / "styles" / "app.css",
Path(__file__).parent / "styles" / "messages.css",
@ -23,257 +18,108 @@ class GraphMemoryApp(App[None]):
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):
def __init__(self, backend_server: BackendServer = 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
self._backend_server = backend_server
self._backend_client = BackendClient(backend_server) if backend_server else 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 RightPanel()
yield StatusBar()
def on_mount(self) -> None:
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)
return
status = self._backend_client.get_status()
api_configured = status.get("config", {}).get("api_key", "") != ""
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)
welcome = Message(
role="assistant",
content=f"系统就绪\nAPI Key: {'已配置' if api_configured else '未配置'}\n\n输入消息开始对话"
)
history.add_message(welcome)
def on_unmount(self) -> None:
if self._backend_server:
self._backend_server.shutdown()
if self._backend_client:
self._backend_client.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)
self.notify("F1-帮助 F2-侧边栏 F3-工具详情 F5-清屏 F6-退出", 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
if not self._backend_client:
return
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:
user_input = event.content
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()
history.add_message(Message(role="user", content=user_input))
history.add_message(Message(role="assistant", content="处理中..."))
asyncio.create_task(self._process(user_input))
async def _process(self, user_input: str) -> None:
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)
lambda: self._backend_client.send_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)
from .widgets.message_history import MessageHistory
history = self.query_one(MessageHistory)
history.clear_messages()
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/
"""
history.add_message(Message(role="user", content=user_input))
if result.get("success"):
content = result.get("content", "(无回复)")
history.add_message(Message(role="assistant", content=content))
else:
help_text = f"\n详细错误: {error_msg}"
error_message = Message(role="assistant", content=f"错误: {error_msg}\n{help_text}")
history.add_message(error_message)
error = result.get("error", "未知错误")
history.add_message(Message(role="assistant", content=f"错误: {error}"))
except Exception as e:
from .widgets.message_history import MessageHistory
history = self.query_one(MessageHistory)
history.add_message(Message(role="assistant", content=f"错误: {str(e)}"))
def on_config_section_config_changed(self, event) -> None:
from .widgets.right_panel import RightPanel
def on_config_changed(self, event) -> None:
if not self._backend_client:
return
self._config = event.config
self._config_manager.save(self._config)
api_key = event.api_key
base_url = event.base_url
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="警告")
result = self._backend_client.update_config(api_key=api_key, base_url=base_url)
self.notify("配置已保存" if result.get("success") else "配置失败", title="配置")