feat: 实现流式消息显示和界面优化

This commit is contained in:
JianFeeeee
2026-04-11 09:30:56 +08:00
parent 4075f9a30d
commit b33a87d19e
14 changed files with 538 additions and 193 deletions

View File

@ -72,11 +72,11 @@ class GraphMemoryApp(App[None]):
def on_mount(self) -> None:
"""应用启动初始化"""
history = self.query_one(MessageHistory)
try:
# 初始化内嵌图数据库
self._graph = Neo4jGraph(db_path="graph_memory.db")
# 初始化 API 客户端
self._init_client()
@ -194,8 +194,8 @@ F6 - 退出
)
history.add_message(processing_msg)
# 异步处理使用call_later避免崩溃
self.call_later(self._process_message_safe, event.content)
# 异步处理
asyncio.create_task(self._process_message_async(event.content))
except Exception as e:
# 显示错误
@ -206,22 +206,6 @@ F6 - 退出
)
history.add_message(error_msg)
def _process_message_safe(self, user_input: str) -> None:
"""安全处理消息"""
try:
history = self.query_one(MessageHistory)
# 简单测试响应
response_msg = Message(
role="assistant",
content=f"收到消息: {user_input}\n\n完整功能需要配置API Key并连接API服务。",
timestamp=datetime.now()
)
history.add_message(response_msg)
except Exception as e:
print(f"Error: {e}")
async def _process_message_async(self, user_input: str) -> None:
"""异步处理消息 - 参考demo实现"""
history = self.query_one(MessageHistory)
@ -246,7 +230,9 @@ F6 - 退出
# 处理工具调用循环
tool_calls = []
tool_results = []
last_assistant_msg = None
accumulated_content = "" # 累积所有中间内容
# 显示工具调用摘要
if message.tool_calls:
tool_summary = f"🔧 正在调用 {len(message.tool_calls)} 个工具..."
@ -256,9 +242,30 @@ F6 - 退出
timestamp=datetime.now()
)
history.add_message(summary_msg)
while message.tool_calls:
# 保存工具调用信息
# 累积中间内容(如果有)
if message.content:
accumulated_content += message.content + "\n\n"
# 保存包含 tool_calls 的 assistant 消息
last_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
]
}
# 执行当前轮的所有工具调用
current_tool_results = []
for tool_call in message.tool_calls:
tc = ToolCall(
id=tool_call.id,
@ -266,7 +273,7 @@ F6 - 退出
arguments=json.loads(tool_call.function.arguments)
)
tool_calls.append(tc)
# 执行工具
start_time = datetime.now()
result = await asyncio.get_event_loop().run_in_executor(
@ -274,7 +281,7 @@ F6 - 退出
lambda: execute_tool(self._graph, tc.name, tc.arguments)
)
duration = (datetime.now() - start_time).total_seconds()
# 保存工具结果
tr = ToolResult(
tool_call_id=tc.id,
@ -284,7 +291,14 @@ F6 - 退出
success=not result.startswith("工具执行错误")
)
tool_results.append(tr)
# 添加到当前轮结果
current_tool_results.append({
"role": "tool",
"tool_call_id": tc.id,
"content": result
})
# 添加日志
log_entry = LogEntry(
timestamp=datetime.now(),
@ -294,14 +308,26 @@ F6 - 退出
duration=duration
)
log.add_log(log_entry)
# 继续调用API参考demo的实现
# 这里简化处理实际应该像demo一样继续循环
break
response = await asyncio.get_event_loop().run_in_executor(
None,
lambda: self._client.send_message(
user_input,
current_tool_results,
last_assistant_msg
)
)
message = response.choices[0].message
# 添加助手消息(完整内容)
content = message.content or "(无回复)"
# 使用累积的内容 + 最终内容
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]
@ -314,8 +340,12 @@ F6 - 退出
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)
@ -362,6 +392,13 @@ API Key 未配置!
# 持久化保存配置
self._config_manager.save(self._config)
# 同步更新 RightPanel 的配置
try:
right_panel = self.query_one(RightPanel)
right_panel._config = self._config
except Exception:
pass
# 重新初始化客户端
self._init_client()

View File

@ -94,31 +94,46 @@ class EmbeddedGraphDB:
检索结果
"""
keywords = [w.strip().lower() for w in query_intent.replace(',', ' ').split() if w.strip()]
if not keywords and not seed_entities:
return {"entities": [], "relations": [], "message": "无查询关键词"}
cursor = self.conn.cursor()
# 搜索实体
entities = []
entity_ids = set()
for keyword in keywords:
# 如果没有关键词,返回所有实体(用于"我们都聊过什么"这类问题)
if not keywords and not seed_entities:
cursor.execute("""
SELECT id, name, type, mention_count
FROM entities
WHERE LOWER(name) LIKE ?
""", (f"%{keyword}%",))
ORDER BY mention_count DESC
LIMIT 50
""")
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']
})
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 = []

View File

