From 803b5d8a4de2ef9f705354fc050872903b82780c Mon Sep 17 00:00:00 2001 From: Hongyu Shi Date: Mon, 4 Aug 2025 20:08:59 +0800 Subject: [PATCH 1/6] feat: context management Signed-off-by: Hongyu Shi --- src/backend/base.py | 21 +- src/backend/factory.py | 10 +- src/backend/hermes/client.py | 498 ++++++++++++++++++++++++++++------- src/backend/openai.py | 47 +++- 4 files changed, 471 insertions(+), 105 deletions(-) diff --git a/src/backend/base.py b/src/backend/base.py index 6bab2b1..472b11e 100644 --- a/src/backend/base.py +++ b/src/backend/base.py @@ -5,8 +5,11 @@ from __future__ import annotations from abc import ABC, abstractmethod from typing import TYPE_CHECKING +from typing_extensions import Self + if TYPE_CHECKING: from collections.abc import AsyncGenerator + from types import TracebackType class LLMClientBase(ABC): @@ -35,14 +38,28 @@ class LLMClientBase(ABC): """ + @abstractmethod + def reset_conversation(self) -> None: + """ + 重置对话上下文 + + 此方法作为模板方法,子类可以重写以实现具体的会话重置逻辑。 + 默认实现不执行任何操作,适用于无状态的客户端。 + """ + @abstractmethod async def close(self) -> None: """关闭客户端连接""" - async def __aenter__(self) -> LLMClientBase: + async def __aenter__(self) -> Self: """异步上下文管理器入口""" return self - async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: """异步上下文管理器出口""" await self.close() diff --git a/src/backend/factory.py b/src/backend/factory.py index 2775650..e5f2dfc 100644 --- a/src/backend/factory.py +++ b/src/backend/factory.py @@ -4,12 +4,12 @@ from __future__ import annotations from typing import TYPE_CHECKING -from backend.base import LLMClientBase from backend.hermes.client import HermesChatClient from backend.openai import OpenAIClient from config.model import Backend if TYPE_CHECKING: + from backend.base import LLMClientBase from config.manager import ConfigManager @@ -29,6 +29,7 @@ class BackendFactory: Raises: ValueError: 当后端类型不支持时 + """ backend = config_manager.get_backend() @@ -38,11 +39,10 @@ class BackendFactory: model=config_manager.get_model(), api_key=config_manager.get_api_key(), ) - elif backend == Backend.EULERINTELLI: + if backend == Backend.EULERINTELLI: return HermesChatClient( base_url=config_manager.get_eulerintelli_url(), auth_token=config_manager.get_eulerintelli_key(), ) - else: - msg = f"不支持的后端类型: {backend}" - raise ValueError(msg) + msg = f"不支持的后端类型: {backend}" + raise ValueError(msg) diff --git a/src/backend/hermes/client.py b/src/backend/hermes/client.py index 01e979d..e4ed9c7 100644 --- a/src/backend/hermes/client.py +++ b/src/backend/hermes/client.py @@ -6,7 +6,7 @@ import json import re import time from typing import TYPE_CHECKING, Any -from urllib.parse import urljoin +from urllib.parse import urljoin, urlparse import httpx from typing_extensions import Self @@ -177,6 +177,184 @@ class HermesChatClient(LLMClientBase): self.logger.info("Hermes 客户端初始化成功 - URL: %s", base_url) + def reset_conversation(self) -> None: + """重置会话,下次聊天时会创建新的会话""" + self._conversation_id = None + + async def get_llm_response(self, prompt: str) -> AsyncGenerator[str, None]: + """ + 生成命令建议 + + 为了兼容现有的 OpenAI 客户端接口,提供简化的聊天接口。 + + Args: + prompt: 用户输入的提示语 + + Yields: + str: 流式响应的文本内容 + + Raises: + HermesAPIError: 当 API 调用失败时 + + """ + self.logger.info("开始 Hermes 流式聊天请求") + start_time = time.time() + + try: + # 确保有会话 ID + conversation_id = await self._ensure_conversation() + + # 创建聊天请求 + app = HermesApp("default-app") + request = HermesChatRequest( + app=app, + conversation_id=conversation_id, + question=prompt, + features=HermesFeatures(), + language="zh_cn", + ) + + # 直接传递异常,不在这里处理 + async for text in self._chat_stream(request): + yield text + + duration = time.time() - start_time + self.logger.info("Hermes 流式聊天请求完成 - 耗时: %.3fs", duration) + + except Exception as e: + duration = time.time() - start_time + log_exception(self.logger, "Hermes 流式聊天请求失败", e) + raise + + async def get_available_models(self) -> list[str]: + """ + 获取当前 LLM 服务中可用的模型,返回名称列表 + + 通过调用 /api/llm 接口获取可用的大模型列表。 + 如果调用失败或没有返回,使用空列表,后端接口会自动使用默认模型。 + """ + start_time = time.time() + self.logger.info("开始请求 Hermes 模型列表 API") + + try: + client = await self._get_client() + llm_url = urljoin(self.base_url, "/api/llm") + + headers = { + "Host": self._get_host_header(), + } + if self.auth_token: + headers["Authorization"] = f"Bearer {self.auth_token}" + response = await client.get(llm_url, headers=headers) + + duration = time.time() - start_time + + if response.status_code != HTTP_OK: + # 如果接口调用失败,返回空列表 + log_api_request( + self.logger, + "GET", + llm_url, + response.status_code, + duration, + error="API 调用失败", + ) + self.logger.warning("Hermes 模型列表 API 调用失败,返回空列表") + return [] + + data = response.json() + + # 检查响应格式 + if not isinstance(data, dict) or "result" not in data: + log_api_request( + self.logger, + "GET", + llm_url, + response.status_code, + duration, + error="响应格式无效", + ) + self.logger.warning("Hermes 模型列表 API 响应格式无效,返回空列表") + return [] + + result = data["result"] + if not isinstance(result, list): + log_api_request( + self.logger, + "GET", + llm_url, + response.status_code, + duration, + error="result字段不是数组", + ) + self.logger.warning("Hermes 模型列表 API result字段不是数组,返回空列表") + return [] + + # 提取模型名称 + models = [] + for llm_info in result: + if isinstance(llm_info, dict): + # 优先使用 modelName,如果没有则使用 llmId + model_name = llm_info.get("modelName") or llm_info.get("llmId") + if model_name: + models.append(model_name) + + # 记录成功的API请求 + log_api_request( + self.logger, + "GET", + llm_url, + response.status_code, + duration, + model_count=len(models), + ) + + self.logger.info("获取到 %d 个可用模型", len(models)) + + except ( + httpx.HTTPError, + httpx.InvalidURL, + json.JSONDecodeError, + KeyError, + ValueError, + ) as e: + # 如果发生网络错误、JSON解析错误或其他预期错误,返回空列表 + duration = time.time() - start_time + log_exception(self.logger, "Hermes 模型列表 API 请求异常", e) + log_api_request( + self.logger, + "GET", + f"{self.base_url}/api/llm", + 500, + duration, + error=str(e), + ) + self.logger.warning("Hermes 模型列表 API 请求异常,返回空列表") + return [] + else: + return models + + async def close(self) -> None: + """关闭 HTTP 客户端""" + try: + if self.client and not self.client.is_closed: + await self.client.aclose() + self.logger.info("Hermes 客户端已关闭") + except Exception as e: + log_exception(self.logger, "关闭 Hermes 客户端失败", e) + raise + + def _get_host_header(self) -> str: + """ + 从base_url中提取主机名用于Host头部 + + Returns: + str: 主机名,如 'www.eulercopilot.io' + + """ + parsed_url = urlparse(self.base_url) + return parsed_url.netloc + async def _get_client(self) -> httpx.AsyncClient: """获取或创建 HTTP 客户端""" if self.client is None or self.client.is_closed: @@ -217,7 +395,9 @@ class HermesChatClient(LLMClientBase): if llm_id: params["llm_id"] = llm_id - headers = {} + headers = { + "Host": self._get_host_header(), + } if self.auth_token: headers["Authorization"] = f"Bearer {self.auth_token}" @@ -321,7 +501,10 @@ class HermesChatClient(LLMClientBase): async def _ensure_conversation(self, llm_id: str = "") -> str: """ - 确保有可用的会话 ID,如果没有则创建新会话 + 确保有可用的会话 ID,智能重用空对话或创建新会话 + + 优先使用已存在的空对话,如果没有空对话或获取失败,则创建新对话。 + 这样可以避免产生过多的空对话记录。 Args: llm_id: 指定的 LLM ID @@ -331,12 +514,32 @@ class HermesChatClient(LLMClientBase): """ if self._conversation_id is None: - self._conversation_id = await self._create_conversation(llm_id) - return self._conversation_id + try: + # 先尝试获取现有对话列表 + conversation_list = await self._get_conversation_list() + + # 如果有对话,检查最新的对话是否为空 + if conversation_list: + latest_conversation_id = conversation_list[0] # 已经按时间排序,第一个是最新的 + try: + # 检查最新对话是否为空 + if await self._is_conversation_empty(latest_conversation_id): + self.logger.info("重用空对话 - ID: %s", latest_conversation_id) + self._conversation_id = latest_conversation_id + return self._conversation_id + except HermesAPIError: + # 如果检查对话记录失败,继续创建新对话 + self.logger.warning("检查对话记录失败,将创建新对话") + + # 如果没有对话或最新对话不为空,创建新对话 + self._conversation_id = await self._create_conversation(llm_id) + + except HermesAPIError: + # 如果获取对话列表失败,直接创建新对话 + self.logger.warning("获取对话列表失败,将创建新对话") + self._conversation_id = await self._create_conversation(llm_id) - def reset_conversation(self) -> None: - """重置会话,下次聊天时会创建新的会话""" - self._conversation_id = None + return self._conversation_id async def _chat_stream( self, @@ -358,7 +561,9 @@ class HermesChatClient(LLMClientBase): client = await self._get_client() chat_url = urljoin(self.base_url, "/api/chat") - headers = {} + headers = { + "Host": self._get_host_header(), + } if self.auth_token: headers["Authorization"] = f"Bearer {self.auth_token}" @@ -398,165 +603,266 @@ class HermesChatClient(LLMClientBase): except (json.JSONDecodeError, KeyError, ValueError) as e: raise HermesAPIError(500, f"Data parsing error: {e!s}") from e - async def get_llm_response(self, prompt: str) -> AsyncGenerator[str, None]: + async def _get_conversation_list(self) -> list[str]: """ - 生成命令建议 - - 为了兼容现有的 OpenAI 客户端接口,提供简化的聊天接口。 + 获取会话ID列表,按创建时间从新到旧排序 - Args: - prompt: 用户输入的提示语 + 通过调用 /api/conversation 接口获取用户的所有会话, + 提取 conversationId 并按 createdTime 从新到旧排序。 - Yields: - str: 流式响应的文本内容 + Returns: + list[str]: 会话ID列表,按创建时间排序(新到旧) Raises: HermesAPIError: 当 API 调用失败时 """ - self.logger.info("开始 Hermes 流式聊天请求") start_time = time.time() + self.logger.info("开始请求 Hermes 会话列表 API") + + client = await self._get_client() + conversation_url = urljoin(self.base_url, "/api/conversation") + + headers = { + "Accept": "application/json, text/plain, */*", + "Host": self._get_host_header(), + } + if self.auth_token: + headers["Authorization"] = f"Bearer {self.auth_token}" try: - # 确保有会话 ID - conversation_id = await self._ensure_conversation() + response = await client.get(conversation_url, headers=headers) + duration = time.time() - start_time - # 创建聊天请求 - app = HermesApp("default-app") - request = HermesChatRequest( - app=app, - conversation_id=conversation_id, - question=prompt, - features=HermesFeatures(), - language="zh_cn", - ) + if response.status_code != HTTP_OK: + error_text = await response.aread() + log_api_request( + self.logger, + "GET", + conversation_url, + response.status_code, + duration, + error=error_text.decode("utf-8"), + ) + raise HermesAPIError(response.status_code, error_text.decode("utf-8")) - # 直接传递异常,不在这里处理 - async for text in self._chat_stream(request): - yield text + try: + data = response.json() + except json.JSONDecodeError as e: + log_api_request( + self.logger, + "GET", + conversation_url, + response.status_code, + duration, + error="Invalid JSON response", + ) + raise HermesAPIError(500, "Invalid JSON response") from e - duration = time.time() - start_time - self.logger.info("Hermes 流式聊天请求完成 - 耗时: %.3fs", duration) + # 检查响应格式 + if not isinstance(data, dict) or "result" not in data: + log_api_request( + self.logger, + "GET", + conversation_url, + response.status_code, + duration, + error="Invalid API response format", + ) + raise HermesAPIError(500, "Invalid API response format") - except Exception as e: + result = data["result"] + if not isinstance(result, dict) or "conversations" not in result: + log_api_request( + self.logger, + "GET", + conversation_url, + response.status_code, + duration, + error="Missing conversations in response", + ) + raise HermesAPIError(500, "Missing conversations in response") + + conversations = result["conversations"] + if not isinstance(conversations, list): + log_api_request( + self.logger, + "GET", + conversation_url, + response.status_code, + duration, + error="conversations is not a list", + ) + raise HermesAPIError(500, "conversations field is not a list") + + # 提取会话信息并按创建时间排序 + conversation_items = [ + { + "id": conv["conversationId"], + "created_time": conv["createdTime"], + } + for conv in conversations + if isinstance(conv, dict) and "conversationId" in conv and "createdTime" in conv + ] + + # 按创建时间排序(从新到旧) + conversation_items.sort(key=lambda x: x["created_time"], reverse=True) + + # 提取排序后的会话ID列表 + conversation_ids = [item["id"] for item in conversation_items] + + # 记录成功的API请求 + log_api_request( + self.logger, + "GET", + conversation_url, + response.status_code, + duration, + conversation_count=len(conversation_ids), + ) + + self.logger.info("获取到 %d 个会话", len(conversation_ids)) + + except httpx.RequestError as e: duration = time.time() - start_time - log_exception(self.logger, "Hermes 流式聊天请求失败", e) - raise + log_exception(self.logger, "Hermes 会话列表请求失败", e) + log_api_request( + self.logger, + "GET", + conversation_url, + 500, + duration, + error=str(e), + ) + raise HermesAPIError(500, f"Failed to get conversation list: {e!s}") from e + else: + return conversation_ids - async def get_available_models(self) -> list[str]: + async def _is_conversation_empty(self, conversation_id: str) -> bool: """ - 获取当前 LLM 服务中可用的模型,返回名称列表 + 检查指定对话是否为空(没有聊天记录) + + 通过调用 /api/record/{conversation_id} 接口检查对话的聊天记录。 + 如果 result.records 为空列表,说明这是一个新对话,可以直接使用。 + + Args: + conversation_id: 要检查的对话ID + + Returns: + bool: True 表示对话为空,False 表示对话有内容 + + Raises: + HermesAPIError: 当 API 调用失败时 - 通过调用 /api/llm 接口获取可用的大模型列表。 - 如果调用失败或没有返回,使用空列表,后端接口会自动使用默认模型。 """ start_time = time.time() - self.logger.info("开始请求 Hermes 模型列表 API") + self.logger.info("检查对话是否为空 - ID: %s", conversation_id) - try: - client = await self._get_client() - llm_url = urljoin(self.base_url, "/api/llm") + client = await self._get_client() + record_url = urljoin(self.base_url, f"/api/record/{conversation_id}") - headers = {} + headers = { + "Accept": "application/json, text/plain, */*", + "Host": self._get_host_header(), + } + if self.auth_token: headers["Authorization"] = f"Bearer {self.auth_token}" - response = await client.get(llm_url, headers=headers) + try: + response = await client.get(record_url, headers=headers) duration = time.time() - start_time if response.status_code != HTTP_OK: - # 如果接口调用失败,返回空列表 + error_text = await response.aread() log_api_request( self.logger, "GET", - llm_url, + record_url, response.status_code, duration, - error="API 调用失败", + error=error_text.decode("utf-8"), ) - self.logger.warning("Hermes 模型列表 API 调用失败,返回空列表") - return [] + raise HermesAPIError(response.status_code, error_text.decode("utf-8")) - data = response.json() + try: + data = response.json() + except json.JSONDecodeError as e: + log_api_request( + self.logger, + "GET", + record_url, + response.status_code, + duration, + error="Invalid JSON response", + ) + raise HermesAPIError(500, "Invalid JSON response") from e # 检查响应格式 if not isinstance(data, dict) or "result" not in data: log_api_request( self.logger, "GET", - llm_url, + record_url, response.status_code, duration, - error="响应格式无效", + error="Invalid API response format", ) - self.logger.warning("Hermes 模型列表 API 响应格式无效,返回空列表") - return [] + raise HermesAPIError(500, "Invalid API response format") result = data["result"] - if not isinstance(result, list): + if not isinstance(result, dict) or "records" not in result: log_api_request( self.logger, "GET", - llm_url, + record_url, response.status_code, duration, - error="result字段不是数组", + error="Missing records in response", ) - self.logger.warning("Hermes 模型列表 API result字段不是数组,返回空列表") - return [] + raise HermesAPIError(500, "Missing records in response") - # 提取模型名称 - models = [] - for llm_info in result: - if isinstance(llm_info, dict): - # 优先使用 modelName,如果没有则使用 llmId - model_name = llm_info.get("modelName") or llm_info.get("llmId") - if model_name: - models.append(model_name) + records = result["records"] + if not isinstance(records, list): + log_api_request( + self.logger, + "GET", + record_url, + response.status_code, + duration, + error="records is not a list", + ) + raise HermesAPIError(500, "records field is not a list") + + # 判断对话是否为空 + is_empty = len(records) == 0 # 记录成功的API请求 log_api_request( self.logger, "GET", - llm_url, + record_url, response.status_code, duration, - model_count=len(models), + records_count=len(records), + is_empty=is_empty, ) - self.logger.info("获取到 %d 个可用模型", len(models)) + self.logger.info("对话 %s %s", conversation_id, "为空" if is_empty else "有内容") - except ( - httpx.HTTPError, - httpx.InvalidURL, - json.JSONDecodeError, - KeyError, - ValueError, - ) as e: - # 如果发生网络错误、JSON解析错误或其他预期错误,返回空列表 + except httpx.RequestError as e: duration = time.time() - start_time - log_exception(self.logger, "Hermes 模型列表 API 请求异常", e) + log_exception(self.logger, "检查对话记录请求失败", e) log_api_request( self.logger, "GET", - f"{self.base_url}/api/llm", + record_url, 500, duration, error=str(e), ) - self.logger.warning("Hermes 模型列表 API 请求异常,返回空列表") - return [] + raise HermesAPIError(500, f"Failed to check conversation records: {e!s}") from e else: - return models - - async def close(self) -> None: - """关闭 HTTP 客户端""" - try: - if self.client and not self.client.is_closed: - await self.client.aclose() - self.logger.info("Hermes 客户端已关闭") - except Exception as e: - log_exception(self.logger, "关闭 Hermes 客户端失败", e) - raise + return is_empty async def __aenter__(self) -> Self: """异步上下文管理器入口""" diff --git a/src/backend/openai.py b/src/backend/openai.py index 4e48cc3..f00cb16 100644 --- a/src/backend/openai.py +++ b/src/backend/openai.py @@ -3,12 +3,16 @@ import re import time from collections.abc import AsyncGenerator +from typing import TYPE_CHECKING from openai import AsyncOpenAI from backend.base import LLMClientBase from log.manager import get_logger, log_api_request, log_exception +if TYPE_CHECKING: + from openai.types.chat import ChatCompletionMessageParam + def validate_url(url: str) -> bool: """ @@ -37,6 +41,10 @@ class OpenAIClient(LLMClientBase): api_key=api_key, base_url=base_url, ) + + # 添加历史记录管理 + self._conversation_history: list[ChatCompletionMessageParam] = [] + self.logger.info("OpenAI 客户端初始化成功 - URL: %s, Model: %s", base_url, model) async def get_llm_response(self, prompt: str) -> AsyncGenerator[str, None]: @@ -44,15 +52,20 @@ class OpenAIClient(LLMClientBase): 生成命令建议 异步调用 OpenAI 或兼容接口的大模型生成命令建议,支持流式输出。 - 请确保已安装 openai 库(pip install openai)。 + 保持对话历史记录,支持多轮对话上下文。 """ start_time = time.time() self.logger.info("开始请求 OpenAI 流式聊天 API - Model: %s", self.model) + # 添加用户消息到历史记录 + user_message: ChatCompletionMessageParam = {"role": "user", "content": prompt} + self._conversation_history.append(user_message) + try: + # 使用完整的对话历史记录 response = await self.client.chat.completions.create( model=self.model, - messages=[{"role": "user", "content": prompt}], + messages=self._conversation_history, stream=True, ) @@ -66,14 +79,35 @@ class OpenAIClient(LLMClientBase): duration, model=self.model, stream=True, + history_length=len(self._conversation_history), ) + # 收集助手的完整回复 + assistant_response = "" async for chunk in response: content = chunk.choices[0].delta.content if content: + assistant_response += content yield content + # 将助手回复添加到历史记录 + if assistant_response: + assistant_message: ChatCompletionMessageParam = { + "role": "assistant", + "content": assistant_response, + } + self._conversation_history.append(assistant_message) + self.logger.info("对话历史记录已更新,当前消息数: %d", len(self._conversation_history)) + except Exception as e: + # 如果请求失败,移除刚添加的用户消息 + if ( + self._conversation_history + and len(self._conversation_history) > 0 + and self._conversation_history[-1].get("content") == prompt + ): + self._conversation_history.pop() + duration = time.time() - start_time log_exception(self.logger, "OpenAI 流式聊天 API 请求失败", e) # 记录失败的API请求 @@ -89,6 +123,15 @@ class OpenAIClient(LLMClientBase): ) raise + def reset_conversation(self) -> None: + """ + 重置对话上下文 + + 清空历史记录,开始新的对话会话。 + """ + self._conversation_history.clear() + self.logger.info("OpenAI 客户端对话历史记录已重置") + async def get_available_models(self) -> list[str]: """ 获取当前 LLM 服务中可用的模型,返回名称列表 -- Gitee From a066bfef50000dbfe7bdc565cafd36c9f6fa7c1e Mon Sep 17 00:00:00 2001 From: Hongyu Shi Date: Mon, 4 Aug 2025 20:28:32 +0800 Subject: [PATCH 2/6] chore: update package version and dependencies Signed-off-by: Hongyu Shi --- setup.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index d3fa7fe..5916349 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ from setuptools import find_packages, setup setup( name="oi-cli", - version="0.9.6", + version="0.10.0", description="智能 Shell 命令行工具", author="openEuler", author_email="contact@openeuler.org", @@ -18,8 +18,8 @@ setup( include_package_data=True, install_requires=[ "httpx>=0.28.0", - "openai>=1.93.0", - "rich>=14.0.0", + "openai>=1.97.0", + "rich>=14.1.0", "textual>=3.0.0", ], entry_points={ -- Gitee From 18418bdb45b166acf99c1bd259f2e59879357bc2 Mon Sep 17 00:00:00 2001 From: Hongyu Shi Date: Tue, 5 Aug 2025 16:29:23 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=E6=B7=BB=E5=8A=A0=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=81=9C=E6=AD=A2=E5=8A=9F=E8=83=BD=EF=BC=8C=E8=A7=A3=E5=86=B3?= =?UTF-8?q?=20429=20=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Hongyu Shi --- src/app/tui.py | 3 ++- src/backend/hermes/client.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/app/tui.py b/src/app/tui.py index 7f2a231..e021001 100644 --- a/src/app/tui.py +++ b/src/app/tui.py @@ -14,7 +14,6 @@ from textual.screen import ModalScreen from textual.widgets import Button, Footer, Header, Input, Label, Static from app.settings import SettingsScreen -from backend.base import LLMClientBase from backend.factory import BackendFactory from config import ConfigManager from tool.command_processor import process_command @@ -24,6 +23,8 @@ if TYPE_CHECKING: from textual.events import Mount from textual.visual import VisualType + from backend.base import LLMClientBase + class FocusableContainer(Container): """可聚焦的容器,用于接收键盘事件处理滚动""" diff --git a/src/backend/hermes/client.py b/src/backend/hermes/client.py index e4ed9c7..d7946e2 100644 --- a/src/backend/hermes/client.py +++ b/src/backend/hermes/client.py @@ -197,6 +197,9 @@ class HermesChatClient(LLMClientBase): HermesAPIError: 当 API 调用失败时 """ + # 如果有未完成的会话,先停止它 + await self._stop() + self.logger.info("开始 Hermes 流式聊天请求") start_time = time.time() @@ -336,6 +339,8 @@ class HermesChatClient(LLMClientBase): async def close(self) -> None: """关闭 HTTP 客户端""" + # 如果有未完成的会话,先停止它 + await self._stop() try: if self.client and not self.client.is_closed: await self.client.aclose() @@ -864,6 +869,29 @@ class HermesChatClient(LLMClientBase): else: return is_empty + async def _stop(self) -> None: + """停止当前会话""" + if self.client is None or self.client.is_closed: + return + + try: + stop_url = urljoin(self.base_url, "/api/stop") + headers = { + "Host": self._get_host_header(), + } + if self.auth_token: + headers["Authorization"] = f"Bearer {self.auth_token}" + + response = await self.client.post(stop_url, headers=headers) + + if response.status_code != HTTP_OK: + error_text = await response.aread() + raise HermesAPIError(response.status_code, error_text.decode("utf-8")) + + except httpx.RequestError as e: + log_exception(self.logger, "停止会话请求失败", e) + raise HermesAPIError(500, f"Failed to stop conversation: {e!s}") from e + async def __aenter__(self) -> Self: """异步上下文管理器入口""" return self -- Gitee From 017e6f24e9bc10d82a3ab81d5f9a9bbd8d8c44a0 Mon Sep 17 00:00:00 2001 From: Hongyu Shi Date: Tue, 5 Aug 2025 16:30:01 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E5=93=8D=E5=BA=94=E5=A4=84=E7=90=86=E5=92=8C=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Hongyu Shi --- src/app/settings.py | 5 + src/app/tui.py | 186 +++++++++++++++++++++++++---------- src/backend/hermes/client.py | 129 +++++++++++++++++------- src/main.py | 4 +- 4 files changed, 239 insertions(+), 85 deletions(-) diff --git a/src/app/settings.py b/src/app/settings.py index d3c4065..3e248f5 100644 --- a/src/app/settings.py +++ b/src/app/settings.py @@ -231,6 +231,11 @@ class SettingsScreen(Screen): self.config_manager.set_eulerintelli_url(base_url) self.config_manager.set_eulerintelli_key(api_key) + # 通知主应用刷新客户端 + from app.tui import IntelligentTerminal + if isinstance(self.app, IntelligentTerminal): + self.app.refresh_llm_client() + self.app.pop_screen() @on(Button.Pressed, "#cancel-btn") diff --git a/src/app/tui.py b/src/app/tui.py index e021001..bcaf730 100644 --- a/src/app/tui.py +++ b/src/app/tui.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, ClassVar, NamedTuple from rich.markdown import Markdown as RichMarkdown from textual import on @@ -26,6 +26,15 @@ if TYPE_CHECKING: from backend.base import LLMClientBase +class ContentChunkParams(NamedTuple): + """内容块处理参数""" + + content: str + is_llm_output: bool + current_content: str + is_first_content: bool + + class FocusableContainer(Container): """可聚焦的容器,用于接收键盘事件处理滚动""" @@ -175,13 +184,14 @@ class ExitDialog(ModalScreen): self.app.exit() -class Hermes(App): +class IntelligentTerminal(App): """基于 Textual 的智能终端应用""" CSS_PATH = "css/styles.tcss" BINDINGS: ClassVar[list[BindingType]] = [ Binding(key="ctrl+s", action="settings", description="设置"), + Binding(key="ctrl+r", action="reset_conversation", description="重置对话"), Binding(key="esc", action="request_quit", description="退出"), Binding(key="tab", action="toggle_focus", description="切换焦点"), ] @@ -189,10 +199,14 @@ class Hermes(App): def __init__(self) -> None: """初始化应用""" super().__init__() + # 设置应用标题 + self.title = "openEuler 智能 Shell" self.config_manager = ConfigManager() self.processing: bool = False # 添加保存任务的集合到类属性 self.background_tasks: set[asyncio.Task] = set() + # 创建并保持单一的 LLM 客户端实例以维持对话历史 + self._llm_client: LLMClientBase | None = None def compose(self) -> ComposeResult: """构建界面""" @@ -210,6 +224,13 @@ class Hermes(App): """请求退出应用""" self.push_screen(ExitDialog()) + def action_reset_conversation(self) -> None: + """重置对话历史记录的动作""" + self.reset_conversation() + # 清除屏幕上的所有内容 + output_container = self.query_one("#output-container") + output_container.remove_children() + def on_mount(self) -> None: """初始化完成时设置焦点""" self.query_one(CommandInput).focus() @@ -264,62 +285,116 @@ class Hermes(App): async def _process_command(self, user_input: str) -> None: """异步处理命令""" try: - current_line: OutputLine | MarkdownOutputLine | None = None - current_content = "" # 用于累积内容 output_container = self.query_one("#output-container", Container) - is_first_content = True # 标记是否是第一段内容 - - # 通过process_command获取命令处理结果和输出类型 - async for output_tuple in process_command(user_input, self._get_llm_client()): - content, is_llm_output = output_tuple # 解包输出内容和类型标志 - - # 处理第一段内容,创建适当的输出组件 - if is_first_content: - is_first_content = False - - if is_llm_output: - # LLM输出,使用富文本渲染 - current_line = MarkdownOutputLine(content) - current_content = content - else: - # 系统命令输出,使用纯文本 - current_line = OutputLine(content) - current_content = content - - # 将组件添加到输出容器 - output_container.mount(current_line) - # 处理后续内容 - elif is_llm_output and isinstance(current_line, MarkdownOutputLine): - # 继续累积LLM富文本内容 - current_content += content - current_line.update_markdown(current_content) - elif not is_llm_output and isinstance(current_line, OutputLine): - # 继续累积命令输出纯文本 - current_text = current_line.get_content() - current_line.update(current_text + content) - else: - # 输出类型发生变化,创建新的输出组件 - if is_llm_output: - current_line = MarkdownOutputLine(content) - current_content = content - else: - current_line = OutputLine(content) - current_content = content - - output_container.mount(current_line) - - # 滚动到底部 - await self._scroll_to_end() + received_any_content = await self._handle_command_stream(user_input, output_container) + + # 如果没有收到任何内容,显示错误信息 + if not received_any_content: + output_container.mount( + OutputLine("没有收到响应,请检查网络连接或稍后重试", command=False), + ) except (asyncio.CancelledError, OSError, ValueError) as e: # 添加异常处理,显示错误信息 output_container = self.query_one("#output-container", Container) - output_container.mount(OutputLine(f"处理命令时出错: {e!s}", command=False)) + error_msg = self._format_error_message(e) + output_container.mount(OutputLine(error_msg, command=False)) finally: # 重新聚焦到输入框 self.query_one(CommandInput).focus() # 注意:不在这里重置processing标志,由回调函数处理 + async def _handle_command_stream(self, user_input: str, output_container: Container) -> bool: + """处理命令流式响应""" + current_line: OutputLine | MarkdownOutputLine | None = None + current_content = "" # 用于累积内容 + is_first_content = True # 标记是否是第一段内容 + received_any_content = False # 标记是否收到任何内容 + start_time = asyncio.get_event_loop().time() + timeout_seconds = 30.0 # 30秒超时 + + # 通过 process_command 获取命令处理结果和输出类型 + async for output_tuple in process_command(user_input, self._get_llm_client()): + content, is_llm_output = output_tuple # 解包输出内容和类型标志 + received_any_content = True + + # 检查超时 + if asyncio.get_event_loop().time() - start_time > timeout_seconds: + output_container.mount(OutputLine("请求超时,已停止处理", command=False)) + break + + # 处理内容 + params = ContentChunkParams( + content=content, + is_llm_output=is_llm_output, + current_content=current_content, + is_first_content=is_first_content, + ) + current_line = await self._process_content_chunk( + params, + current_line, + output_container, + ) + + # 更新状态 + if is_first_content: + is_first_content = False + current_content = content + elif isinstance(current_line, MarkdownOutputLine) and is_llm_output: + current_content += content + + # 滚动到底部 + await self._scroll_to_end() + + return received_any_content + + async def _process_content_chunk( + self, + params: ContentChunkParams, + current_line: OutputLine | MarkdownOutputLine | None, + output_container: Container, + ) -> OutputLine | MarkdownOutputLine: + """处理单个内容块""" + content = params.content + is_llm_output = params.is_llm_output + current_content = params.current_content + is_first_content = params.is_first_content + + # 处理第一段内容,创建适当的输出组件 + if is_first_content: + new_line: OutputLine | MarkdownOutputLine = ( + MarkdownOutputLine(content) if is_llm_output else OutputLine(content) + ) + output_container.mount(new_line) + return new_line + + # 处理后续内容 + if is_llm_output and isinstance(current_line, MarkdownOutputLine): + # 继续累积LLM富文本内容 + updated_content = current_content + content + current_line.update_markdown(updated_content) + return current_line + + if not is_llm_output and isinstance(current_line, OutputLine): + # 继续累积命令输出纯文本 + current_text = current_line.get_content() + current_line.update(current_text + content) + return current_line + + # 输出类型发生变化,创建新的输出组件 + new_line = MarkdownOutputLine(content) if is_llm_output else OutputLine(content) + output_container.mount(new_line) + return new_line + + def _format_error_message(self, error: BaseException) -> str: + """格式化错误消息""" + error_str = str(error).lower() + if "timeout" in error_str: + return "请求超时,请稍后重试" + if any(keyword in error_str for keyword in ["network", "connection"]): + return "网络连接错误,请检查网络后重试" + return f"处理命令时出错: {error!s}" + async def _scroll_to_end(self) -> None: """滚动到容器底部的辅助方法""" # 获取输出容器 @@ -330,8 +405,19 @@ class Hermes(App): await asyncio.sleep(0.01) def _get_llm_client(self) -> LLMClientBase: - """获取大模型客户端""" - return BackendFactory.create_client(self.config_manager) + """获取大模型客户端,使用单例模式维持对话历史""" + if self._llm_client is None: + self._llm_client = BackendFactory.create_client(self.config_manager) + return self._llm_client + + def refresh_llm_client(self) -> None: + """刷新 LLM 客户端实例,用于配置更改后重新创建客户端""" + self._llm_client = BackendFactory.create_client(self.config_manager) + + def reset_conversation(self) -> None: + """重置对话历史记录""" + if self._llm_client is not None and hasattr(self._llm_client, "reset_conversation"): + self._llm_client.reset_conversation() def action_toggle_focus(self) -> None: """在命令输入框和文本区域之间切换焦点""" diff --git a/src/backend/hermes/client.py b/src/backend/hermes/client.py index d7946e2..72388f5 100644 --- a/src/backend/hermes/client.py +++ b/src/backend/hermes/client.py @@ -134,11 +134,17 @@ class HermesStreamEvent: data_str = line[6:] # 去掉 "data: " 前缀 - if data_str == "[DONE]": - return cls("done", {}) + # 处理特殊字段 + special_events = { + "[DONE]": ("done", {}), + "[ERROR]": ("error", {"error": "Backend error occurred"}), + "[SENSITIVE]": ("sensitive", {"message": "Content contains sensitive information"}), + '{"event": "heartbeat"}': ("heartbeat", {}), + } - if data_str == '{"event": "heartbeat"}': - return cls("heartbeat", {}) + if data_str in special_events: + event_type, data = special_events[data_str] + return cls(event_type, data) try: data = json.loads(data_str) @@ -546,6 +552,78 @@ class HermesChatClient(LLMClientBase): return self._conversation_id + def _build_chat_headers(self) -> dict[str, str]: + """构建聊天请求的HTTP头部""" + headers = { + "Host": self._get_host_header(), + } + if self.auth_token: + headers["Authorization"] = f"Bearer {self.auth_token}" + return headers + + async def _validate_chat_response(self, response: httpx.Response) -> None: + """验证聊天响应状态""" + if response.status_code != HTTP_OK: + error_text = await response.aread() + raise HermesAPIError( + response.status_code, + error_text.decode("utf-8"), + ) + + async def _process_stream_events(self, response: httpx.Response) -> AsyncGenerator[str, None]: + """处理流式响应事件""" + has_content = False + event_count = 0 + + async for line in response.aiter_lines(): + stripped_line = line.strip() + if not stripped_line: + continue + + self.logger.debug("收到 SSE 行: %s", stripped_line) + event = HermesStreamEvent.from_line(stripped_line) + if event is None: + self.logger.debug("无法解析 SSE 事件") + continue + + event_count += 1 + self.logger.debug("解析到事件 #%d - 类型: %s", event_count, event.event_type) + + # 处理完成事件 + if event.event_type == "done": + self.logger.debug("收到完成事件,结束流式响应") + break + + # 处理错误事件 + if event.event_type == "error": + self.logger.error("收到后端错误事件: %s", event.data.get("error", "Unknown error")) + yield "抱歉,后端服务出现错误,请稍后重试。" + break + + # 处理敏感内容事件 + if event.event_type == "sensitive": + self.logger.warning("收到敏感内容事件: %s", event.data.get("message", "Sensitive content detected")) + yield "抱歉,响应内容包含敏感信息,已被系统屏蔽。" + break + + # 获取文本内容 + text_content = event.get_text_content() + if text_content: + has_content = True + self._log_text_content(text_content) + yield text_content + else: + self.logger.debug("事件无文本内容") + + # 检查是否产生了任何内容 + if not has_content: + self.logger.warning( + "流式响应完成但未产生任何文本内容 - 事件总数: %d", + event_count, + ) + # 如果没有产生任何内容,yield 一个错误信息 + yield "抱歉,服务暂时无法响应您的请求,请稍后重试。" + async def _chat_stream( self, request: HermesChatRequest, @@ -565,12 +643,7 @@ class HermesChatClient(LLMClientBase): """ client = await self._get_client() chat_url = urljoin(self.base_url, "/api/chat") - - headers = { - "Host": self._get_host_header(), - } - if self.auth_token: - headers["Authorization"] = f"Bearer {self.auth_token}" + headers = self._build_chat_headers() try: async with client.stream( @@ -579,35 +652,25 @@ class HermesChatClient(LLMClientBase): json=request.to_dict(), headers=headers, ) as response: - if response.status_code != HTTP_OK: - error_text = await response.aread() - raise HermesAPIError( - response.status_code, - error_text.decode("utf-8"), - ) - - async for line in response.aiter_lines(): - if not line.strip(): - continue - - event = HermesStreamEvent.from_line(line) - if event is None: - continue - - # 处理完成事件 - if event.event_type == "done": - break - - # 获取文本内容 - text_content = event.get_text_content() - if text_content: - yield text_content + await self._validate_chat_response(response) + async for text in self._process_stream_events(response): + yield text except httpx.RequestError as e: raise HermesAPIError(500, f"Network error: {e!s}") from e except (json.JSONDecodeError, KeyError, ValueError) as e: raise HermesAPIError(500, f"Data parsing error: {e!s}") from e + def _log_text_content(self, text_content: str) -> None: + """记录文本内容到日志""" + max_log_length = 50 + display_text = ( + text_content[:max_log_length] + "..." + if len(text_content) > max_log_length + else text_content + ) + self.logger.debug("产生文本内容: %s", display_text) + async def _get_conversation_list(self) -> list[str]: """ 获取会话ID列表,按创建时间从新到旧排序 diff --git a/src/main.py b/src/main.py index 5a031df..4b3c916 100644 --- a/src/main.py +++ b/src/main.py @@ -4,7 +4,7 @@ import argparse import atexit import sys -from app.tui import Hermes +from app.tui import IntelligentTerminal from log.manager import ( cleanup_empty_logs, disable_console_output, @@ -62,7 +62,7 @@ def main() -> None: atexit.register(cleanup_empty_logs) try: - app = Hermes() + app = IntelligentTerminal() app.run() except Exception: logger.exception("智能 Shell 应用发生致命错误") -- Gitee From 89bc6aabfb74c1403cfa36edcbf402f061c899b8 Mon Sep 17 00:00:00 2001 From: Hongyu Shi Date: Wed, 6 Aug 2025 15:54:54 +0800 Subject: [PATCH 5/6] feat: close client on exit Signed-off-by: Hongyu Shi --- src/app/tui.py | 60 ++++++++++++++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/src/app/tui.py b/src/app/tui.py index bcaf730..900bbfb 100644 --- a/src/app/tui.py +++ b/src/app/tui.py @@ -16,6 +16,7 @@ from textual.widgets import Button, Footer, Header, Input, Label, Static from app.settings import SettingsScreen from backend.factory import BackendFactory from config import ConfigManager +from log.manager import get_logger, log_exception from tool.command_processor import process_command if TYPE_CHECKING: @@ -207,6 +208,8 @@ class IntelligentTerminal(App): self.background_tasks: set[asyncio.Task] = set() # 创建并保持单一的 LLM 客户端实例以维持对话历史 self._llm_client: LLMClientBase | None = None + # 创建日志实例 + self.logger = get_logger(__name__) def compose(self) -> ComposeResult: """构建界面""" @@ -226,11 +229,24 @@ class IntelligentTerminal(App): def action_reset_conversation(self) -> None: """重置对话历史记录的动作""" - self.reset_conversation() + if self._llm_client is not None and hasattr(self._llm_client, "reset_conversation"): + self._llm_client.reset_conversation() # 清除屏幕上的所有内容 output_container = self.query_one("#output-container") output_container.remove_children() + def action_toggle_focus(self) -> None: + """在命令输入框和文本区域之间切换焦点""" + # 获取当前聚焦的组件 + focused = self.focused + if isinstance(focused, CommandInput): + # 如果当前聚焦在命令输入框,则聚焦到输出容器 + output_container = self.query_one("#output-container", FocusableContainer) + output_container.focus() + else: + # 否则聚焦到命令输入框 + self.query_one(CommandInput).focus() + def on_mount(self) -> None: """初始化完成时设置焦点""" self.query_one(CommandInput).focus() @@ -241,6 +257,23 @@ class IntelligentTerminal(App): for task in self.background_tasks: if not task.done(): task.cancel() + + # 清理 LLM 客户端连接 + if self._llm_client is not None: + try: + # 创建新的事件循环来处理异步清理 + import asyncio + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(self._llm_client.close()) + self.log.info("LLM 客户端已安全关闭") + finally: + loop.close() + except (OSError, RuntimeError, ValueError) as e: + # 使用项目的日志异常处理函数 + log_exception(self.logger, "关闭 LLM 客户端时出错", e) + # 调用父类的exit方法 super().exit(*args, **kwargs) @@ -267,6 +300,10 @@ class IntelligentTerminal(App): # 添加完成回调,自动从集合中移除 task.add_done_callback(self._task_done_callback) + def refresh_llm_client(self) -> None: + """刷新 LLM 客户端实例,用于配置更改后重新创建客户端""" + self._llm_client = BackendFactory.create_client(self.config_manager) + def _task_done_callback(self, task: asyncio.Task) -> None: """任务完成回调,从任务集合中移除""" if task in self.background_tasks: @@ -409,24 +446,3 @@ class IntelligentTerminal(App): if self._llm_client is None: self._llm_client = BackendFactory.create_client(self.config_manager) return self._llm_client - - def refresh_llm_client(self) -> None: - """刷新 LLM 客户端实例,用于配置更改后重新创建客户端""" - self._llm_client = BackendFactory.create_client(self.config_manager) - - def reset_conversation(self) -> None: - """重置对话历史记录""" - if self._llm_client is not None and hasattr(self._llm_client, "reset_conversation"): - self._llm_client.reset_conversation() - - def action_toggle_focus(self) -> None: - """在命令输入框和文本区域之间切换焦点""" - # 获取当前聚焦的组件 - focused = self.focused - if isinstance(focused, CommandInput): - # 如果当前聚焦在命令输入框,则聚焦到输出容器 - output_container = self.query_one("#output-container", FocusableContainer) - output_container.focus() - else: - # 否则聚焦到命令输入框 - self.query_one(CommandInput).focus() -- Gitee From 1115183a5550367523f827d7cc55b96410711fbf Mon Sep 17 00:00:00 2001 From: Hongyu Shi Date: Wed, 6 Aug 2025 15:56:36 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96LLM=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E6=B8=85=E7=90=86=E6=B5=81=E7=A8=8B=EF=BC=8C?= =?UTF-8?q?=E7=A1=AE=E4=BF=9D=E5=AE=89=E5=85=A8=E9=80=80=E5=87=BA=E6=97=B6?= =?UTF-8?q?=E5=85=B3=E9=97=AD=E8=BF=9E=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Hongyu Shi --- src/app/tui.py | 70 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 48 insertions(+), 22 deletions(-) diff --git a/src/app/tui.py b/src/app/tui.py index 900bbfb..4b42e48 100644 --- a/src/app/tui.py +++ b/src/app/tui.py @@ -259,23 +259,34 @@ class IntelligentTerminal(App): task.cancel() # 清理 LLM 客户端连接 + if self._llm_client is not None: + # 创建清理任务并在当前事件循环中执行 + cleanup_task = asyncio.create_task(self._cleanup_llm_client()) + self.background_tasks.add(cleanup_task) + cleanup_task.add_done_callback(self._cleanup_task_done_callback) + + # 调用父类的exit方法 + super().exit(*args, **kwargs) + + async def _cleanup_llm_client(self) -> None: + """异步清理 LLM 客户端""" if self._llm_client is not None: try: - # 创建新的事件循环来处理异步清理 - import asyncio - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - loop.run_until_complete(self._llm_client.close()) - self.log.info("LLM 客户端已安全关闭") - finally: - loop.close() + await self._llm_client.close() + self.logger.info("LLM 客户端已安全关闭") except (OSError, RuntimeError, ValueError) as e: - # 使用项目的日志异常处理函数 log_exception(self.logger, "关闭 LLM 客户端时出错", e) - # 调用父类的exit方法 - super().exit(*args, **kwargs) + def _cleanup_task_done_callback(self, task: asyncio.Task) -> None: + """清理任务完成回调""" + if task in self.background_tasks: + self.background_tasks.remove(task) + try: + task.result() + except asyncio.CancelledError: + pass + except (OSError, ValueError, RuntimeError): + self.logger.exception("LLM client cleanup error") @on(Input.Submitted, "#command-input") def handle_input(self, event: Input.Submitted) -> None: @@ -312,9 +323,10 @@ class IntelligentTerminal(App): try: task.result() except asyncio.CancelledError: + # 任务被取消是正常情况,不需要记录错误 pass - except (OSError, ValueError, RuntimeError) as e: - self.log.error("Command processing error: %s", e) # noqa: TRY400 + except (OSError, ValueError, RuntimeError): + self.logger.exception("Command processing error") finally: # 确保处理标志被重置 self.processing = False @@ -325,20 +337,34 @@ class IntelligentTerminal(App): output_container = self.query_one("#output-container", Container) received_any_content = await self._handle_command_stream(user_input, output_container) - # 如果没有收到任何内容,显示错误信息 - if not received_any_content: + # 如果没有收到任何内容且应用仍在运行,显示错误信息 + if not received_any_content and hasattr(self, "is_running") and self.is_running: output_container.mount( OutputLine("没有收到响应,请检查网络连接或稍后重试", command=False), ) - except (asyncio.CancelledError, OSError, ValueError) as e: + except asyncio.CancelledError: + # 任务被取消,通常是因为应用退出 + self.logger.info("Command processing cancelled") + except (OSError, ValueError) as e: # 添加异常处理,显示错误信息 - output_container = self.query_one("#output-container", Container) - error_msg = self._format_error_message(e) - output_container.mount(OutputLine(error_msg, command=False)) + try: + output_container = self.query_one("#output-container", Container) + error_msg = self._format_error_message(e) + # 检查应用是否已经开始退出 + if hasattr(self, "is_running") and self.is_running: + output_container.mount(OutputLine(error_msg, command=False)) + except (AttributeError, ValueError, RuntimeError): + # 如果UI组件已不可用,只记录错误日志 + self.logger.exception("Failed to display error message") finally: - # 重新聚焦到输入框 - self.query_one(CommandInput).focus() + # 重新聚焦到输入框(如果应用仍在运行) + try: + if hasattr(self, "is_running") and self.is_running: + self.query_one(CommandInput).focus() + except (AttributeError, ValueError, RuntimeError): + # 应用可能正在退出,忽略聚焦错误 + self.logger.debug("Failed to focus input widget, app may be exiting") # 注意:不在这里重置processing标志,由回调函数处理 async def _handle_command_stream(self, user_input: str, output_container: Container) -> bool: -- Gitee