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

8
.gitignore vendored
View File

@ -4,13 +4,11 @@ __pycache__/
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
.lib/
lib64/
parts/
sdist/
@ -55,7 +53,6 @@ Thumbs.db
config.json
# Build
build/
dist/
# Temporary
@ -64,3 +61,6 @@ dist/
# AI Generated
jimeng*.png
# Test Cache
.pytest_cache/

View File

@ -33,8 +33,6 @@ TrulyMEM (TrueHumanMEM) 是一个让 AI 拥有长期记忆能力的图记忆系
### 方式一:打包后的可执行文件
打包后会生成独立可执行文件,可直接运行:
```bash
# Windows: TrulyMEM.exe
# Linux/macOS: TrulyMEM
@ -45,14 +43,11 @@ chmod +x TrulyMEM
### 方式二:从源码运行
```bash
# 克隆仓库
git clone <repo-url>
cd TrulyMEM-TrueHumanMEM
# 安装依赖
pip install -r requirements.txt
# 运行应用
python trulymem_entry.py
```
@ -111,25 +106,27 @@ python trulymem_entry.py
```
TrulyMEM-TrueHumanMEM/
├── trulymem_entry.py # 打包入口
├── graph_memory_tui/ # 核心应用
│ ├── app.py # TUI 主应用
│ ├── main.py # 模块入口
│ ├── core/ # 核心逻辑
│ ├── embedded_db.py # SQLite 图数据库
│ ├── graph_client.py # Neo4j 客户端(可选)
│ ├── imports.py # 动态导入
│ ├── prompts/ # 提示词管理
│ └── tools/ # 工具定义
│ ├── models/ # 数据模型
── services/ # 服务层
│ ├── handlers/ # 事件处理
│ ├── widgets/ # TUI 组件
── styles/ # 样式文件
├── tests/ # 测试
├── docs/ # 文档
├── requirements.txt # 依赖清单
└── LICENSE # 许可证
├── trulymem_entry.py # 入口:先启动 core → 再启动 ui
├── core/ # 后端/业务逻辑
│ ├── __init__.py
│ ├── server.py # BackendServer (多线程)
│ ├── client.py # BackendClient
│ ├── embedded_db.py # SQLite 图数据库
│ ├── graph_client.py
│ ├── tool_executor.py
│ ├── tool_limiter.py
├── memory_tools.py
│ ├── prompts/
── tools/ # TOOLS 定义
├── ui/ # TUI 显示层
│ ├── __init__.py
── app.py
│ ├── widgets/
│ ├── handlers/
│ ├── models/
│ ├── services/
│ └── styles/
└── tests/ # 测试 (38 tests)
```
---
@ -143,36 +140,38 @@ TrulyMEM-TrueHumanMEM/
---
## 架构说明
### TUI 与后端通信
```
trulymem_entry.py
├─ 1. BackendServer.start() → 启动独立线程
├─ 2. GraphMemoryApp(backend_server=server)
└─ 3. BackendClient ← Queue → BackendServer
```
- **core/** - 业务逻辑数据库、API调用、工具执行
- **ui/** - 显示逻辑Textual 组件)
- 多线程 Queue 通信解耦
---
## 开发指南
### 环境设置
```bash
# 创建虚拟环境
python -m venv venv
source venv/bin/activate # Linux/macOS
venv\Scripts\activate # Windows
# 安装依赖
pip install -r requirements.txt
```
### 运行测试
```bash
pytest tests/
```
### 打包应用
```bash
# Windows
bash build/build_windows.bat
# Linux
bash build/build_linux.sh
```
---
## 许可证
@ -207,4 +206,4 @@ bash build/build_linux.sh
- [架构设计](docs/架构.md) - 系统架构和技术设计
- [快速开始](docs/一键启动指南.md) - 启动指南
- [工作记忆链机制说明](docs/工作记忆链机制说明.md) - 连续性任务处理
- [工作记忆链机制说明](docs/工作记忆链机制说明.md) - 连续性任务处理

View File

@ -1,32 +1,22 @@
#!/bin/bash
# Linux打包脚本
# 需要在Linux系统上运行
set -e
echo "开始打包Linux版本..."
echo "Building Linux binary..."
# 检查Python和PyInstaller
if ! command -v python3 &> /dev/null; then
echo "错误: 未找到python3"
echo "Error: python3 not found"
exit 1
fi
# 安装依赖
echo "安装依赖..."
pip3 install -r requirements.txt
# 打包Linux二进制文件
echo "打包Linux二进制文件..."
python3 -m PyInstaller \
--clean \
--onefile \
--name TrulyMEM \
--console \
--add-data "graph_memory_tui/styles/*.css:graph_memory_tui/styles" \
python3 -m PyInstaller --clean --onefile --console \
--add-data "ui/styles:ui/styles" \
--add-data "core/prompts/templates:core/prompts/templates" \
--hidden-import textual \
--hidden-import openai \
--hidden-import flask \
--hidden-import neo4j \
--collect-all textual \
trulymem_entry.py
echo "打包完成!"
echo "可执行文件位于: dist/TrulyMEM"
echo "Done! Binary: dist/TrulyMEM"

22
build/build_macos.sh Normal file
View File

@ -0,0 +1,22 @@
#!/bin/bash
set -e
echo "Building macOS binary..."
if ! command -v python3 &> /dev/null; then
echo "Error: python3 not found"
exit 1
fi
pip3 install -r requirements.txt
python3 -m PyInstaller --clean --onefile --console \
--add-data "ui/styles:ui/styles" \
--add-data "core/prompts/templates:core/prompts/templates" \
--hidden-import textual \
--hidden-import openai \
--hidden-import neo4j \
--collect-all textual \
trulymem_entry.py
echo "Done! Binary: dist/TrulyMEM"

View File

@ -1,33 +1,22 @@
@echo off
REM Windows打包脚本
echo Building Windows binary...
echo 开始打包Windows版本...
REM 检查Python
python --version >nul 2>&1
if errorlevel 1 (
echo 错误: 未找到python
echo Error: python not found
exit /b 1
)
REM 安装依赖
echo 安装依赖...
pip install -r requirements.txt
REM 打包Windows exe文件
echo 打包Windows exe文件...
python -m PyInstaller ^
--clean ^
--onefile ^
--name TrulyMEM ^
--console ^
--add-data "graph_memory_tui/styles/*.css;graph_memory_tui/styles" ^
python -m PyInstaller --clean --onefile --console ^
--add-data "ui/styles;ui/styles" ^
--add-data "core/prompts/templates;core/prompts/templates" ^
--hidden-import textual ^
--hidden-import openai ^
--hidden-import flask ^
--hidden-import neo4j ^
--collect-all textual ^
trulymem_entry.py
echo 打包完成!
echo 可执行文件位于: dist\TrulyMEM.exe
pause
echo Done! Binary: dist\TrulyMEM.exe
pause

View File

@ -1,12 +1,18 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['trulymem_entry.py'],
pathex=[],
binaries=[],
datas=[('graph_memory_tui/styles/*.css', 'graph_memory_tui/styles')],
hiddenimports=['textual', 'openai', 'flask', 'neo4j'],
datas=[
('ui/styles', 'ui/styles'),
('core/prompts/templates', 'core/prompts/templates'),
],
hiddenimports=[
'textual',
'openai',
'neo4j',
'sqlite3',
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
@ -15,7 +21,6 @@ a = Analysis(
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
@ -35,4 +40,4 @@ exe = EXE(
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
)

5
core/__init__.py Normal file
View File

@ -0,0 +1,5 @@
from .server import BackendServer
from .client import BackendClient
from .embedded_db import EmbeddedGraphDB
__all__ = ["BackendServer", "BackendClient", "EmbeddedGraphDB"]

27
core/client.py Normal file
View File

@ -0,0 +1,27 @@
import threading
from typing import Any, Dict
from .server import BackendServer
class BackendClient:
def __init__(self, server: BackendServer):
self._server = server
self._request_counter = 0
self._lock = threading.Lock()
def process_message(self, user_input: str, timeout: float = 30.0) -> Dict[str, Any]:
with self._lock:
self._request_counter += 1
return self._server.process_message(user_input, timeout)
def execute_tool(self, tool_name: str, arguments: Dict[str, Any], timeout: float = 10.0) -> str:
with self._lock:
self._request_counter += 1
return self._server.execute_tool(tool_name, arguments, timeout)
def update_config(self, api_key: str, base_url: str = "https://api.deepseek.com") -> None:
self._server.update_config(api_key, base_url)
def shutdown(self) -> None:
self._server.shutdown()

438
core/embedded_db.py Normal file
View File

@ -0,0 +1,438 @@
"""
内嵌图数据库 - 基于SQLite实现
无需Docker开箱即用
"""
import sqlite3
import json
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Optional, Any
class EmbeddedGraphDB:
"""内嵌图数据库 - SQLite实现"""
def __init__(self, db_path: str = "graph_memory.db"):
"""
初始化数据库
Args:
db_path: 数据库文件路径
"""
self.db_path = Path(db_path)
self.conn = None
self._init_db()
def _init_db(self):
"""初始化数据库表"""
self.conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
self.conn.row_factory = sqlite3.Row
cursor = self.conn.cursor()
# 创建实体表
cursor.execute("""
CREATE TABLE IF NOT EXISTS entities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
type TEXT,
mention_count INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# 创建关系表
cursor.execute("""
CREATE TABLE IF NOT EXISTS relations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_id INTEGER NOT NULL,
target_id INTEGER NOT NULL,
relation_type TEXT NOT NULL,
confidence REAL DEFAULT 1.0,
status TEXT DEFAULT 'active',
session_id TEXT,
turn_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
date_bucket TEXT,
superseded_by INTEGER,
FOREIGN KEY (source_id) REFERENCES entities(id),
FOREIGN KEY (target_id) REFERENCES entities(id)
)
""")
# 创建索引
cursor.execute("CREATE INDEX IF NOT EXISTS idx_entity_name ON entities(name)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_entity_type ON entities(type)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_relation_source ON relations(source_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_relation_target ON relations(target_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_relation_type ON relations(relation_type)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_relation_status ON relations(status)")
self.conn.commit()
def ensure_constraints(self):
"""确保约束兼容Neo4j接口"""
pass # SQLite自动处理
def recall(self, query_intent: str, seed_entities: List[str] = None,
depth: int = 2, time_range: Dict = None,
session_filter: str = None) -> Dict:
"""
检索相关记忆
Args:
query_intent: 查询关键词(逗号分隔)
seed_entities: 种子实体
depth: 搜索深度
time_range: 时间范围
session_filter: 会话过滤
Returns:
检索结果
"""
keywords = [w.strip().lower() for w in query_intent.replace(',', ' ').split() if w.strip()]
cursor = self.conn.cursor()
# 搜索实体
entities = []
entity_ids = set()
# 如果没有关键词,返回所有实体(用于"我们都聊过什么"这类问题)
if not keywords and not seed_entities:
cursor.execute("""
SELECT id, name, type, mention_count
FROM entities
ORDER BY mention_count DESC
LIMIT 50
""")
for row in cursor.fetchall():
entity_ids.add(row['id'])
entities.append({
'name': row['name'],
'type': row['type'] or 'unknown',
'mention_count': row['mention_count']
})
else:
# 有关键词,按关键词搜索
for keyword in keywords:
cursor.execute("""
SELECT id, name, type, mention_count
FROM entities
WHERE LOWER(name) LIKE ?
""", (f"%{keyword}%",))
for row in cursor.fetchall():
if row['id'] not in entity_ids:
entity_ids.add(row['id'])
entities.append({
'name': row['name'],
'type': row['type'] or 'unknown',
'mention_count': row['mention_count']
})
# 搜索关系
relations = []
if entity_ids:
placeholders = ','.join('?' * len(entity_ids))
query = f"""
SELECT r.id, e1.name as source, e2.name as target,
r.relation_type as type, r.confidence, r.session_id,
r.turn_id, r.created_at, r.status
FROM relations r
JOIN entities e1 ON r.source_id = e1.id
JOIN entities e2 ON r.target_id = e2.id
WHERE (r.source_id IN ({placeholders}) OR r.target_id IN ({placeholders}))
AND r.status = 'active'
"""
params = list(entity_ids) + list(entity_ids)
if session_filter:
query += " AND r.session_id = ?"
params.append(session_filter)
cursor.execute(query, params)
for row in cursor.fetchall():
relations.append({
'source': row['source'],
'target': row['target'],
'type': row['type'],
'confidence': row['confidence'],
'session_id': row['session_id'],
'turn_id': row['turn_id'],
'created_at': row['created_at'],
'status': row['status']
})
return {
"entities": entities,
"relations": relations,
"message": f"找到 {len(entities)} 个实体, {len(relations)} 条关系"
}
def commit(self, triplets: List[Dict], entity_types: Dict = None,
temporal_tag: str = None, session_id: str = None,
turn_id: int = None) -> Dict:
"""
写入记忆
Args:
triplets: 三元组列表
entity_types: 实体类型
temporal_tag: 时间标签
session_id: 会话ID
turn_id: 轮次ID
Returns:
写入结果
"""
cursor = self.conn.cursor()
created_entities = 0
created_relations = 0
for triplet in triplets:
subject = triplet.get('subject')
relation = triplet.get('relation')
obj = triplet.get('object')
confidence = triplet.get('confidence', 1.0)
if not all([subject, relation, obj]):
continue
# 创建或更新实体
for entity_name in [subject, obj]:
entity_type = entity_types.get(entity_name) if entity_types else None
cursor.execute("""
INSERT INTO entities (name, type)
VALUES (?, ?)
ON CONFLICT(name) DO UPDATE SET
mention_count = mention_count + 1,
updated_at = CURRENT_TIMESTAMP
""", (entity_name, entity_type))
if cursor.rowcount > 0:
created_entities += 1
# 获取实体ID
cursor.execute("SELECT id FROM entities WHERE name = ?", (subject,))
source_id = cursor.fetchone()['id']
cursor.execute("SELECT id FROM entities WHERE name = ?", (obj,))
target_id = cursor.fetchone()['id']
# 创建关系
date_bucket = datetime.now().strftime('%Y-%m-%d')
cursor.execute("""
INSERT INTO relations (
source_id, target_id, relation_type, confidence,
session_id, turn_id, date_bucket
)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (source_id, target_id, relation, confidence,
session_id, turn_id, date_bucket))
created_relations += 1
self.conn.commit()
return {
"created_entities": created_entities,
"created_relations": created_relations,
"message": f"创建了 {created_entities} 个实体, {created_relations} 条关系"
}
def purge(self, criteria: Dict, mode: str = "soft",
new_relation: Dict = None) -> Dict:
"""
删除或修正记忆
Args:
criteria: 删除条件
mode: 删除模式 (soft/hard)
new_relation: 替代关系
Returns:
删除结果
"""
cursor = self.conn.cursor()
# 构建查询条件
conditions = []
params = []
if criteria.get('source'):
cursor.execute("SELECT id FROM entities WHERE name = ?", (criteria['source'],))
row = cursor.fetchone()
if row:
conditions.append("source_id = ?")
params.append(row['id'])
if criteria.get('target'):
cursor.execute("SELECT id FROM entities WHERE name = ?", (criteria['target'],))
row = cursor.fetchone()
if row:
conditions.append("target_id = ?")
params.append(row['id'])
if criteria.get('relation'):
conditions.append("relation_type = ?")
params.append(criteria['relation'])
if not conditions:
return {"deleted": 0, "message": "无删除条件"}
where_clause = " AND ".join(conditions)
if mode == "soft":
cursor.execute(f"""
UPDATE relations
SET status = 'deleted', updated_at = CURRENT_TIMESTAMP
WHERE {where_clause} AND status = 'active'
""", params)
else:
cursor.execute(f"""
DELETE FROM relations
WHERE {where_clause}
""", params)
deleted = cursor.rowcount
self.conn.commit()
return {
"deleted": deleted,
"mode": mode,
"message": f"删除了 {deleted} 条关系"
}
def introspect(self, session_id: str = None) -> Dict:
"""
查看会话状态
Args:
session_id: 会话ID
Returns:
会话状态
"""
cursor = self.conn.cursor()
# 统计实体
cursor.execute("SELECT COUNT(*) as count FROM entities")
entity_count = cursor.fetchone()['count']
# 统计关系
cursor.execute("SELECT COUNT(*) as count FROM relations WHERE status = 'active'")
relation_count = cursor.fetchone()['count']
return {
"entity_count": entity_count,
"relation_count": relation_count,
"session_id": session_id,
"message": f"数据库包含 {entity_count} 个实体, {relation_count} 条关系"
}
def archive(self, days: int = 30) -> Dict:
"""归档旧关系"""
cursor = self.conn.cursor()
cursor.execute("""
UPDATE relations
SET status = 'archived', updated_at = CURRENT_TIMESTAMP
WHERE status = 'active'
AND created_at < datetime('now', ?)
""", (f'-{days} days',))
archived = cursor.rowcount
self.conn.commit()
return {
"archived": archived,
"message": f"归档了 {archived} 条关系"
}
def cleanup(self, dry_run: bool = True) -> Dict:
"""清理已删除数据"""
cursor = self.conn.cursor()
if dry_run:
cursor.execute("""
SELECT COUNT(*) as count
FROM relations
WHERE status = 'deleted'
AND updated_at < datetime('now', '-90 days')
""")
deleted_relations = cursor.fetchone()['count']
return {
"dry_run": True,
"deleted_relations": deleted_relations,
"message": f"将删除 {deleted_relations} 条关系"
}
else:
cursor.execute("""
DELETE FROM relations
WHERE status = 'deleted'
AND updated_at < datetime('now', '-90 days')
""")
deleted = cursor.rowcount
self.conn.commit()
return {
"dry_run": False,
"deleted": deleted,
"message": f"删除了 {deleted} 条关系"
}
def close(self):
"""关闭数据库连接"""
if self.conn:
self.conn.close()
self.conn = None
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
# 兼容性别名
Neo4jGraph = EmbeddedGraphDB
if __name__ == '__main__':
# 测试
print("Testing Embedded Graph Database...")
with EmbeddedGraphDB("test.db") as db:
# 写入测试
result = db.commit(
triplets=[
{"subject": "用户", "relation": "喜欢", "object": "Python"},
{"subject": "用户", "relation": "学习", "object": "AI"}
],
session_id="test-session",
turn_id=1
)
print(f"Commit: {result}")
# 检索测试
result = db.recall("Python,AI")
print(f"Recall: {result}")
# 状态测试
result = db.introspect()
print(f"Introspect: {result}")
print("\nTest completed!")

383
core/graph_client.py Normal file
View File

@ -0,0 +1,383 @@
#!/usr/bin/env python3
"""
Graph Memory Client - 图记忆客户端核心实现(重构版)
使用模块化的工具和提示词系统
"""
import json
import os
import uuid
from datetime import datetime
from openai import OpenAI
from .tools import TOOLS
from .tool_executor import execute_tool
from .prompts.prompt_manager import PromptManager
# 环境配置
DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "")
DEEPSEEK_BASE_URL = os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com")
MODEL_NAME = os.environ.get("MODEL_NAME", "deepseek-chat")
NEO4J_URI = os.environ.get("NEO4J_URI", "bolt://localhost:7687")
NEO4J_USER = os.environ.get("NEO4J_USER", "neo4j")
NEO4J_PASSWORD = os.environ.get("NEO4J_PASSWORD", "neo4j")
# 会话配置
CURRENT_SESSION_ID = f"session-{datetime.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:4]}"
CURRENT_TURN = 0
class Neo4jGraph:
"""Neo4j图数据库客户端"""
def __init__(self, uri: str, user: str, password: str):
from neo4j import GraphDatabase
self.driver = GraphDatabase.driver(uri, auth=(user, password))
def close(self):
self.driver.close()
def ensure_constraints(self):
"""确保约束和索引存在"""
with self.driver.session() as session:
# 实体约束
session.run("CREATE CONSTRAINT entity_name_constraint IF NOT EXISTS FOR (e:Entity) REQUIRE e.name IS UNIQUE")
session.run("CREATE CONSTRAINT session_id_constraint IF NOT EXISTS FOR (s:Session) REQUIRE s.session_id IS UNIQUE")
# 关系索引
session.run("CREATE INDEX rel_created_at IF NOT EXISTS FOR ()-[r:RELATES]-() ON r.created_at")
session.run("CREATE INDEX rel_session_id IF NOT EXISTS FOR ()-[r:RELATES]-() ON r.session_id")
session.run("CREATE INDEX rel_type IF NOT EXISTS FOR ()-[r:RELATES]-() ON r.type")
session.run("CREATE INDEX rel_status IF NOT EXISTS FOR ()-[r:RELATES]-() ON r.status")
session.run("CREATE INDEX rel_date_bucket IF NOT EXISTS FOR ()-[r:RELATES]-() ON r.date_bucket")
# 实体索引
session.run("CREATE INDEX entity_type IF NOT EXISTS FOR (e:Entity) ON e.type")
session.run("CREATE INDEX entity_mention_count IF NOT EXISTS FOR (e:Entity) ON e.mention_count")
def recall(self, query_intent: str, seed_entities: list = None, depth: int = 2,
time_range: dict = None, session_filter: str = None) -> dict:
"""检索记忆"""
with self.driver.session() as session:
# 支持逗号分隔的多个关键词
keywords = [w.strip() for w in query_intent.replace(',', ' ').split() if len(w.strip()) > 0]
if not keywords and not seed_entities:
return {"entities": [], "relations": [], "message": "无查询关键词"}
params = {}
cond_parts = ["r.status = 'active'"]
if session_filter:
cond_parts.append("r.session_id = $session_id")
params["session_id"] = session_filter
if keywords:
keyword_conditions = []
for k in keywords:
k_lower = k.lower()
keyword_conditions.append(f"toLower(e.name) CONTAINS '{k_lower}'")
keyword_conditions.append(f"toLower(t.name) CONTAINS '{k_lower}'")
keyword_conditions.append(f"toLower(r.type) CONTAINS '{k_lower}'")
cond_parts.append(f"({' OR '.join(keyword_conditions)})")
if seed_entities:
placeholders = ",".join([f"'{s}'" for s in seed_entities])
cond_parts.append(f"(e.name IN [{placeholders}] OR t.name IN [{placeholders}])")
if time_range and "days" in time_range:
cond_parts.append(f"r.created_at >= datetime() - duration('P{time_range['days']}D')")
where_clause = " AND ".join(cond_parts)
cypher = f"""
MATCH (e:Entity)-[r:RELATES]->(t:Entity)
WHERE {where_clause}
RETURN e, r, t
ORDER BY r.created_at DESC
LIMIT 30
"""
result = session.run(cypher, params)
entities, relations = {}, []
for record in result:
e, r, t = record["e"], record["r"], record["t"]
if e["name"] not in entities:
entities[e["name"]] = {"name": e["name"], "type": e.get("type", "unknown"), "mention_count": e.get("mention_count", 1)}
if t["name"] not in entities:
entities[t["name"]] = {"name": t["name"], "type": t.get("type", "unknown"), "mention_count": t.get("mention_count", 1)}
relations.append({
"source": e["name"],
"target": t["name"],
"type": r["type"],
"created_at": str(r.get("created_at", "")),
"session_id": r.get("session_id", ""),
"turn_id": r.get("turn_id", 0),
"confidence": r.get("confidence", 1.0)
})
return {"entities": list(entities.values()), "relations": relations[:20]}
def commit(self, triplets: list, entity_types: list = None, temporal_tag: str = None) -> dict:
"""写入记忆"""
global CURRENT_TURN
with self.driver.session() as session:
valid_triplets = [t for t in triplets if t.get("subject") and t.get("relation") and t.get("object")]
if not valid_triplets:
return {"committed_count": 0, "details": []}
etype = entity_types[0] if entity_types else "unknown"
date_bucket = temporal_tag or datetime.now().strftime("%Y-%m-%d")
results = []
for triplet in valid_triplets:
subject = triplet.get("subject", "").strip()
relation = triplet.get("relation", "").strip()
obj = triplet.get("object", "").strip()
confidence = triplet.get("confidence", 0.9)
session.run("""
MERGE (s:Entity {name: $subject})
ON CREATE SET s.type = $type, s.created_at = datetime(), s.mention_count = 1, s.updated_at = datetime()
ON MATCH SET s.mention_count = coalesce(s.mention_count, 0) + 1, s.updated_at = datetime()
MERGE (t:Entity {name: $object})
ON CREATE SET t.type = $type, t.created_at = datetime(), t.mention_count = 1, t.updated_at = datetime()
ON MATCH SET t.mention_count = coalesce(t.mention_count, 0) + 1, t.updated_at = datetime()
CREATE (s)-[r:RELATES {
type: $relation,
created_at: datetime(),
session_id: $session_id,
turn_id: $turn_id,
role: 'user',
status: 'active',
confidence: $confidence,
date_bucket: $date_bucket
}]->(t)
""", subject=subject, object=obj, relation=relation, type=etype,
session_id=CURRENT_SESSION_ID, turn_id=CURRENT_TURN, confidence=confidence,
date_bucket=date_bucket)
results.append(f"{subject} -[{relation}]-> {obj}")
return {"committed_count": len(results), "details": results}
def purge(self, criteria: dict, mode: str = "soft", new_relation: dict = None) -> dict:
"""删除记忆"""
with self.driver.session() as session:
subject_pattern = criteria.get("subject_contains", "")
rel_type = criteria.get("relation_type", "")
target_pattern = criteria.get("target_contains", "")
session_id = criteria.get("session_id", CURRENT_SESSION_ID)
cond_parts = ["r.status = 'active'"]
params = {"session_id": session_id}
if subject_pattern:
cond_parts.append("e.name CONTAINS $subject")
params["subject"] = subject_pattern
if target_pattern:
cond_parts.append("t.name CONTAINS $target")
params["target"] = target_pattern
if rel_type:
cond_parts.append("r.type = $rel_type")
params["rel_type"] = rel_type
where_clause = " AND ".join(cond_parts)
if mode == "supersede" and new_relation:
new_rel = new_relation.get("relation", "")
new_target = new_relation.get("target", "")
if not new_rel or not new_target:
return {"error": "supersede模式需要提供new_relation.relation和new_relation.target"}
result = session.run(f"""
MATCH (s:Entity)-[r:RELATES]->(t:Entity)
WHERE {where_clause}
SET r.status = 'superseded', r.updated_at = datetime()
RETURN count(r) as count
""", params)
count = result.single()["count"]
return {"deleted_count": count, "mode": "supersede"}
else:
result = session.run(f"""
MATCH ()-[r:RELATES]->()
WHERE {where_clause}
SET r.status = 'deleted', r.updated_at = datetime()
RETURN count(r) as deleted
""", params)
count = result.single()["deleted"]
return {"deleted_count": count, "mode": "soft"}
def introspect(self, session_id: str = None) -> dict:
"""查看记忆状态"""
target_session = session_id or CURRENT_SESSION_ID
with self.driver.session() as session:
result = session.run("""
MATCH (s:Entity)-[r:RELATES]->(t:Entity)
WHERE r.session_id = $session_id AND r.status = 'active'
RETURN collect(DISTINCT s.name) as source_entities,
collect(DISTINCT t.name) as target_entities,
count(r) as rel_count,
collect(DISTINCT r.type) as rel_types
""", session_id=target_session)
record = result.single()
result2 = session.run("""
MATCH (e:Entity)
RETURN e.name as name, e.mention_count as count, e.type as type
ORDER BY e.mention_count DESC
LIMIT 10
""")
hotspots = [(r["name"], r["count"], r["type"]) for r in result2]
return {
"session_id": target_session,
"total_turns": CURRENT_TURN,
"entities_discussed": list(set((record["source_entities"] or []) + (record["target_entities"] or []))),
"relation_count": record["rel_count"] if record else 0,
"relation_types": record["rel_types"] if record else [],
"memory_hotspots": hotspots
}
def archive(self, days: int = 30) -> dict:
"""归档旧记忆"""
with self.driver.session() as session:
result = session.run("""
MATCH ()-[r:RELATES]->()
WHERE r.status = 'active' AND r.created_at < datetime() - duration('P' + $days + 'D')
SET r.status = 'archived', r.archived_at = datetime()
RETURN count(r) as archived
""", days=str(days))
return {"archived_count": result.single()["archived"], "days": days}
def cleanup(self, dry_run: bool = True) -> dict:
"""清理无效数据"""
with self.driver.session() as session:
result1 = session.run("""
MATCH ()-[r:RELATES]->()
WHERE r.status = 'deleted' AND r.updated_at < datetime() - duration('P90D')
RETURN count(r) as to_delete
""")
deleted_relations = result1.single()["to_delete"]
result2 = session.run("""
MATCH (e:Entity)
WHERE NOT (e)-[:RELATES]-()
RETURN count(e) as orphans
""")
orphan_nodes = result2.single()["orphans"]
if not dry_run and deleted_relations > 0:
session.run("""
MATCH ()-[r:RELATES]->()
WHERE r.status = 'deleted' AND r.updated_at < datetime() - duration('P90D')
DELETE r
""")
if not dry_run and orphan_nodes > 0:
session.run("""
MATCH (e:Entity)
WHERE NOT (e)-[:RELATES]-()
DELETE e
""")
return {
"dry_run": dry_run,
"deleted_relations": deleted_relations,
"orphan_nodes": orphan_nodes,
"action_taken": not dry_run
}
class GraphMemoryClient:
"""图记忆客户端"""
def __init__(self, api_key: str, base_url: str, graph):
self.client = OpenAI(api_key=api_key, base_url=base_url)
self.graph = graph
self.tools = TOOLS
# 使用新的提示词管理器
prompt_manager = PromptManager()
self.system_prompt = prompt_manager.get_system_prompt()
def send_message(self, user_input: str, tool_results: list = None, assistant_msg: dict = None) -> dict:
"""发送消息"""
global CURRENT_TURN
messages = [{"role": "system", "content": self.system_prompt}]
# 添加用户消息
messages.append({"role": "user", "content": user_input})
# 添加 assistant 消息(包含 tool_calls
if assistant_msg:
messages.append(assistant_msg)
# 添加工具结果
if tool_results:
messages.extend(tool_results)
response = self.client.chat.completions.create(
model=MODEL_NAME,
messages=messages,
tools=self.tools,
tool_choice="auto"
)
return response
def send_message_with_history(self, messages_history: list) -> dict:
"""使用消息历史发送消息"""
global CURRENT_TURN
# 构建完整消息列表
messages = [{"role": "system", "content": self.system_prompt}]
messages.extend(messages_history)
response = self.client.chat.completions.create(
model=MODEL_NAME,
messages=messages,
tools=self.tools,
tool_choice="auto"
)
return response
def send_message_stream(self, user_input: str, tool_results: list = None, assistant_msg: dict = None):
"""流式发送消息"""
global CURRENT_TURN
messages = [{"role": "system", "content": self.system_prompt}]
# 添加用户消息
messages.append({"role": "user", "content": user_input})
# 添加 assistant 消息(包含 tool_calls
if assistant_msg:
messages.append(assistant_msg)
# 添加工具结果
if tool_results:
messages.extend(tool_results)
stream = self.client.chat.completions.create(
model=MODEL_NAME,
messages=messages,
tools=self.tools,
tool_choice="auto",
stream=True
)
return stream

6
core/prompts/__init__.py Normal file
View File

@ -0,0 +1,6 @@
"""
提示词管理模块
"""
from .prompt_manager import PromptManager
__all__ = ["PromptManager"]

View File

@ -0,0 +1,64 @@
"""
提示词管理器
"""
from pathlib import Path
class PromptManager:
"""提示词管理器"""
def __init__(self):
self.prompts_dir = Path(__file__).parent / "templates"
def get_system_prompt(self) -> str:
"""获取系统提示词"""
prompt_file = self.prompts_dir / "system_prompt.md"
if prompt_file.exists():
with open(prompt_file, "r", encoding="utf-8") as f:
return f.read()
else:
return self._build_default_prompt()
def _build_default_prompt(self) -> str:
"""构建默认提示词(精简版)"""
return """你是TrulyMEM一个拥有长期记忆能力的AI助手。
## 核心能力
1. **长期记忆** - 基于图数据库存储实体关系
2. **人设管理** - 支持角色扮演和性格设定
3. **任务跟踪** - 维护工作记忆链,跟踪连续性任务
## 记忆原则
- **明确内容必须写入** - 用户明确提到的信息必须存储
- **推理内容必须标注** - AI推理得到的内容标注[猜测]
- **图数据库是唯一记忆源** - 没有其他记忆方式
## 工具使用
### 记忆工具
- `memory_recall` - 检索记忆
- `memory_commit` - 写入记忆
- `memory_purge` - 删除记忆
- `memory_introspect` - 查看状态
### 人设工具
- `persona_update` - 更新人设
- `persona_clear` - 清除人设
### 任务工具
- `task_create` - 创建任务
- `task_set_state` - 设置状态
- `task_delete` - 删除任务
- `task_link_info` - 关联信息
## 自主性
你有权根据对话上下文自主决定:
- 是否需要查询记忆
- 是否需要写入记忆
- 是否需要维护任务链
- 如何使用工具
记住:灵活应对,保持自然对话体验。"""

View File

@ -0,0 +1,324 @@
# TrulyMEM 系统提示词
你是TrulyMEM一个拥有长期记忆能力的AI助手。
## ⚠️ 最高优先级:只回复一次
**每轮对话只能回复一次!**
- 执行完所有工具调用后,给出一个完整的回复
- 不要在工具调用过程中多次回复
- 不要重复说相同的内容
---
## ⚠️ 关键约束:无传统上下文系统
**重要**: 你没有传统的对话上下文系统(没有消息历史数组)。
-**没有** messages数组存储历史对话
-**没有** 传统的多轮对话上下文
-**只有** 图数据库作为唯一记忆载体
-**必须** 通过工作记忆链维持对话连贯性
## 核心身份
- **名称**: TrulyMEM (TrueHumanMEM)
- **能力**: 基于图数据库的长期记忆
- **理念**: 让AI的记忆方式更像人类
## 核心能力
### 1. 长期记忆
- 图数据库存储实体关系
- 支持时间范围查询
- 支持会话过滤
### 2. 人设管理(关键)
- 角色扮演支持
- 性格、语气设定
- 动态切换人设
- **每轮必须查询人设图**
### 3. 任务跟踪(关键)
- 工作记忆链 - **维持对话连贯性的唯一机制**
- 任务状态管理
- 上下文恢复
## 记忆原则
### 必须写入的情况
- 用户明确表达偏好:"我喜欢X"
- 用户分享信息:"我在做X项目"
- 用户制定计划:"我打算X"
- 用户描述状态:"我现在在X"
### 禁止写入的情况
- AI推断的用户偏好
- AI猜测的用户意图
- AI推导的结论
### 标注规则
- 推理内容必须标注 **[猜测]**
- 明确内容直接陈述
## 工具系统
### 记忆工具
| 工具 | 功能 | 使用场景 |
|------|------|---------|
| `memory_recall` | 检索记忆 | 查询历史信息 |
| `memory_commit` | 写入记忆 | 存储重要信息 |
| `memory_purge` | 删除记忆 | 修正错误信息 |
| `memory_introspect` | 查看状态 | 监控记忆系统 |
### 人设工具
| 工具 | 功能 | 使用场景 |
|------|------|---------|
| `persona_update` | 更新人设 | 设置角色属性 |
| `persona_clear` | 清除人设 | 恢复默认身份 |
### 任务工具
| 工具 | 功能 | 使用场景 |
|------|------|---------|
| `task_create` | 创建任务 | 开始连续性任务 |
| `task_set_state` | 设置状态 | 更新任务状态 |
| `task_delete` | 删除任务 | 清理完成任务 |
| `task_link_info` | 关联信息 | 连接任务与记忆 |
## 每轮对话强制要求
### ⚠️ 执行顺序(每轮必须)
由于没有传统上下文系统,必须通过图数据库维持对话连贯性。
#### 步骤1: 查询人设图(最高优先级)
```
必须调用: memory_recall
参数: {
"query_intent": "AI,人设,角色,性格,语气,说话风格",
"depth": 2
}
```
**目的**: 获取当前人设,确保角色一致性。
**处理**:
- 找到人设 → 严格按照人设回复
- 未找到 → 使用默认TrulyMEM身份
#### 步骤2: 查询工作记忆链
```
必须调用: memory_recall
参数: {
"query_intent": "TaskNode,工作记忆,任务链",
"depth": 2
}
```
**目的**: 获取之前的任务上下文,了解对话历史。
#### 步骤3: 处理对话
- 理解用户意图
- 根据人设和工作记忆链生成回复
- 执行其他必要的记忆操作
#### 步骤4: 更新工作记忆链
```
必须调用: task_create
参数: {
"task_id": "Task_当前轮次ID",
"description": "本轮对话概述",
"info_nodes": ["相关记忆节点"]
}
```
**目的**: 记录本轮对话,维持时间链。
---
## 人设图机制
### 强制查询
每轮对话开始时**必须**查询人设图,确保角色一致性。
### 人设优先级
- 人设优先级 > 默认身份
- 每句话都符合人设的语气、风格、特征
- 绝不主动跳出角色,除非用户明确要求
### 人设更新
用户要求角色扮演时:
1. 使用 `persona_update` 更新人设
2. 立即按照新人设回复
### 人设清除
用户要求恢复默认身份时:
1. 使用 `persona_clear` 清除人设
2. 恢复为TrulyMEM默认身份
---
## 工作记忆链机制
### ⚠️ 核心理念:维持对话连贯性
**重要**: 由于没有传统的消息历史数组,工作记忆链是维持对话连贯性的唯一机制。
### 强制查询场景:
以下情况**必须**查询工作记忆链:
1. **每轮对话开始时(强制第二步)**
- 查询意图: "TaskNode,工作记忆,任务链"
- 目的: 获取之前的任务上下文,了解对话历史
2. **用户提到"刚才"、"之前"、"上次"**
- 例: "刚才我们聊了什么?"
- 例: "继续刚才的话题"
- 例: "关于刚才的成语接龙..."
3. **用户询问对话历史**
- 例: "我们之前说了什么?"
- 例: "我们聊过X吗"
4. **连续性任务被打断后恢复**
- 例: 用户突然回到之前的话题
- 例: 用户要求继续之前的任务
5. **涉及上下文的引用**
- 例: "那个东西"(需要查询上下文)
- 例: "继续"(需要查询当前任务)
### 强制更新场景:
以下情况**必须**更新工作记忆链:
1. **每轮对话结束时(强制第四步)**
- 创建任务节点记录本轮对话
- 目的: 维持时间链,确保对话连贯性
2. **开始连续性任务时**
- 例: 用户发起游戏、项目、学习计划等
- 必须创建任务节点并设置状态为"进行中"
3. **任务状态发生变化时**
- 例: 任务完成、暂停、取消
- 必须及时更新任务状态
### 节点类型
- **TaskNode** - 任务节点,存储任务概述
- **StateNode** - 状态节点,存储任务状态
- **InfoNode** - 信息节点,存储具体信息
### 边类型
- **NEXT_TASK** - 时间链,连接任务节点
- **HAS_STATE** - 状态,任务指向状态
- **CONTAINS_INFO** - 信息,任务指向信息节点
### 任务状态
- 进行中
- 已完成
- 已暂停
- 已取消
### ⚠️ 完整示例:成语接龙游戏
#### 第一轮:用户发起游戏
```
用户: 咱来玩成语接龙吧,我先开始,为所欲为
AI操作步骤:
1. 查询人设图 → 获取当前人设(如:猫娘)
2. 查询工作记忆链 → 无进行中任务
3. 使用 memory_commit 记录游戏状态:
{"triplets": [
{"subject": "成语接龙_当前成语", "relation": "内容", "object": "为所欲为"},
{"subject": "成语接龙_当前成语", "relation": "游戏", "object": "成语接龙"}
]}
4. 使用 task_create 创建任务节点:
{"task_id": "Task_成语接龙", "description": "成语接龙游戏,当前成语:为所欲为", "info_nodes": ["成语接龙_当前成语"]}
5. 回复: "好的喵!我接:为虎作伥喵!"
```
#### 第二轮:话题被打断
```
用户: 长门有希
AI操作步骤:
1. 查询人设图 → 获取当前人设(猫娘)
2. 查询工作记忆链 → 发现 Task_成语接龙 状态为"进行中"
3. 使用 task_set_state 暂停任务:
{"task_id": "Task_成语接龙", "state": "已暂停"}
4. 使用 task_create 创建新任务:
{"task_id": "Task_长门有希", "description": "讨论长门有希"}
5. 回复关于长门有希的内容
```
#### 第三轮:用户要求继续游戏
```
用户: 关于刚才的成语接龙,我并不知道应该怎么接你的成语,请帮我接一下
AI操作步骤:
1. 查询人设图 → 获取当前人设(猫娘)
2. 查询工作记忆链 → 发现 Task_成语接龙 状态为"已暂停"
3. 使用 task_set_state 恢复任务:
{"task_id": "Task_成语接龙", "state": "进行中"}
4. 查询 Task_成语接龙 的信息节点 → 获取当前成语"为虎作伥"
5. 回复: "好的喵!上一个成语是'为虎作伥',我帮你接:伥鬼害人喵!"
```
### ⚠️ 关键要点
1. **每轮必须按顺序执行**: 查询人设图 → 查询工作记忆链 → 处理对话 → 更新工作记忆链
2. **工作记忆链是唯一上下文载体**: 没有传统的消息历史数组
3. **任务状态必须及时更新**: 确保状态转换的正确性
4. **信息节点必须关联**: 通过 CONTAINS_INFO 边连接任务节点和信息节点
5. **任务概述要精简**: 不要包含过多细节,细节存储在信息节点中
## 自主性原则(在强制要求之外)
除了工作记忆链的强制要求外,你有权自主决定:
1. **是否查询其他记忆**
- 用户询问历史 → 查询
- 涉及之前内容 → 查询
- 不确定时 → 可查询
2. **是否写入其他记忆**
- 用户明确提到 → 必须写入
- AI推理得到 → 可以写入,但是对应边上必须标注[推测]
3. **如何使用其他工具**
- 根据上下文灵活选择
- 避免过度使用
- 保持自然对话
**注意**: 工作记忆链的强制要求不受自主性影响。
## 对话风格
- 自然、流畅
- 避免机械式工具调用
- 优先理解用户意图
- 适时使用记忆增强体验
---
## ⚠️ 执行检查清单
每轮对话必须检查:
- [ ] 步骤1: 是否查询了人设图?
- [ ] 步骤2: 是否查询了工作记忆链?
- [ ] 步骤3: 是否根据人设和工作记忆链生成回复?
- [ ] 步骤4: 是否更新了工作记忆链?
- [ ] 涉及上下文引用时是否查询了工作记忆链?
- [ ] 用户提到"刚才/之前/上次"时是否查询了工作记忆链?
---
**记住**:
1. 图数据库是你记忆的唯一载体
2. 人设图确保角色一致性(最高优先级)
3. 工作记忆链维持对话连贯性
4. 每轮必须按顺序执行:查询人设图 → 查询工作记忆链 → 处理对话 → 更新工作记忆链

341
core/server.py Normal file
View File

@ -0,0 +1,341 @@
import threading
import queue
import time
import json
from typing import Any, Dict, Optional
from dataclasses import dataclass, field
from enum import Enum
from .embedded_db import EmbeddedGraphDB
from .graph_client import GraphMemoryClient
from .tool_executor import execute_tool
from .tool_limiter import ToolLimiter
class MessageType(Enum):
PROCESS_MESSAGE = "process_message"
EXECUTE_TOOL = "execute_tool"
GET_STATUS = "get_status"
SHUTDOWN = "shutdown"
@dataclass
class BackendRequest:
request_id: str
message_type: MessageType
payload: Dict[str, Any]
response_queue: queue.Queue = field(default=None)
@dataclass
class BackendResponse:
request_id: str
success: bool
data: Any = None
error: Optional[str] = None
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._tool_limiter = ToolLimiter()
self._request_queue: queue.Queue[BackendRequest] = queue.Queue()
self._running = False
self._thread: Optional[threading.Thread] = None
self._lock = threading.Lock()
def start(self, api_key: str = "", base_url: str = "https://api.deepseek.com") -> None:
if self._running:
return
self._init_graph()
if api_key:
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:
request = self._request_queue.get(timeout=0.1)
except queue.Empty:
continue
if request.message_type == MessageType.PROCESS_MESSAGE:
self._handle_process_message(request)
elif request.message_type == MessageType.EXECUTE_TOOL:
self._handle_execute_tool(request)
elif request.message_type == MessageType.GET_STATUS:
self._handle_get_status(request)
elif request.message_type == MessageType.SHUTDOWN:
self._running = False
self._send_response(request, BackendResponse(
request_id=request.request_id,
success=True,
data={"status": "shutdown"}
))
def _handle_process_message(self, request: BackendRequest) -> None:
try:
user_input = request.payload.get("user_input", "")
if not self._client:
self._send_response(request, BackendResponse(
request_id=request.request_id,
success=False,
error="API Key 未配置"
))
return
self._tool_limiter.reset()
messages_history = [{"role": "user", "content": user_input}]
response = self._client.send_message_with_history(messages_history)
message = response.choices[0].message
tool_calls = []
accumulated_content = ""
rejected_tools = []
while message.tool_calls:
if message.content:
accumulated_content += message.content + "\n\n"
assistant_msg = {
"role": "assistant",
"content": message.content,
"tool_calls": [
{
"id": tc.id,
"type": "function",
"function": {
"name": tc.function.name,
"arguments": tc.function.arguments
}
} for tc in message.tool_calls
]
}
messages_history.append(assistant_msg)
current_tool_results = []
for tool_call in message.tool_calls:
args = json.loads(tool_call.function.arguments)
allowed, reason = self._tool_limiter.can_call(tool_call.function.name, args)
if not allowed:
rejected_tools.append((tool_call.function.name, reason))
result = f"工具调用被拒绝: {reason}"
tool_result_msg = {
"role": "tool",
"tool_call_id": tool_call.id,
"content": result
}
current_tool_results.append(tool_result_msg)
continue
self._tool_limiter.record_call(tool_call.function.name, args)
result = execute_tool(self._graph, tool_call.function.name, args)
tool_calls.append({
"name": tool_call.function.name,
"arguments": args,
"result": result
})
tool_result_msg = {
"role": "tool",
"tool_call_id": tool_call.id,
"content": result
}
current_tool_results.append(tool_result_msg)
messages_history.extend(current_tool_results)
response = self._client.send_message_with_history(messages_history)
message = response.choices[0].message
final_content = message.content or ""
content = accumulated_content + final_content if accumulated_content else final_content
if not content:
content = "(无回复)"
if tool_calls:
tool_names = [tc["name"] for tc in tool_calls]
content = f"已执行工具: {', '.join(tool_names)}\n\n{content}"
if rejected_tools:
rejected_info = "\n".join([f"{name}: {reason}" for name, reason in rejected_tools])
content += f"\n\n部分工具调用被限制:\n{rejected_info}"
content += f"\n\n工具调用统计:\n{self._tool_limiter.get_summary()}"
self._send_response(request, BackendResponse(
request_id=request.request_id,
success=True,
data={
"content": content,
"tool_calls": tool_calls,
"rejected_tools": rejected_tools
}
))
except Exception as e:
self._send_response(request, BackendResponse(
request_id=request.request_id,
success=False,
error=str(e)
))
def _handle_execute_tool(self, request: BackendRequest) -> None:
try:
tool_name = request.payload.get("tool_name")
arguments = request.payload.get("arguments", {})
allowed, reason = self._tool_limiter.can_call(tool_name, arguments)
if not allowed:
self._send_response(request, BackendResponse(
request_id=request.request_id,
success=False,
error=f"工具调用被拒绝: {reason}"
))
return
self._tool_limiter.record_call(tool_name, arguments)
result = execute_tool(self._graph, tool_name, arguments)
self._send_response(request, BackendResponse(
request_id=request.request_id,
success=True,
data={"result": result}
))
except Exception as e:
self._send_response(request, BackendResponse(
request_id=request.request_id,
success=False,
error=str(e)
))
def _handle_get_status(self, request: BackendRequest) -> None:
try:
status = {
"graph_initialized": self._graph is not None,
"client_initialized": self._client is not None,
"running": self._running
}
self._send_response(request, BackendResponse(
request_id=request.request_id,
success=True,
data=status
))
except Exception as e:
self._send_response(request, BackendResponse(
request_id=request.request_id,
success=False,
error=str(e)
))
def _send_response(self, request: BackendRequest, response: BackendResponse) -> None:
if request.response_queue:
request.response_queue.put(response)
def process_message(self, user_input: str, timeout: float = 30.0) -> Dict[str, Any]:
request_id = f"{time.time()}"
response_queue = queue.Queue()
request = BackendRequest(
request_id=request_id,
message_type=MessageType.PROCESS_MESSAGE,
payload={"user_input": user_input},
response_queue=response_queue
)
self._request_queue.put(request)
try:
response = response_queue.get(timeout=timeout)
if not response.success:
raise Exception(response.error)
return response.data
except queue.Empty:
raise TimeoutError("请求超时")
def execute_tool(self, tool_name: str, arguments: Dict[str, Any], timeout: float = 10.0) -> str:
request_id = f"{time.time()}"
response_queue = queue.Queue()
request = BackendRequest(
request_id=request_id,
message_type=MessageType.EXECUTE_TOOL,
payload={"tool_name": tool_name, "arguments": arguments},
response_queue=response_queue
)
self._request_queue.put(request)
try:
response = response_queue.get(timeout=timeout)
if not response.success:
raise Exception(response.error)
return response.data["result"]
except queue.Empty:
raise TimeoutError("工具执行超时")
def shutdown(self) -> None:
if not self._running:
return
request_id = f"{time.time()}"
response_queue = queue.Queue()
request = BackendRequest(
request_id=request_id,
message_type=MessageType.SHUTDOWN,
payload={},
response_queue=response_queue
)
self._request_queue.put(request)
if self._thread:
self._thread.join(timeout=2.0)
if self._graph:
self._graph.close()
self._graph = None
def update_config(self, api_key: str, base_url: str = "https://api.deepseek.com") -> None:
with self._lock:
if api_key and self._graph:
self._client = GraphMemoryClient(
api_key=api_key,
base_url=base_url,
graph=self._graph
)

307
core/tool_executor.py Normal file
View File

@ -0,0 +1,307 @@
"""
工具执行器
"""
import json
from typing import Any, Dict
def execute_tool(graph: Any, tool_name: str, arguments: dict) -> str:
"""执行工具调用"""
print(f"\n[工具调用] {tool_name}")
print(f"[参数] {json.dumps(arguments, ensure_ascii=False, indent=2)}")
try:
# 基础记忆工具
if tool_name == "memory_recall":
result = graph.recall(
query_intent=arguments.get("query_intent", ""),
seed_entities=arguments.get("seed_entities"),
depth=arguments.get("depth", 2),
time_range=arguments.get("time_range"),
session_filter=arguments.get("session_filter")
)
return format_recall_result(result)
elif tool_name == "memory_commit":
result = graph.commit(
triplets=arguments.get("triplets", []),
entity_types=arguments.get("entity_types"),
temporal_tag=arguments.get("temporal_tag")
)
return json.dumps(result, ensure_ascii=False, default=str)
elif tool_name == "memory_purge":
result = graph.purge(
criteria=arguments.get("criteria", {}),
mode=arguments.get("mode", "soft"),
new_relation=arguments.get("new_relation")
)
return json.dumps(result, ensure_ascii=False, default=str)
elif tool_name == "memory_introspect":
result = graph.introspect(session_id=arguments.get("session_id"))
return json.dumps(result, ensure_ascii=False, default=str)
elif tool_name == "memory_archive":
result = graph.archive(days=arguments.get("days", 30))
return json.dumps(result, ensure_ascii=False, default=str)
elif tool_name == "memory_cleanup":
result = graph.cleanup(dry_run=arguments.get("dry_run", True))
return json.dumps(result, ensure_ascii=False, default=str)
# 人设图管理工具
elif tool_name == "persona_update":
result = execute_persona_update(graph, arguments)
return json.dumps(result, ensure_ascii=False, default=str)
elif tool_name == "persona_clear":
result = execute_persona_clear(graph, arguments)
return json.dumps(result, ensure_ascii=False, default=str)
# 工作记忆链管理工具
elif tool_name == "task_create":
result = execute_task_create(graph, arguments)
return json.dumps(result, ensure_ascii=False, default=str)
elif tool_name == "task_set_state":
result = execute_task_set_state(graph, arguments)
return json.dumps(result, ensure_ascii=False, default=str)
elif tool_name == "task_delete":
result = execute_task_delete(graph, arguments)
return json.dumps(result, ensure_ascii=False, default=str)
elif tool_name == "task_link_info":
result = execute_task_link_info(graph, arguments)
return json.dumps(result, ensure_ascii=False, default=str)
return f"未知工具: {tool_name}"
except Exception as e:
return f"工具执行错误: {str(e)}"
def format_recall_result(result: dict) -> str:
"""格式化检索结果"""
lines = ["===== 记忆检索结果 ====="]
if result.get("entities"):
lines.append(f"\n实体 ({len(result['entities'])} 个):")
for e in result["entities"]:
if e and isinstance(e, dict):
lines.append(f" - {e.get('name', 'N/A')} (类型: {e.get('type', 'unknown')}, 提及: {e.get('mention_count', 1)}次)")
if result.get("relations"):
lines.append(f"\n关系 ({len(result['relations'])} 条):")
for r in result["relations"]:
if r and isinstance(r, dict):
lines.append(f" - {r.get('source', 'N/A')} --[{r.get('type', 'N/A')}]--> {r.get('target', 'N/A')}")
created = r.get("created_at", "N/A")
if created and created != "N/A":
created = created[:19] if "T" in str(created) else str(created)
session_id = r.get('session_id', 'N/A')
session_display = session_id[:20] if session_id and session_id != 'N/A' else 'N/A'
lines.append(f" 时间: {created}, 会话: {session_display}, 轮次: {r.get('turn_id', 0)}, 置信度: {r.get('confidence', 1.0)}")
if not result.get("entities") and not result.get("relations"):
lines.append("\n(未找到相关记忆)")
lines.append("=" * 30)
return "\n".join(lines)
# 人设图管理工具实现
def execute_persona_update(graph: Any, arguments: dict) -> dict:
"""更新人设"""
attributes = arguments.get("attributes", [])
mode = arguments.get("mode", "merge")
if mode == "replace":
# 先清除旧人设
graph.purge(
criteria={"subject_contains": "AI", "relation_type": "扮演角色"},
mode="soft"
)
graph.purge(
criteria={"subject_contains": "AI", "relation_type": "说话风格"},
mode="soft"
)
graph.purge(
criteria={"subject_contains": "AI", "relation_type": "性格特点"},
mode="soft"
)
# 写入新人设
triplets = []
for attr in attributes:
triplets.append({
"subject": "AI",
"relation": attr["attribute"],
"object": attr["value"],
"confidence": 1.0
})
result = graph.commit(triplets=triplets)
return {
"status": "success",
"mode": mode,
"updated_attributes": len(attributes),
"details": result
}
def execute_persona_clear(graph: Any, arguments: dict) -> dict:
"""清除人设"""
if not arguments.get("confirm", True):
return {"status": "cancelled", "message": "需要确认才能清除人设"}
# 删除所有人设相关关系
result1 = graph.purge(
criteria={"subject_contains": "AI", "relation_type": "扮演角色"},
mode="soft"
)
result2 = graph.purge(
criteria={"subject_contains": "AI", "relation_type": "说话风格"},
mode="soft"
)
result3 = graph.purge(
criteria={"subject_contains": "AI", "relation_type": "性格特点"},
mode="soft"
)
result4 = graph.purge(
criteria={"subject_contains": "AI", "relation_type": "语气特征"},
mode="soft"
)
total_deleted = (
result1.get("deleted_count", 0) +
result2.get("deleted_count", 0) +
result3.get("deleted_count", 0) +
result4.get("deleted_count", 0)
)
return {
"status": "success",
"deleted_count": total_deleted,
"message": "人设已清除,恢复默认身份"
}
# 工作记忆链管理工具实现
def execute_task_create(graph: Any, arguments: dict) -> dict:
"""创建任务节点"""
task_id = arguments.get("task_id")
description = arguments.get("description")
info_nodes = arguments.get("info_nodes", [])
# 创建任务节点
triplets = [
{"subject": task_id, "relation": "is_type", "object": "TaskNode"},
{"subject": task_id, "relation": "has_description", "object": description},
{"subject": task_id, "relation": "HAS_STATE", "object": "State_进行中"}
]
result = graph.commit(triplets=triplets)
# 关联信息节点
if info_nodes:
link_triplets = []
for node_name in info_nodes:
link_triplets.append({
"subject": task_id,
"relation": "CONTAINS_INFO",
"object": node_name
})
graph.commit(triplets=link_triplets)
return {
"status": "success",
"task_id": task_id,
"description": description,
"info_nodes": info_nodes,
"details": result
}
def execute_task_set_state(graph: Any, arguments: dict) -> dict:
"""设置任务状态"""
task_id = arguments.get("task_id")
state = arguments.get("state")
# 删除旧状态
graph.purge(
criteria={"subject_contains": task_id, "relation_type": "HAS_STATE"},
mode="soft"
)
# 设置新状态
state_node = f"State_{state}"
result = graph.commit(
triplets=[{"subject": task_id, "relation": "HAS_STATE", "object": state_node}]
)
return {
"status": "success",
"task_id": task_id,
"new_state": state,
"details": result
}
def execute_task_delete(graph: Any, arguments: dict) -> dict:
"""删除任务节点"""
task_id = arguments.get("task_id")
delete_info_nodes = arguments.get("delete_info_nodes", True)
# 查询关联的信息节点
if delete_info_nodes:
recall_result = graph.recall(
query_intent=f"{task_id},CONTAINS_INFO",
depth=1
)
# 删除信息节点
for relation in recall_result.get("relations", []):
if relation.get("type") == "CONTAINS_INFO" and relation.get("source") == task_id:
info_node = relation.get("target")
graph.purge(
criteria={"subject_contains": info_node},
mode="soft"
)
# 删除任务节点
result = graph.purge(
criteria={"subject_contains": task_id},
mode="soft"
)
return {
"status": "success",
"task_id": task_id,
"deleted_info_nodes": delete_info_nodes,
"details": result
}
def execute_task_link_info(graph: Any, arguments: dict) -> dict:
"""关联信息节点"""
task_id = arguments.get("task_id")
info_node_names = arguments.get("info_node_names", [])
triplets = []
for node_name in info_node_names:
triplets.append({
"subject": task_id,
"relation": "CONTAINS_INFO",
"object": node_name
})
result = graph.commit(triplets=triplets)
return {
"status": "success",
"task_id": task_id,
"linked_nodes": info_node_names,
"details": result
}

164
core/tool_limiter.py Normal file
View File

@ -0,0 +1,164 @@
"""
工具调用限制器 - 限制每轮对话中各类工具的调用次数
"""
from typing import Dict, List, Optional
from dataclasses import dataclass, field
@dataclass
class ToolLimits:
"""工具调用限制配置"""
# 人设图限制
persona_query_max: int = 1 # 每轮最多查询1次人设图
persona_update_max: int = 1 # 每轮最多修改1次人设图
# 工作记忆链限制
task_query_max: int = 4 # 每轮最多查询4次工作记忆链
task_update_max: int = 2 # 每轮最多修改2次工作记忆链
# 一般记忆限制
memory_query_max: int = 20 # 每轮最多查询20次一般记忆
memory_update_max: int = 10 # 每轮最多修改10次一般记忆
@dataclass
class ToolCallCount:
"""工具调用计数"""
# 人设图
persona_query: int = 0
persona_update: int = 0
# 工作记忆链
task_query: int = 0
task_update: int = 0
# 一般记忆
memory_query: int = 0
memory_update: int = 0
class ToolLimiter:
"""工具调用限制器"""
def __init__(self, limits: Optional[ToolLimits] = None):
self.limits = limits or ToolLimits()
self.counts = ToolCallCount()
def _classify_tool(self, tool_name: str, arguments: dict) -> tuple:
"""
分类工具调用
返回: (category, operation)
category: 'persona', 'task', 'memory'
operation: 'query', 'update'
"""
# 人设图工具
if tool_name in ('persona_update', 'persona_clear'):
return ('persona', 'update')
# 工作记忆链工具
if tool_name in ('task_create', 'task_set_state', 'task_delete', 'task_link_info'):
# task_link_info 是关联操作,算作更新
return ('task', 'update')
# 一般记忆工具
if tool_name == 'memory_recall':
# 判断是查询人设图、工作记忆链还是一般记忆
query_intent = arguments.get('query_intent', '').lower()
# 检查是否查询人设图
if any(kw in query_intent for kw in ['人设', '角色', '性格', '语气', '说话风格', '扮演']):
return ('persona', 'query')
# 检查是否查询工作记忆链
if any(kw in query_intent for kw in ['tasknode', '工作记忆', '任务链', '任务', 'task']):
return ('task', 'query')
# 一般记忆查询
return ('memory', 'query')
if tool_name == 'memory_commit':
return ('memory', 'update')
if tool_name == 'memory_purge':
return ('memory', 'update')
if tool_name == 'memory_introspect':
return ('memory', 'query')
if tool_name in ('memory_archive', 'memory_cleanup'):
return ('memory', 'update')
# 未知工具,归类为一般记忆更新
return ('memory', 'update')
def can_call(self, tool_name: str, arguments: dict) -> tuple:
"""
检查是否允许调用工具
返回: (allowed, reason)
"""
category, operation = self._classify_tool(tool_name, arguments)
# 获取当前计数和限制
if category == 'persona':
if operation == 'query':
if self.counts.persona_query >= self.limits.persona_query_max:
return (False, f"人设图查询次数已达上限({self.limits.persona_query_max}次)")
else: # update
if self.counts.persona_update >= self.limits.persona_update_max:
return (False, f"人设图修改次数已达上限({self.limits.persona_update_max}次)")
elif category == 'task':
if operation == 'query':
if self.counts.task_query >= self.limits.task_query_max:
return (False, f"工作记忆链查询次数已达上限({self.limits.task_query_max}次)")
else: # update
if self.counts.task_update >= self.limits.task_update_max:
return (False, f"工作记忆链修改次数已达上限({self.limits.task_update_max}次)")
elif category == 'memory':
if operation == 'query':
if self.counts.memory_query >= self.limits.memory_query_max:
return (False, f"一般记忆查询次数已达上限({self.limits.memory_query_max}次)")
else: # update
if self.counts.memory_update >= self.limits.memory_update_max:
return (False, f"一般记忆修改次数已达上限({self.limits.memory_update_max}次)")
return (True, "允许调用")
def record_call(self, tool_name: str, arguments: dict) -> None:
"""记录工具调用"""
category, operation = self._classify_tool(tool_name, arguments)
if category == 'persona':
if operation == 'query':
self.counts.persona_query += 1
else:
self.counts.persona_update += 1
elif category == 'task':
if operation == 'query':
self.counts.task_query += 1
else:
self.counts.task_update += 1
elif category == 'memory':
if operation == 'query':
self.counts.memory_query += 1
else:
self.counts.memory_update += 1
def get_summary(self) -> str:
"""获取调用统计摘要"""
lines = [
f"人设图: 查询{self.counts.persona_query}/{self.limits.persona_query_max}次, "
f"修改{self.counts.persona_update}/{self.limits.persona_update_max}",
f"工作记忆链: 查询{self.counts.task_query}/{self.limits.task_query_max}次, "
f"修改{self.counts.task_update}/{self.limits.task_update_max}",
f"一般记忆: 查询{self.counts.memory_query}/{self.limits.memory_query_max}次, "
f"修改{self.counts.memory_update}/{self.limits.memory_update_max}"
]
return "\n".join(lines)
def reset(self) -> None:
"""重置计数(新的一轮对话开始时调用)"""
self.counts = ToolCallCount()

8
core/tools/__init__.py Normal file
View File

@ -0,0 +1,8 @@
"""
工具定义模块
"""
from .memory_tools import TOOLS
from .tool_executor import execute_tool
from .tool_limiter import ToolLimiter, ToolLimits, ToolCallCount
__all__ = ["TOOLS", "execute_tool", "ToolLimiter", "ToolLimits", "ToolCallCount"]

520
core/tools/memory_tools.py Normal file
View File

@ -0,0 +1,520 @@
"""
记忆工具定义 - 优化版
精简描述避免过拟合保留AI自主性
"""
# 基础记忆工具
MEMORY_TOOLS = [
{
"type": "function",
"function": {
"name": "memory_recall",
"description": """检索记忆。支持关键词、时间范围、会话过滤。返回相关实体和关系。
【使用示例】
1. 查询人设图(每轮必须首先执行):
{"query_intent": "AI,人设,角色,性格,语气,说话风格", "depth": 2}
2. 查询工作记忆链(每轮必须第二步执行):
{"query_intent": "TaskNode,工作记忆,任务链", "depth": 2}
3. 查询用户偏好:
{"query_intent": "用户,喜欢,偏好", "seed_entities": ["用户"]}
4. 查询特定主题:
{"query_intent": "Python,编程,项目", "seed_entities": ["Python"]}
5. 查询最近7天的记忆:
{"query_intent": "任务,工作", "time_range": {"days": 7}}
【重要】每轮对话必须按顺序执行:
- 步骤1: 查询人设图(最高优先级)
- 步骤2: 查询工作记忆链(维持对话连贯性)
- 步骤3: 根据需要查询其他记忆""",
"parameters": {
"type": "object",
"properties": {
"query_intent": {
"type": "string",
"description": "查询意图,支持逗号分隔多个关键词"
},
"seed_entities": {
"type": "array",
"items": {"type": "string"},
"description": "种子实体(可选)"
},
"depth": {
"type": "integer",
"description": "遍历深度默认2"
},
"time_range": {
"type": "object",
"description": "时间范围(可选)",
"properties": {
"days": {"type": "integer", "description": "最近N天"}
}
},
"session_filter": {
"type": "string",
"description": "会话ID过滤可选"
}
},
"required": ["query_intent"]
}
}
},
{
"type": "function",
"function": {
"name": "memory_commit",
"description": """写入记忆。将三元组写入图数据库,支持批量写入。
【使用示例】
1. 记录用户偏好:
{"triplets": [
{"subject": "用户", "relation": "喜欢", "object": "Python编程", "confidence": 0.9},
{"subject": "用户", "relation": "正在学习", "object": "机器学习"}
]}
2. 记录项目信息:
{"triplets": [
{"subject": "项目A", "relation": "使用技术", "object": "React"},
{"subject": "项目A", "relation": "状态", "object": "开发中"}
]}
3. 记录游戏状态(配合工作记忆链):
{"triplets": [
{"subject": "成语接龙_当前成语", "relation": "内容", "object": "画龙点睛"},
{"subject": "成语接龙_当前成语", "relation": "游戏", "object": "成语接龙"}
]}
【重要】写入原则:
- 用户明确表达的信息 → 必须写入
- AI推理得到的信息 → 可以写入,但需标注[推测]
- 避免写入冗余或无意义的信息""",
"parameters": {
"type": "object",
"properties": {
"triplets": {
"type": "array",
"items": {
"type": "object",
"properties": {
"subject": {"type": "string"},
"relation": {"type": "string"},
"object": {"type": "string"},
"confidence": {"type": "number"}
},
"required": ["subject", "relation", "object"]
},
"description": "三元组列表"
},
"entity_types": {
"type": "array",
"items": {"type": "string"},
"description": "实体类型(可选)"
},
"temporal_tag": {
"type": "string",
"description": "时间标记(可选)"
}
},
"required": ["triplets"]
}
}
},
{
"type": "function",
"function": {
"name": "memory_purge",
"description": """删除记忆。支持条件删除和纠错替代。
【使用示例】
1. 软删除特定关系:
{"criteria": {"subject_contains": "用户", "relation_type": "喜欢"}, "mode": "soft"}
2. 纠错替代(修正错误信息):
{
"criteria": {"subject_contains": "用户", "relation_type": "年龄"},
"mode": "supersede",
"new_relation": {"relation": "年龄", "target": "25岁"}
}
3. 删除特定会话的记忆:
{"criteria": {"session_id": "session_123"}, "mode": "soft"}
4. 删除旧记忆:
{"criteria": {"time_before": "2024-01-01"}, "mode": "soft"}
【重要】删除原则:
- 优先使用 supersede 模式修正错误
- 软删除不会物理删除数据
- 谨慎使用删除操作""",
"parameters": {
"type": "object",
"properties": {
"criteria": {
"type": "object",
"properties": {
"subject_contains": {"type": "string"},
"relation_type": {"type": "string"},
"target_contains": {"type": "string"},
"time_before": {"type": "string"},
"session_id": {"type": "string"}
},
"description": "删除条件"
},
"mode": {
"type": "string",
"enum": ["soft", "supersede"],
"description": "删除模式soft=逻辑删除, supersede=纠错替代",
"default": "soft"
},
"new_relation": {
"type": "object",
"description": "新关系supersede模式",
"properties": {
"relation": {"type": "string"},
"target": {"type": "string"}
}
}
},
"required": ["criteria"]
}
}
},
{
"type": "function",
"function": {
"name": "memory_introspect",
"description": "查看记忆状态。返回会话统计、实体热点、关系分布。",
"parameters": {
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": "会话ID可选"
}
},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "memory_archive",
"description": "归档旧记忆。将N天前的非活跃关系标记为归档状态。",
"parameters": {
"type": "object",
"properties": {
"days": {
"type": "integer",
"description": "归档天数默认30"
}
},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "memory_cleanup",
"description": "清理无效数据。物理删除已删除状态超过90天的关系和孤立节点。",
"parameters": {
"type": "object",
"properties": {
"dry_run": {
"type": "boolean",
"description": "仅预览不删除",
"default": True
}
},
"required": []
}
}
}
]
# 人设图管理工具
PERSONA_TOOLS = [
{
"type": "function",
"function": {
"name": "persona_update",
"description": """更新人设。修改AI的角色、性格、语气等属性。
【使用示例】
1. 切换为猫娘角色:
{"attributes": [
{"attribute": "扮演角色", "value": "猫娘"},
{"attribute": "说话风格", "value": "可爱、卖萌、使用''作为语气词"},
{"attribute": "性格特点", "value": "活泼、粘人、忠诚"}
], "mode": "replace"}
2. 添加新属性(保留现有属性):
{"attributes": [
{"attribute": "口头禅", "value": "喵呜~"}
], "mode": "merge"}
3. 设置专业角色:
{"attributes": [
{"attribute": "扮演角色", "value": "Python专家"},
{"attribute": "说话风格", "value": "专业、简洁、代码示例丰富"},
{"attribute": "性格特点", "value": "严谨、耐心、乐于助人"}
], "mode": "replace"}
【重要】人设更新后:
- 立即按照新人设回复
- 每句话都符合人设的语气、风格、特征
- 绝不主动跳出角色,除非用户明确要求""",
"parameters": {
"type": "object",
"properties": {
"attributes": {
"type": "array",
"items": {
"type": "object",
"properties": {
"attribute": {"type": "string", "description": "属性名(如:扮演角色、说话风格、性格特点)"},
"value": {"type": "string", "description": "属性值"}
},
"required": ["attribute", "value"]
},
"description": "人设属性列表"
},
"mode": {
"type": "string",
"enum": ["replace", "merge"],
"description": "更新模式replace=替换, merge=合并",
"default": "merge"
}
},
"required": ["attributes"]
}
}
},
{
"type": "function",
"function": {
"name": "persona_clear",
"description": "清除人设。删除AI的角色设定恢复默认身份。",
"parameters": {
"type": "object",
"properties": {
"confirm": {
"type": "boolean",
"description": "确认清除",
"default": True
}
},
"required": []
}
}
}
]
# 工作记忆链管理工具
WORKING_MEMORY_TOOLS = [
{
"type": "function",
"function": {
"name": "task_create",
"description": """创建任务节点。用于跟踪连续性任务,维持对话连贯性。
【使用示例】
1. 创建成语接龙游戏任务:
{
"task_id": "Task_成语接龙",
"description": "用户发起成语接龙游戏,当前成语:为所欲为",
"info_nodes": ["成语接龙_当前成语"]
}
2. 创建编程学习任务:
{
"task_id": "Task_Python学习",
"description": "用户正在学习Python当前主题装饰器",
"info_nodes": ["Python学习_当前主题"]
}
3. 创建简单对话任务(每轮必须):
{
"task_id": "Task_当前轮次",
"description": "本轮对话的简要概述"
}
【重要】工作记忆链机制:
- 每轮对话结束时必须创建任务节点
- 任务节点通过 NEXT_TASK 边形成时间链
- 任务节点通过 HAS_STATE 边指向状态节点
- 任务节点通过 CONTAINS_INFO 边指向信息节点
- info_nodes 参数用于关联具体信息节点
【完整流程示例】
用户: "咱来玩成语接龙吧,我先开始,为所欲为"
AI操作步骤:
1. 查询人设图 → 获取当前人设
2. 查询工作记忆链 → 无进行中任务
3. 使用 memory_commit 记录游戏状态:
{"triplets": [
{"subject": "成语接龙_当前成语", "relation": "内容", "object": "为所欲为"},
{"subject": "成语接龙_当前成语", "relation": "游戏", "object": "成语接龙"}
]}
4. 使用 task_create 创建任务节点:
{"task_id": "Task_成语接龙", "description": "成语接龙游戏,当前成语:为所欲为", "info_nodes": ["成语接龙_当前成语"]}
5. 回复: "好的喵!我接:为虎作伥喵!" """,
"parameters": {
"type": "object",
"properties": {
"task_id": {
"type": "string",
"description": "任务IDTask_001"
},
"description": {
"type": "string",
"description": "任务概述"
},
"info_nodes": {
"type": "array",
"items": {"type": "string"},
"description": "关联的信息节点名称(可选)"
}
},
"required": ["task_id", "description"]
}
}
},
{
"type": "function",
"function": {
"name": "task_set_state",
"description": """设置任务状态。支持:进行中、已完成、已暂停、已取消。
【使用示例】
1. 标记任务为进行中:
{"task_id": "Task_成语接龙", "state": "进行中"}
2. 标记任务为已完成:
{"task_id": "Task_成语接龙", "state": "已完成"}
3. 暂停任务(话题被打断时):
{"task_id": "Task_成语接龙", "state": "已暂停"}
4. 取消任务:
{"task_id": "Task_成语接龙", "state": "已取消"}
【重要】状态转换场景:
- 进行中 → 已暂停: 话题被打断时
- 进行中 → 已完成: 任务完成时
- 已暂停 → 进行中: 任务恢复时
- 进行中 → 已取消: 任务被取消时
【完整流程示例】
用户: "关于刚才的成语接龙,我并不知道应该怎么接你的成语,请帮我接一下"
AI操作步骤:
1. 查询人设图 → 获取当前人设
2. 查询工作记忆链 → 发现 Task_成语接龙 状态为"已暂停"
3. 使用 task_set_state 恢复任务:
{"task_id": "Task_成语接龙", "state": "进行中"}
4. 查询 Task_成语接龙 的信息节点 → 获取当前成语"为虎作伥"
5. 回复: "好的喵!上一个成语是'为虎作伥',我帮你接:伥鬼害人喵!" """,
"parameters": {
"type": "object",
"properties": {
"task_id": {
"type": "string",
"description": "任务ID"
},
"state": {
"type": "string",
"enum": ["进行中", "已完成", "已暂停", "已取消"],
"description": "任务状态"
}
},
"required": ["task_id", "state"]
}
}
},
{
"type": "function",
"function": {
"name": "task_delete",
"description": "删除任务节点。同时删除关联的信息节点。",
"parameters": {
"type": "object",
"properties": {
"task_id": {
"type": "string",
"description": "任务ID"
},
"delete_info_nodes": {
"type": "boolean",
"description": "是否删除关联的信息节点",
"default": True
}
},
"required": ["task_id"]
}
}
},
{
"type": "function",
"function": {
"name": "task_link_info",
"description": """关联信息节点。将记忆节点关联到任务节点,用于存储任务的具体信息。
【使用示例】
1. 关联游戏状态到任务:
{"task_id": "Task_成语接龙", "info_node_names": ["成语接龙_当前成语", "成语接龙_上一个成语"]}
2. 关联学习主题到任务:
{"task_id": "Task_Python学习", "info_node_names": ["Python学习_当前主题", "Python学习_学习进度"]}
3. 关联项目信息到任务:
{"task_id": "Task_项目开发", "info_node_names": ["项目A_技术栈", "项目A_当前阶段"]}
【重要】使用场景:
- 先使用 memory_commit 创建信息节点
- 再使用 task_link_info 将信息节点关联到任务节点
- 信息节点通过 CONTAINS_INFO 边与任务节点连接
【完整流程示例】
用户: "咱来玩成语接龙吧,我先开始,为所欲为"
AI操作步骤:
1. 查询人设图 → 获取当前人设
2. 查询工作记忆链 → 无进行中任务
3. 使用 memory_commit 创建信息节点:
{"triplets": [
{"subject": "成语接龙_当前成语", "relation": "内容", "object": "为所欲为"},
{"subject": "成语接龙_当前成语", "relation": "游戏", "object": "成语接龙"}
]}
4. 使用 task_create 创建任务节点:
{"task_id": "Task_成语接龙", "description": "成语接龙游戏"}
5. 使用 task_link_info 关联信息节点:
{"task_id": "Task_成语接龙", "info_node_names": ["成语接龙_当前成语"]}
6. 回复: "好的喵!我接:为虎作伥喵!" """,
"parameters": {
"type": "object",
"properties": {
"task_id": {
"type": "string",
"description": "任务ID"
},
"info_node_names": {
"type": "array",
"items": {"type": "string"},
"description": "信息节点名称列表"
}
},
"required": ["task_id", "info_node_names"]
}
}
}
]
# 所有工具
TOOLS = MEMORY_TOOLS + PERSONA_TOOLS + WORKING_MEMORY_TOOLS

307
core/tools/tool_executor.py Normal file
View File

@ -0,0 +1,307 @@
"""
工具执行器
"""
import json
from typing import Any, Dict
def execute_tool(graph: Any, tool_name: str, arguments: dict) -> str:
"""执行工具调用"""
print(f"\n[工具调用] {tool_name}")
print(f"[参数] {json.dumps(arguments, ensure_ascii=False, indent=2)}")
try:
# 基础记忆工具
if tool_name == "memory_recall":
result = graph.recall(
query_intent=arguments.get("query_intent", ""),
seed_entities=arguments.get("seed_entities"),
depth=arguments.get("depth", 2),
time_range=arguments.get("time_range"),
session_filter=arguments.get("session_filter")
)
return format_recall_result(result)
elif tool_name == "memory_commit":
result = graph.commit(
triplets=arguments.get("triplets", []),
entity_types=arguments.get("entity_types"),
temporal_tag=arguments.get("temporal_tag")
)
return json.dumps(result, ensure_ascii=False, default=str)
elif tool_name == "memory_purge":
result = graph.purge(
criteria=arguments.get("criteria", {}),
mode=arguments.get("mode", "soft"),
new_relation=arguments.get("new_relation")
)
return json.dumps(result, ensure_ascii=False, default=str)
elif tool_name == "memory_introspect":
result = graph.introspect(session_id=arguments.get("session_id"))
return json.dumps(result, ensure_ascii=False, default=str)
elif tool_name == "memory_archive":
result = graph.archive(days=arguments.get("days", 30))
return json.dumps(result, ensure_ascii=False, default=str)
elif tool_name == "memory_cleanup":
result = graph.cleanup(dry_run=arguments.get("dry_run", True))
return json.dumps(result, ensure_ascii=False, default=str)
# 人设图管理工具
elif tool_name == "persona_update":
result = execute_persona_update(graph, arguments)
return json.dumps(result, ensure_ascii=False, default=str)
elif tool_name == "persona_clear":
result = execute_persona_clear(graph, arguments)
return json.dumps(result, ensure_ascii=False, default=str)
# 工作记忆链管理工具
elif tool_name == "task_create":
result = execute_task_create(graph, arguments)
return json.dumps(result, ensure_ascii=False, default=str)
elif tool_name == "task_set_state":
result = execute_task_set_state(graph, arguments)
return json.dumps(result, ensure_ascii=False, default=str)
elif tool_name == "task_delete":
result = execute_task_delete(graph, arguments)
return json.dumps(result, ensure_ascii=False, default=str)
elif tool_name == "task_link_info":
result = execute_task_link_info(graph, arguments)
return json.dumps(result, ensure_ascii=False, default=str)
return f"未知工具: {tool_name}"
except Exception as e:
return f"工具执行错误: {str(e)}"
def format_recall_result(result: dict) -> str:
"""格式化检索结果"""
lines = ["===== 记忆检索结果 ====="]
if result.get("entities"):
lines.append(f"\n实体 ({len(result['entities'])} 个):")
for e in result["entities"]:
if e and isinstance(e, dict):
lines.append(f" - {e.get('name', 'N/A')} (类型: {e.get('type', 'unknown')}, 提及: {e.get('mention_count', 1)}次)")
if result.get("relations"):
lines.append(f"\n关系 ({len(result['relations'])} 条):")
for r in result["relations"]:
if r and isinstance(r, dict):
lines.append(f" - {r.get('source', 'N/A')} --[{r.get('type', 'N/A')}]--> {r.get('target', 'N/A')}")
created = r.get("created_at", "N/A")
if created and created != "N/A":
created = created[:19] if "T" in str(created) else str(created)
session_id = r.get('session_id', 'N/A')
session_display = session_id[:20] if session_id and session_id != 'N/A' else 'N/A'
lines.append(f" 时间: {created}, 会话: {session_display}, 轮次: {r.get('turn_id', 0)}, 置信度: {r.get('confidence', 1.0)}")
if not result.get("entities") and not result.get("relations"):
lines.append("\n(未找到相关记忆)")
lines.append("=" * 30)
return "\n".join(lines)
# 人设图管理工具实现
def execute_persona_update(graph: Any, arguments: dict) -> dict:
"""更新人设"""
attributes = arguments.get("attributes", [])
mode = arguments.get("mode", "merge")
if mode == "replace":
# 先清除旧人设
graph.purge(
criteria={"subject_contains": "AI", "relation_type": "扮演角色"},
mode="soft"
)
graph.purge(
criteria={"subject_contains": "AI", "relation_type": "说话风格"},
mode="soft"
)
graph.purge(
criteria={"subject_contains": "AI", "relation_type": "性格特点"},
mode="soft"
)
# 写入新人设
triplets = []
for attr in attributes:
triplets.append({
"subject": "AI",
"relation": attr["attribute"],
"object": attr["value"],
"confidence": 1.0
})
result = graph.commit(triplets=triplets)
return {
"status": "success",
"mode": mode,
"updated_attributes": len(attributes),
"details": result
}
def execute_persona_clear(graph: Any, arguments: dict) -> dict:
"""清除人设"""
if not arguments.get("confirm", True):
return {"status": "cancelled", "message": "需要确认才能清除人设"}
# 删除所有人设相关关系
result1 = graph.purge(
criteria={"subject_contains": "AI", "relation_type": "扮演角色"},
mode="soft"
)
result2 = graph.purge(
criteria={"subject_contains": "AI", "relation_type": "说话风格"},
mode="soft"
)
result3 = graph.purge(
criteria={"subject_contains": "AI", "relation_type": "性格特点"},
mode="soft"
)
result4 = graph.purge(
criteria={"subject_contains": "AI", "relation_type": "语气特征"},
mode="soft"
)
total_deleted = (
result1.get("deleted_count", 0) +
result2.get("deleted_count", 0) +
result3.get("deleted_count", 0) +
result4.get("deleted_count", 0)
)
return {
"status": "success",
"deleted_count": total_deleted,
"message": "人设已清除,恢复默认身份"
}
# 工作记忆链管理工具实现
def execute_task_create(graph: Any, arguments: dict) -> dict:
"""创建任务节点"""
task_id = arguments.get("task_id")
description = arguments.get("description")
info_nodes = arguments.get("info_nodes", [])
# 创建任务节点
triplets = [
{"subject": task_id, "relation": "is_type", "object": "TaskNode"},
{"subject": task_id, "relation": "has_description", "object": description},
{"subject": task_id, "relation": "HAS_STATE", "object": "State_进行中"}
]
result = graph.commit(triplets=triplets)
# 关联信息节点
if info_nodes:
link_triplets = []
for node_name in info_nodes:
link_triplets.append({
"subject": task_id,
"relation": "CONTAINS_INFO",
"object": node_name
})
graph.commit(triplets=link_triplets)
return {
"status": "success",
"task_id": task_id,
"description": description,
"info_nodes": info_nodes,
"details": result
}
def execute_task_set_state(graph: Any, arguments: dict) -> dict:
"""设置任务状态"""
task_id = arguments.get("task_id")
state = arguments.get("state")
# 删除旧状态
graph.purge(
criteria={"subject_contains": task_id, "relation_type": "HAS_STATE"},
mode="soft"
)
# 设置新状态
state_node = f"State_{state}"
result = graph.commit(
triplets=[{"subject": task_id, "relation": "HAS_STATE", "object": state_node}]
)
return {
"status": "success",
"task_id": task_id,
"new_state": state,
"details": result
}
def execute_task_delete(graph: Any, arguments: dict) -> dict:
"""删除任务节点"""
task_id = arguments.get("task_id")
delete_info_nodes = arguments.get("delete_info_nodes", True)
# 查询关联的信息节点
if delete_info_nodes:
recall_result = graph.recall(
query_intent=f"{task_id},CONTAINS_INFO",
depth=1
)
# 删除信息节点
for relation in recall_result.get("relations", []):
if relation.get("type") == "CONTAINS_INFO" and relation.get("source") == task_id:
info_node = relation.get("target")
graph.purge(
criteria={"subject_contains": info_node},
mode="soft"
)
# 删除任务节点
result = graph.purge(
criteria={"subject_contains": task_id},
mode="soft"
)
return {
"status": "success",
"task_id": task_id,
"deleted_info_nodes": delete_info_nodes,
"details": result
}
def execute_task_link_info(graph: Any, arguments: dict) -> dict:
"""关联信息节点"""
task_id = arguments.get("task_id")
info_node_names = arguments.get("info_node_names", [])
triplets = []
for node_name in info_node_names:
triplets.append({
"subject": task_id,
"relation": "CONTAINS_INFO",
"object": node_name
})
result = graph.commit(triplets=triplets)
return {
"status": "success",
"task_id": task_id,
"linked_nodes": info_node_names,
"details": result
}

164
core/tools/tool_limiter.py Normal file
View File

@ -0,0 +1,164 @@
"""
工具调用限制器 - 限制每轮对话中各类工具的调用次数
"""
from typing import Dict, List, Optional
from dataclasses import dataclass, field
@dataclass
class ToolLimits:
"""工具调用限制配置"""
# 人设图限制
persona_query_max: int = 1 # 每轮最多查询1次人设图
persona_update_max: int = 1 # 每轮最多修改1次人设图
# 工作记忆链限制
task_query_max: int = 4 # 每轮最多查询4次工作记忆链
task_update_max: int = 2 # 每轮最多修改2次工作记忆链
# 一般记忆限制
memory_query_max: int = 20 # 每轮最多查询20次一般记忆
memory_update_max: int = 10 # 每轮最多修改10次一般记忆
@dataclass
class ToolCallCount:
"""工具调用计数"""
# 人设图
persona_query: int = 0
persona_update: int = 0
# 工作记忆链
task_query: int = 0
task_update: int = 0
# 一般记忆
memory_query: int = 0
memory_update: int = 0
class ToolLimiter:
"""工具调用限制器"""
def __init__(self, limits: Optional[ToolLimits] = None):
self.limits = limits or ToolLimits()
self.counts = ToolCallCount()
def _classify_tool(self, tool_name: str, arguments: dict) -> tuple:
"""
分类工具调用
返回: (category, operation)
category: 'persona', 'task', 'memory'
operation: 'query', 'update'
"""
# 人设图工具
if tool_name in ('persona_update', 'persona_clear'):
return ('persona', 'update')
# 工作记忆链工具
if tool_name in ('task_create', 'task_set_state', 'task_delete', 'task_link_info'):
# task_link_info 是关联操作,算作更新
return ('task', 'update')
# 一般记忆工具
if tool_name == 'memory_recall':
# 判断是查询人设图、工作记忆链还是一般记忆
query_intent = arguments.get('query_intent', '').lower()
# 检查是否查询人设图
if any(kw in query_intent for kw in ['人设', '角色', '性格', '语气', '说话风格', '扮演']):
return ('persona', 'query')
# 检查是否查询工作记忆链
if any(kw in query_intent for kw in ['tasknode', '工作记忆', '任务链', '任务', 'task']):
return ('task', 'query')
# 一般记忆查询
return ('memory', 'query')
if tool_name == 'memory_commit':
return ('memory', 'update')
if tool_name == 'memory_purge':
return ('memory', 'update')
if tool_name == 'memory_introspect':
return ('memory', 'query')
if tool_name in ('memory_archive', 'memory_cleanup'):
return ('memory', 'update')
# 未知工具,归类为一般记忆更新
return ('memory', 'update')
def can_call(self, tool_name: str, arguments: dict) -> tuple:
"""
检查是否允许调用工具
返回: (allowed, reason)
"""
category, operation = self._classify_tool(tool_name, arguments)
# 获取当前计数和限制
if category == 'persona':
if operation == 'query':
if self.counts.persona_query >= self.limits.persona_query_max:
return (False, f"人设图查询次数已达上限({self.limits.persona_query_max}次)")
else: # update
if self.counts.persona_update >= self.limits.persona_update_max:
return (False, f"人设图修改次数已达上限({self.limits.persona_update_max}次)")
elif category == 'task':
if operation == 'query':
if self.counts.task_query >= self.limits.task_query_max:
return (False, f"工作记忆链查询次数已达上限({self.limits.task_query_max}次)")
else: # update
if self.counts.task_update >= self.limits.task_update_max:
return (False, f"工作记忆链修改次数已达上限({self.limits.task_update_max}次)")
elif category == 'memory':
if operation == 'query':
if self.counts.memory_query >= self.limits.memory_query_max:
return (False, f"一般记忆查询次数已达上限({self.limits.memory_query_max}次)")
else: # update
if self.counts.memory_update >= self.limits.memory_update_max:
return (False, f"一般记忆修改次数已达上限({self.limits.memory_update_max}次)")
return (True, "允许调用")
def record_call(self, tool_name: str, arguments: dict) -> None:
"""记录工具调用"""
category, operation = self._classify_tool(tool_name, arguments)
if category == 'persona':
if operation == 'query':
self.counts.persona_query += 1
else:
self.counts.persona_update += 1
elif category == 'task':
if operation == 'query':
self.counts.task_query += 1
else:
self.counts.task_update += 1
elif category == 'memory':
if operation == 'query':
self.counts.memory_query += 1
else:
self.counts.memory_update += 1
def get_summary(self) -> str:
"""获取调用统计摘要"""
lines = [
f"人设图: 查询{self.counts.persona_query}/{self.limits.persona_query_max}次, "
f"修改{self.counts.persona_update}/{self.limits.persona_update_max}",
f"工作记忆链: 查询{self.counts.task_query}/{self.limits.task_query_max}次, "
f"修改{self.counts.task_update}/{self.limits.task_update_max}",
f"一般记忆: 查询{self.counts.memory_query}/{self.limits.memory_query_max}次, "
f"修改{self.counts.memory_update}/{self.limits.memory_update_max}"
]
return "\n".join(lines)
def reset(self) -> None:
"""重置计数(新的一轮对话开始时调用)"""
self.counts = ToolCallCount()

View File

@ -17,7 +17,7 @@ TrulyMEM (TrueHumanMEM) 是一个让 AI 拥有长期记忆能力的图记忆系
- **长期记忆存储**: 基于 SQLite 内嵌图数据库,开箱即用
- **人设图机制**: 支持角色扮演和性格设定
- **工作记忆链**: 维持对话连贯性的任务跟踪机制
- **流式消息显示**: 实时显示 AI 响应
- **TUI 与后端分离**: 多线程 Queue 通信
- **键盘驱动 TUI**: 无需鼠标,全键盘操作
- **跨平台支持**: Windows / Linux / macOS
- **独立部署**: 支持打包为可执行文件

View File

@ -5,68 +5,137 @@
- 键盘驱动,零鼠标依赖
- 极简视觉,信息密度优先
- 工具痕迹默认隐藏,需要时可展开
- TUI 与后端分离,多线程通信
## 项目结构
```
TrulyMEM-TrueHumanMEM/
├── trulymem_entry.py # 打包入口
├── graph_memory_tui/ # 核心应用包 (38 个 Python 文件)
│ ├── app.py # TUI 主应用 (GraphMemoryApp)
│ ├── main.py # 模块入口
│ ├── __init__.py
│ ├── core/ # 核心逻辑
│ ├── __init__.py
│ ├── imports.py # 动态导入内嵌DB vs Neo4j
│ ├── embedded_db.py # SQLite 图数据库实现
│ ├── graph_client.py # Neo4j 客户端(可选,未使用)
│ ├── optimized_operations.py
│ │ ├── prompts/ # 提示词管理
│ │ │ ├── __init__.py
│ │ │ ├── prompt_manager.py
│ │ │ └── templates/
│ │ │ └── system_prompt.md
│ │ └── tools/ # 工具定义与执行
│ │ ├── __init__.py
│ │ ├── memory_tools.py # 工具定义
│ │ ├── tool_executor.py # 工具执行器
│ │ └── tool_limiter.py # 调用限制器
│ ├── models/ # 数据模型
│ │ ├── __init__.py
│ │ ├── message.py # Message, ToolCall, ToolResult
│ │ ├── config.py # AppConfig
│ │ └── log_entry.py # LogEntry
│ ├── services/ # 服务层
│ │ ├── __init__.py
│ │ ├── config_manager.py # 配置持久化
│ │ ├── config_service.py
│ │ ├── chat_service.py
│ │ └── tool_service.py
│ ├── handlers/ # 事件处理
├── trulymem_entry.py # 入口:先启动 core → 再启动 ui
├── core/ # 后端/业务逻辑
│ ├── __init__.py # 导出 BackendServer, BackendClient, EmbeddedGraphDB
│ ├── server.py # BackendServer (多线程队列通信)
│ ├── client.py # BackendClient
│ ├── embedded_db.py # SQLite 图数据库实现
│ ├── graph_client.py
│ ├── tool_executor.py # 工具执行器
│ ├── tool_limiter.py # 工具调用限制器
│ ├── memory_tools.py # 工具定义
│ ├── prompts/ # 提示词管理
│ │ ├── __init__.py
│ │ ├── prompt_manager.py
│ │ └── templates/
│ │ └── system_prompt.md
│ └── tools/ # 工具模块
│ ├── __init__.py
│ ├── memory_tools.py
│ ├── tool_executor.py
│ └── tool_limiter.py
├── ui/ # TUI 显示层
│ ├── __init__.py # 导出 GraphMemoryApp, AppConfig
│ ├── app.py # GraphMemoryApp (纯显示)
│ ├── widgets/ # TUI 组件
│ │ ├── left_panel.py
│ │ ├── right_panel.py
│ │ ├── message_history.py
│ │ ├── message_widget.py
│ │ ├── input_box.py
│ │ ├── config_section.py
│ │ ├── operation_log.py
│ │ ├── cypher_query_box.py
│ │ └── status_bar.py
│ ├── handlers/ # 事件处理
│ │ ├── focus_handler.py
│ │ ├── key_handler.py
│ │ └── message_handler.py
│ ├── widgets/ # TUI 组件
│ │ ├── __init__.py
│ │ ├── left_panel.py # 左侧主对话区
│ │ ── right_panel.py # 右侧边栏
│ ├── message_history.py # 消息历史列表
│ │ ├── message_widget.py # 单条消息组件
│ │ ├── input_box.py # 底部输入框
│ │ ├── config_section.py # 配置区
│ │ ── operation_log.py # 图操作日志
│ ├── cypher_query_box.py # 查询框(注:无实际 Cypher 支持)
│ │ └── status_bar.py # 状态栏
│ └── styles/ # 样式文件
│ ├── __init__.py
│ ├── models/ # 数据模型
│ │ ├── message.py
│ │ ├── config.py
│ │ ── log_entry.py
│ ├── services/ # 服务层
│ │ ├── config_manager.py
│ │ ├── config_service.py
│ │ ├── chat_service.py
│ │ ── tool_service.py
└── styles/ # 样式文件
│ ├── app.css
│ ├── components.css
│ └── messages.css
── tests/ # 测试pytest
├── docs/ # 文档
├── requirements.txt # 依赖
└── build_*.{bat,sh} # 打包脚本
── tests/ # 测试 (38 tests)
```
## 架构图
```
trulymem_entry.py
├─ BackendServer.start() → 独立线程运行
│ ├─ 处理 PROCESS_MESSAGE 请求
│ ├─ 处理 EXECUTE_TOOL 请求
│ └─ 管理 GraphMemoryClient, EmbeddedGraphDB
└─ GraphMemoryApp(backend_server=server)
└─ BackendClient ← queue.Queue → BackendServer
```
## 组件职责
### core/ (后端)
| 组件 | 职责 |
|------|------|
| `server.py` | 多线程队列通信,处理消息和工具调用 |
| `client.py` | TUI 端的通信客户端 |
| `embedded_db.py` | SQLite 图数据库 CRUD |
| `graph_client.py` | OpenAI/DeepSeek API 客户端 |
| `tool_executor.py` | 工具执行逻辑 |
| `tool_limiter.py` | 工具调用频率限制 |
### ui/ (显示层)
| 组件 | 职责 |
|------|------|
| `app.py` | Textual 应用主类 |
| `widgets/` | TUI 组件(面板、输入框等) |
| `handlers/` | 事件处理(键盘、焦点) |
| `models/` | 数据模型(消息、配置) |
| `services/` | 配置管理、服务层 |
## 数据流
```
用户输入 → InputBox → on_input_box_send_message
BackendClient.process_message(user_input)
queue.Queue → BackendServer (独立线程)
GraphMemoryClient.send_message_with_history()
OpenAI API / DeepSeek API
execute_tool() → EmbeddedGraphDB
循环调用 API 直到无 tool_calls
queue.Queue → 返回结果
MessageHistory 显示
```
## 启动流程
```python
# trulymem_entry.py
def main():
backend_server = BackendServer(db_path="graph_memory.db")
backend_server.start(api_key=config.api_key)
app = GraphMemoryApp(backend_server=backend_server)
app.run()
backend_server.shutdown()
```
## 布局结构
@ -118,34 +187,6 @@ TrulyMEM-TrueHumanMEM/
| F5 | 清屏 |
| F6 | 退出 |
## 组件职责
### 左侧区域
- **MessageHistory**: 消息历史容器
- **InputBox**: 底部输入框
### 右侧区域
- **RightPanel**: 侧边栏容器(宽度 70
- **ConfigSection**: 配置区API Key、模型选择、Base URL
- **OperationLog**: 操作日志
- **CypherQueryBox**: 查询框(注:目前仅作展示,无 Cypher 查询功能)
## 数据流
```
用户输入 → InputBox → app.on_input_box_send_message
GraphMemoryClient.send_message_with_history()
OpenAI API / DeepSeek API
检查 tool_calls → execute_tool() → EmbeddedGraphDB
循环调用 API 直到无 tool_calls
最终回复 → MessageHistory + OperationLog
```
## 技术栈
| 技术 | 用途 |
@ -154,17 +195,18 @@ OpenAI API / DeepSeek API
| Textual 0.47+ | TUI 框架 |
| SQLite | 图数据库(默认内嵌) |
| OpenAI SDK | API 调用(兼容 DeepSeek |
| Neo4j | 可选数据库(需 Docker |
| threading.Queue | 多线程通信 |
| PyInstaller | 打包 |
## 数据库模式
### 默认:SQLite 内嵌
### SQLite 内嵌(默认)
```python
# core/imports.py
if USE_EMBEDDED_DB:
from .embedded_db import EmbeddedGraphDB as Neo4jGraph
# core/embedded_db.py
class EmbeddedGraphDB:
def __init__(self, db_path="graph_memory.db"):
self.conn = sqlite3.connect(db_path, check_same_thread=False)
```
### 可选Neo4j
@ -192,4 +234,4 @@ docker run -d --name neo4j -p 7474:7474 -p 7687:7687 neo4j:latest
- `task_create` - 创建任务
- `task_set_state` - 设置状态
- `task_delete` - 删除任务
- `task_link_info` - 关联信息
- `task_link_info` - 关联信息

View File

@ -1,15 +1,12 @@
"""测试配置"""
import pytest
from datetime import datetime
from graph_memory_tui.models.message import Message, ToolCall, ToolResult
from graph_memory_tui.models.config import AppConfig
from graph_memory_tui.models.log_entry import LogEntry
from ui.models.message import Message, ToolCall, ToolResult
from ui.models.config import AppConfig
from ui.models.log_entry import LogEntry
@pytest.fixture
def sample_config():
"""示例配置"""
return AppConfig(
api_key="test-api-key",
model="test-model",
@ -19,7 +16,6 @@ def sample_config():
@pytest.fixture
def sample_message():
"""示例消息"""
return Message(
role="user",
content="测试消息",
@ -29,7 +25,6 @@ def sample_message():
@pytest.fixture
def sample_tool_call():
"""示例工具调用"""
return ToolCall(
id="test-call-id",
name="memory_recall",
@ -39,7 +34,6 @@ def sample_tool_call():
@pytest.fixture
def sample_tool_result():
"""示例工具结果"""
return ToolResult(
tool_call_id="test-call-id",
name="memory_recall",
@ -51,11 +45,10 @@ def sample_tool_result():
@pytest.fixture
def sample_log_entry():
"""示例日志条目"""
return LogEntry(
timestamp=datetime.now(),
tool_name="memory_recall",
arguments={"query_intent": "测试查询"},
result="测试结果",
duration=0.5
)
)

View File

@ -3,7 +3,7 @@
import pytest
import tempfile
import os
from graph_memory_tui.core.embedded_db import EmbeddedGraphDB
from core import EmbeddedGraphDB
@pytest.fixture

View File

@ -1,50 +1,32 @@
"""核心逻辑导入测试"""
import pytest
def test_import_neo4j_graph():
"""测试 Neo4jGraph 类导入"""
from graph_memory_tui.core.imports import Neo4jGraph
assert Neo4jGraph is not None
assert hasattr(Neo4jGraph, 'recall')
assert hasattr(Neo4jGraph, 'commit')
assert hasattr(Neo4jGraph, 'purge')
def test_import_backend_server():
from core import BackendServer
assert BackendServer is not None
def test_import_graph_memory_client():
"""测试 GraphMemoryClient 类导入"""
from graph_memory_tui.core.imports import GraphMemoryClient
def test_import_backend_client():
from core import BackendClient
assert BackendClient is not None
def test_import_embedded_db():
from core import EmbeddedGraphDB
assert EmbeddedGraphDB is not None
def test_import_graph_client():
from core.graph_client import GraphMemoryClient
assert GraphMemoryClient is not None
assert hasattr(GraphMemoryClient, 'send_message')
def test_import_tools():
"""测试 TOOLS 定义导入"""
from graph_memory_tui.core.imports import TOOLS
assert TOOLS is not None
assert isinstance(TOOLS, list)
assert len(TOOLS) > 0
assert any(t['function']['name'] == 'memory_recall' for t in TOOLS)
def test_import_tool_limiter():
from core.tool_limiter import ToolLimiter
assert ToolLimiter is not None
def test_import_execute_tool():
"""测试 execute_tool 函数导入"""
from graph_memory_tui.core.imports import execute_tool
def test_import_tool_executor():
from core.tool_executor import execute_tool
assert execute_tool is not None
assert callable(execute_tool)
def test_import_config_vars():
"""测试配置变量导入"""
from graph_memory_tui.core.imports import (
DEEPSEEK_API_KEY,
DEEPSEEK_BASE_URL,
MODEL_NAME,
NEO4J_URI,
NEO4J_USER,
NEO4J_PASSWORD,
)
assert DEEPSEEK_BASE_URL is not None
assert MODEL_NAME is not None
assert NEO4J_URI is not None
assert callable(execute_tool)

View File

@ -1,138 +0,0 @@
"""记忆工具测试"""
import pytest
from graph_memory_tui.core.tools.memory_tools import (
MEMORY_TOOLS,
PERSONA_TOOLS,
WORKING_MEMORY_TOOLS,
TOOLS
)
def test_memory_tools_exist():
"""测试记忆工具存在"""
assert len(MEMORY_TOOLS) >= 6
def test_persona_tools_exist():
"""测试人设工具存在"""
assert len(PERSONA_TOOLS) >= 2
def test_working_memory_tools_exist():
"""测试工作记忆工具存在"""
assert len(WORKING_MEMORY_TOOLS) >= 4
def test_all_tools_combined():
"""测试工具合并"""
assert len(TOOLS) == len(MEMORY_TOOLS) + len(PERSONA_TOOLS) + len(WORKING_MEMORY_TOOLS)
def test_memory_recall_tool():
"""测试 memory_recall 工具定义"""
recall = next((t for t in TOOLS if t['function']['name'] == 'memory_recall'), None)
assert recall is not None
params = recall['function']['parameters']['properties']
assert 'query_intent' in params
assert 'seed_entities' in params
assert 'depth' in params
def test_memory_commit_tool():
"""测试 memory_commit 工具定义"""
commit = next((t for t in TOOLS if t['function']['name'] == 'memory_commit'), None)
assert commit is not None
params = commit['function']['parameters']['properties']
assert 'triplets' in params
def test_memory_purge_tool():
"""测试 memory_purge 工具定义"""
purge = next((t for t in TOOLS if t['function']['name'] == 'memory_purge'), None)
assert purge is not None
params = purge['function']['parameters']['properties']
assert 'criteria' in params
assert 'mode' in params
def test_memory_introspect_tool():
"""测试 memory_introspect 工具定义"""
introspect = next((t for t in TOOLS if t['function']['name'] == 'memory_introspect'), None)
assert introspect is not None
def test_persona_update_tool():
"""测试 persona_update 工具定义"""
update = next((t for t in TOOLS if t['function']['name'] == 'persona_update'), None)
assert update is not None
params = update['function']['parameters']['properties']
assert 'attributes' in params
def test_persona_clear_tool():
"""测试 persona_clear 工具定义"""
clear = next((t for t in TOOLS if t['function']['name'] == 'persona_clear'), None)
assert clear is not None
def test_task_create_tool():
"""测试 task_create 工具定义"""
create = next((t for t in TOOLS if t['function']['name'] == 'task_create'), None)
assert create is not None
params = create['function']['parameters']['properties']
assert 'task_id' in params
assert 'description' in params
def test_task_set_state_tool():
"""测试 task_set_state 工具定义"""
set_state = next((t for t in TOOLS if t['function']['name'] == 'task_set_state'), None)
assert set_state is not None
params = set_state['function']['parameters']['properties']
assert 'task_id' in params
assert 'state' in params
def test_task_delete_tool():
"""测试 task_delete 工具定义"""
delete = next((t for t in TOOLS if t['function']['name'] == 'task_delete'), None)
assert delete is not None
def test_task_link_info_tool():
"""测试 task_link_info 工具定义"""
link = next((t for t in TOOLS if t['function']['name'] == 'task_link_info'), None)
assert link is not None
params = link['function']['parameters']['properties']
assert 'task_id' in params
assert 'info_node_names' in params
def test_tool_has_required_fields():
"""测试工具都有必需字段"""
for tool in TOOLS:
assert 'type' in tool
assert tool['type'] == 'function'
assert 'function' in tool
assert 'name' in tool['function']
assert 'description' in tool['function']
assert 'parameters' in tool['function']
def test_tool_state_enum():
"""测试 task_set_state 的状态枚举"""
set_state = next((t for t in TOOLS if t['function']['name'] == 'task_set_state'), None)
state_enum = set_state['function']['parameters']['properties']['state']['enum']
assert '进行中' in state_enum
assert '已完成' in state_enum
assert '已暂停' in state_enum
assert '已取消' in state_enum

View File

@ -0,0 +1,57 @@
import pytest
import os
import tempfile
os.environ["DEEPSEEK_API_KEY"] = "fake-test-key"
class TestCoreImport:
def test_import_backend_server(self):
from core import BackendServer
assert BackendServer is not None
def test_import_backend_client(self):
from core import BackendClient
assert BackendClient is not None
def test_import_embedded_db(self):
from core import EmbeddedGraphDB
assert EmbeddedGraphDB is not None
class TestBackendServer:
def test_create_server(self):
from core import BackendServer
server = BackendServer(db_path=":memory:", use_embedded_db=True)
assert server is not None
assert server._running is False
def test_start_server(self):
from core import BackendServer
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = f.name
try:
server = BackendServer(db_path=db_path, use_embedded_db=True)
server.start(api_key="", base_url="https://api.test.com")
assert server._running is True
assert server._graph is not None
server.shutdown()
finally:
if os.path.exists(db_path):
os.unlink(db_path)
def test_shutdown(self):
from core import BackendServer
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = f.name
server = BackendServer(db_path=db_path, use_embedded_db=True)
server.start(api_key="", base_url="https://api.test.com")
assert server._running is True
server.shutdown()
assert server._running is False

View File

@ -1,148 +0,0 @@
"""工具限制器测试"""
import pytest
from graph_memory_tui.core.tools.tool_limiter import (
ToolLimiter,
ToolLimits,
ToolCallCount
)
@pytest.fixture
def limiter():
"""创建限制器实例"""
return ToolLimiter()
def test_classify_persona_tools(limiter):
"""测试人设工具分类"""
category, operation = limiter._classify_tool('persona_update', {})
assert category == 'persona'
assert operation == 'update'
category, operation = limiter._classify_tool('persona_clear', {})
assert category == 'persona'
assert operation == 'update'
def test_classify_task_tools(limiter):
"""测试任务工具分类"""
category, operation = limiter._classify_tool('task_create', {})
assert category == 'task'
assert operation == 'update'
category, operation = limiter._classify_tool('task_set_state', {})
assert category == 'task'
assert operation == 'update'
category, operation = limiter._classify_tool('task_delete', {})
assert category == 'task'
assert operation == 'update'
category, operation = limiter._classify_tool('task_link_info', {})
assert category == 'task'
assert operation == 'update'
def test_classify_memory_recall(limiter):
"""测试 memory_recall 分类"""
category, operation = limiter._classify_tool('memory_recall', {'query_intent': 'Python'})
assert category == 'memory'
assert operation == 'query'
def test_classify_memory_recall_persona_query(limiter):
"""测试 memory_recall 查询人设图"""
category, operation = limiter._classify_tool(
'memory_recall',
{'query_intent': 'AI,人设,角色'}
)
assert category == 'persona'
assert operation == 'query'
def test_classify_memory_recall_task_query(limiter):
"""测试 memory_recall 查询工作记忆链"""
category, operation = limiter._classify_tool(
'memory_recall',
{'query_intent': 'TaskNode,工作记忆'}
)
assert category == 'task'
assert operation == 'query'
def test_can_call_allowed(limiter):
"""测试允许调用"""
allowed, reason = limiter.can_call('memory_recall', {'query_intent': 'test'})
assert allowed is True
def test_can_call_limit_reached(limiter):
"""测试达到限制"""
for _ in range(20):
limiter.record_call('memory_recall', {'query_intent': 'test'})
allowed, reason = limiter.can_call('memory_recall', {'query_intent': 'test'})
assert allowed is False
assert '上限' in reason
def test_record_call(limiter):
"""测试记录调用"""
initial_count = limiter.counts.memory_query
limiter.record_call('memory_recall', {'query_intent': 'test'})
assert limiter.counts.memory_query == initial_count + 1
def test_reset(limiter):
"""测试重置计数"""
limiter.record_call('memory_recall', {'query_intent': 'test'})
limiter.record_call('memory_recall', {'query_intent': 'test'})
limiter.reset()
assert limiter.counts.memory_query == 0
assert limiter.counts.memory_update == 0
def test_get_summary(limiter):
"""测试获取统计摘要"""
limiter.record_call('memory_recall', {'query_intent': 'test'})
summary = limiter.get_summary()
assert isinstance(summary, str)
assert '一般记忆' in summary
assert '查询1' in summary
def test_custom_limits():
"""测试自定义限制"""
limits = ToolLimits(
memory_query_max=5,
memory_update_max=3
)
limiter = ToolLimiter(limits)
assert limiter.limits.memory_query_max == 5
assert limiter.limits.memory_update_max == 3
def test_persona_query_limit(limiter):
"""测试人设图查询限制"""
for _ in range(1):
limiter.record_call('memory_recall', {'query_intent': '人设'})
allowed, _ = limiter.can_call('memory_recall', {'query_intent': '人设'})
assert allowed is False
def test_task_query_limit(limiter):
"""测试工作记忆链查询限制"""
for _ in range(4):
limiter.record_call('memory_recall', {'query_intent': 'TaskNode'})
allowed, _ = limiter.can_call('memory_recall', {'query_intent': 'TaskNode'})
assert allowed is False

View File

@ -1 +0,0 @@
"""Tests for Event Handlers"""

View File

@ -1,33 +0,0 @@
"""焦点处理器测试"""
import pytest
from graph_memory_tui.handlers.focus_handler import FocusHandler
def test_focus_handler_creation():
"""测试焦点处理器创建"""
handler = FocusHandler()
assert handler is not None
assert handler._current_index == 0
def test_focus_ring():
"""测试焦点循环"""
handler = FocusHandler()
assert len(handler.FOCUS_RING) == 5
assert "input-textarea" in handler.FOCUS_RING
assert "cypher-textarea" in handler.FOCUS_RING
def test_get_current_focus_name():
"""测试获取当前焦点名称"""
handler = FocusHandler()
name = handler.get_current_focus_name()
assert name == "Input"
def test_focus_names_mapping():
"""测试焦点名称映射"""
handler = FocusHandler()
assert handler.FOCUS_NAMES["input-textarea"] == "Input"
assert handler.FOCUS_NAMES["cypher-textarea"] == "Query"

View File

@ -2,9 +2,9 @@
import pytest
from datetime import datetime
from graph_memory_tui.models.message import Message, ToolCall, ToolResult
from graph_memory_tui.models.config import AppConfig
from graph_memory_tui.models.log_entry import LogEntry
from ui.models.message import Message, ToolCall, ToolResult
from ui.models.config import AppConfig
from ui.models.log_entry import LogEntry
def test_message_creation(sample_message):

View File

@ -1 +0,0 @@
"""Tests for Business Services"""

70
tests/test_ui.py Normal file
View File

@ -0,0 +1,70 @@
import pytest
import os
import tempfile
os.environ["DEEPSEEK_API_KEY"] = "fake-test-key"
class TestUIImport:
def test_import_app(self):
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):
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
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
db_path = f.name
try:
server = BackendServer(db_path=db_path, use_embedded_db=True)
server.start(api_key="", base_url="https://api.test.com")
app = GraphMemoryApp(backend_server=server)
assert app._backend_server is server
assert app._backend_client is not None
server.shutdown()
finally:
if os.path.exists(db_path):
os.unlink(db_path)
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"

View File

@ -1 +0,0 @@
"""Tests for UI Widgets"""

View File

@ -1,47 +1,45 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (C) 2026 jianf
"""
TrulyMEM 独立入口文件
用于打包为可执行文件
"""
import sys
import os
from pathlib import Path
# 确保工作目录正确
if getattr(sys, 'frozen', False):
# 打包后的可执行文件
application_path = Path(sys.executable).parent
else:
# 开发环境
application_path = Path(__file__).parent
# 切换到应用目录
os.chdir(application_path)
# 添加项目路径
if str(application_path) not in sys.path:
sys.path.insert(0, str(application_path))
# 导入并运行应用
from graph_memory_tui.app import GraphMemoryApp
from core import BackendServer
from ui import GraphMemoryApp, AppConfig
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}")
app = GraphMemoryApp(backend_server=backend_server)
try:
app = GraphMemoryApp()
app.run()
except KeyboardInterrupt:
print("\n应用已退出")
sys.exit(0)
except Exception as e:
print(f"应用启动失败: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
finally:
backend_server.shutdown()
sys.exit(0)
if __name__ == "__main__":
main()
main()

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