@ -3,10 +3,114 @@
"""
# 优化后的系统提示词
OPTIMIZED_SYSTEM_PROMPT = """你是图数据库记忆助手。
OPTIMIZED_SYSTEM_PROMPT = """你是一个拥有长期记忆能力的AI助手。
## 核心职责
你是用户的长期记忆助手。每次对话后,你**必须**主动决定是否需要将关键信息写入记忆图库。
你是一个智能对话助手,**图数据库是你记忆的唯一载体**。你的主要任务是:
1. 与用户进行自然、流畅的对话
2. 回答问题、提供建议、协助完成任务
3. 根据对话内容灵活查询和使用记忆
4. 将用户明确提到的信息写入记忆
**重要**
- 图数据库是你记忆的唯一来源,没有其他记忆方式
- 优先进行自然对话,根据需要灵活调用记忆工具
- 用户明确提到的内容必须写入,推理得到的内容必须标注
## 记忆工具使用原则
### 何时检索记忆 (memory_recall)
- 用户询问"我们之前聊过X吗""你还记得X吗" → 查询X相关内容
- 用户询问"我们都聊过什么""我们之前说了什么" → **使用空字符串或通配符查询所有记忆**
- 用户提到某个话题,你想确认是否有相关历史 → 查询该话题
- 需要基于历史信息回答问题 → 查询相关信息
- 对话中涉及之前可能讨论过的内容 → 查询相关内容
**灵活查询**:根据对话上下文,主动判断是否需要查询记忆,不要等待用户明确要求。
**重要**
- 当用户问"我们都聊过什么"时,**不要查询"聊天记录""对话"等关键词**
- 应该使用空字符串 `""` 或通配符 `"*"` 来获取所有记忆内容
- 或者使用非常宽泛的关键词如 `"用户,喜欢,项目,学习,研究,计划"`
### 何时写入记忆 (memory_commit)
**必须写入的情况**(用户明确提到):
- 用户表达偏好:"我喜欢X""我讨厌X"
- 用户分享信息:"我在做X项目""我在学X"
- 用户制定计划:"我打算X""我计划X"
- 用户描述状态:"我现在在X"
**禁止写入的情况**AI推理得到
- AI推断的用户偏好
- AI猜测的用户意图
- AI推导的结论
## memory_recall 使用方法
### 关键词提取
query_intent 使用**逗号分隔的多个关键词**,包含同义词:
```json
{
"query_intent": "量子力学,quantum,物理,physics",
"depth": 2
}
```
### 同义词扩展示例
- "量子力学""量子力学,quantum,quantum mechanics,物理,physics"
- "项目""项目,project,工程,工作"
- "学习""学习,learn,study,掌握"
### 查询规则
1. 根据对话内容灵活提取关键词
2. 第一轮使用广泛的关键词搜索
3. 如果未找到,可以尝试相关概念
4. 最多查询2-3轮避免重复查询
## memory_commit 使用方法
使用三元组格式记录信息:
```json
{
"triplets": [
{"subject": "用户", "relation": "对领域感兴趣", "object": "量子力学"}
]
}
```
## 区分事实与推理(重要!)
### 用户明确提到的内容
直接写入记忆,回复时直接陈述:
- 用户:"我喜欢Python" → 写入,回复:"好的我会记住你喜欢Python"
- 用户:"我在学机器学习" → 写入,回复:"明白了,你在学习机器学习"
### AI推理得到的内容
**禁止写入记忆**,回复时必须在开头标注 **[猜测]**
- AI推断用户可能喜欢X → 不写入,回复:"[猜测] 你可能对X感兴趣"
- AI推测用户意图 → 不写入,回复:"[猜测] 你可能是想..."
**示例**
```
用户:我们聊过量子力学吗?
AI检索记忆 → 未找到
AI回复[猜测] 我们应该还没有聊过量子力学,因为记忆中没有相关记录。
```
```
用户:我最近在研究深度学习
AI写入记忆 → {"subject": "用户", "relation": "正在研究", "object": "深度学习"}
AI回复好的我会记住你最近在研究深度学习。有什么具体问题想讨论吗
```
## 可用工具
1. **memory_recall** - 检索历史记忆(灵活使用)
2. **memory_commit** - 写入记忆(仅限用户明确提到的内容)
3. **memory_purge** - 删除/修正记忆
4. **memory_introspect** - 查看记忆状态
现在开始对话!记住:图数据库是你记忆的唯一载体,明确提到的必须写入,推理得到的必须标注[猜测]。"""
## 多轮查询策略
**允许多轮查询**但必须遵循以下规则

View File

@ -40,21 +40,44 @@ class MessageHandler:
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":
# 模型消息
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)
# 模型消息完成
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":
# 工具调用开始

View File

@ -15,11 +15,8 @@ from .models.config import AppConfig
def main():
"""主函数"""
try:
# 加载配置
config = AppConfig.from_env()
# 创建并运行应用
app = GraphMemoryApp(config=config)
# 不传入配置,让应用自己从配置文件或环境变量加载
app = GraphMemoryApp()
app.run()
except KeyboardInterrupt:

View File

@ -34,18 +34,33 @@ class ChatService:
}
try:
# 2. 异步调用 API
response = await self._call_api_async(user_input)
# 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. 处理工具调用
tool_calls = None
tool_results = None
if response.tool_calls:
if tool_calls_data:
tool_calls = []
tool_results = []
for tool_call_data in response.tool_calls:
for tool_call_data in tool_calls_data:
# 创建工具调用对象
tool_call = ToolCall(
id=tool_call_data.id,
@ -75,7 +90,7 @@ class ChatService:
# 4. 返回最终回复
yield {
"type": "assistant_message",
"content": response.content,
"content": accumulated_content,
"tool_calls": tool_calls,
"tool_results": tool_results
}
@ -87,13 +102,49 @@ class ChatService:
"error": str(e)
}
async def _call_api_async(self, message: str):
"""异步调用 API"""
async def _call_api_stream_async(self, message: str) -> AsyncIterator[dict]:
"""异步流式调用 API"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
lambda: self._client.send_message(message)
)
# 在executor中运行同步流式API
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}
# 使用run_in_executor处理生成器
for result in await loop.run_in_executor(None, lambda: list(process_stream())):
yield result
def clear_history(self) -> None:
"""清空消息历史"""

View File

@ -25,6 +25,12 @@ RightPanel {
width: 40;
dock: right;
background: $panel;
overflow-y: auto;
}
RightPanel ScrollableContainer {
height: 1fr;
overflow-y: auto;
}
StatusBar {

View File

@ -19,18 +19,20 @@ InputBox Input {
ConfigSection {
background: $surface;
padding: 1;
margin: 1;
margin: 0 0 1 0;
height: auto;
}
ConfigSection .config-title {
color: $primary;
text-style: bold;
margin: 1;
margin: 0 0 1 0;
}
ConfigSection .config-label {
color: $text;
margin: 1 0 0 0;
margin: 0;
padding: 1 0 0 0;
}
ConfigSection .config-hint {
@ -41,7 +43,9 @@ ConfigSection .config-hint {
ConfigSection Input {
width: 1fr;
height: 3;
margin: 0 0 1 0;
padding: 0 1;
background: $surface-lighten-1;
border: solid $primary;
color: $text;
@ -52,14 +56,62 @@ OperationLog {
background: $surface-darken-1;
height: 1fr;
margin: 1;
overflow-y: auto;
}
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;
}

View File

@ -63,7 +63,7 @@ class ConfigSection(Vertical):
yield hint
def on_mount(self) -> None:
"""组件挂载时设置Tab顺序"""
"""组件挂载时设置Tab顺序并加载配置"""
try:
api_key = self.query_one("#api-key-input", Input)
model = self.query_one("#model-input", Input)
@ -73,6 +73,14 @@ class ConfigSection(Vertical):
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

View File

@ -27,6 +27,15 @@ class MessageHistory(ScrollableContainer):
# 滚动到最新消息
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()

View File

@ -13,6 +13,7 @@ class MessageWidget(Container):
super().__init__(**kwargs)
self._message = message
self._show_tool_details = False
self._content_widget = None # 保存内容组件的引用
def compose(self):
"""构建消息组件"""
@ -20,12 +21,16 @@ class MessageWidget(Container):
role_emoji = "🟠" if self._message.role == "user" else "🔵"
timestamp_str = self._message.timestamp.strftime("%H:%M:%S")
yield Static(
f"{role_emoji} [{self._message.role}] {timestamp_str}",
f"{role_emoji} {timestamp_str}",
classes="message-header"
)
# 消息内容
yield Static(self._message.content, classes="message-content")
# 消息内容 - 保存引用以便后续更新
self._content_widget = Static(
self._message.content,
classes="message-content"
)
yield self._content_widget
# 工具调用指示器
if self._message.tool_calls:
@ -52,11 +57,22 @@ class MessageWidget(Container):
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.result[:200]}...",
f"结果: {result_text}",
classes="tool-result"
)
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:

View File

@ -15,22 +15,13 @@ class StatusBar(Static):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._focus_indicator = "[Input]"
self._shortcuts = "F1:帮助 F2:侧边栏 F3:工具详情 F4:查询 F5:清屏 F6:退出"
self._license_info = "本项目由jianf设计以GPLv3形式开源"
def on_mount(self) -> None:
"""组件挂载时"""
self._update_display()
def update_focus(self, focus_name: str) -> None:
"""更新焦点指示器"""
self._focus_indicator = f"[{focus_name}]"
self._update_display()
def _update_display(self) -> None:
"""更新显示"""
self.update(f"{self._shortcuts} | 焦点: {self._focus_indicator}")
def get_focus(self) -> str:
"""获取当前焦点"""
return self._focus_indicator
self.update(f"{self._license_info} | {self._shortcuts}")