diff --git a/distribution/linux/euler-copilot-shell.spec b/distribution/linux/euler-copilot-shell.spec index b0a27fc1d5db6540d2ffc758b88e607ae22f8bc3..c69d5e794f720ee189d97b643fa19860b36b2a99 100644 --- a/distribution/linux/euler-copilot-shell.spec +++ b/distribution/linux/euler-copilot-shell.spec @@ -3,7 +3,7 @@ %global debug_package %{nil} Name: euler-copilot-shell -Version: 0.10.2 +Version: 2.0.0 Release: 1%{?dev_timestamp:.dev%{dev_timestamp}}%{?dist} Summary: openEuler Intelligence 智能命令行工具集 License: MulanPSL-2.0 diff --git a/pyproject.toml b/pyproject.toml index 98c8633a7794d0b3aae8a238d0094688ef36a0a5..2a49631dac48b7d1264597f80c6137188a779361 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "oi-cli" -version = "0.10.2" +version = "2.0.0" description = "智能 Shell 命令行工具" readme = "README.md" requires-python = ">=3.11" diff --git a/scripts/tools/i18n-manager.sh b/scripts/tools/i18n-manager.sh index ef38b42c68b1330c5fb3031d66c81fab32e4af38..7d3ebc61ad048e2fffe52285555a94e72756f07f 100755 --- a/scripts/tools/i18n-manager.sh +++ b/scripts/tools/i18n-manager.sh @@ -93,7 +93,7 @@ extract() { --output="$POT_FILE" \ --from-code=UTF-8 \ --package-name=oi-cli \ - --package-version=0.10.2 \ + --package-version=2.0.0 \ --msgid-bugs-address=contact@openeuler.org \ --copyright-holder="openEuler Intelligence Project" \ --add-comments=Translators \ diff --git a/src/__version__.py b/src/__version__.py index a0a887adf831ab4a0d88086a71999e95138949aa..d6fc4d3fc2afe5cba3ac07bb8db539bc47a6d541 100644 --- a/src/__version__.py +++ b/src/__version__.py @@ -1,3 +1,3 @@ """版本信息模块""" -__version__ = "0.10.2" +__version__ = "2.0.0" diff --git a/src/app/css/styles.tcss b/src/app/css/styles.tcss index c89bbfa4e3e06f2b3f815f5bc3b63f1eb917a08d..572a777d4791c4c703deb9b2361eed4aa5338f24 100644 --- a/src/app/css/styles.tcss +++ b/src/app/css/styles.tcss @@ -485,3 +485,99 @@ BackendRequiredDialog { min-height: 3; height: 3; } + +/* LLM 配置对话框样式 */ +LLMConfigDialog { + align: center middle; +} + +#llm-dialog-screen { + align: center middle; + width: 70%; + height: 80%; +} + +#llm-dialog { + background: $surface; + border: solid #4963b1; + padding-left: 1; + padding-right: 1; + padding-bottom: 1; + width: 100%; + height: 100%; +} + +/* 标签页容器 */ +#llm-tabs { + height: 1fr; + margin: 1 0; +} + +/* 模型列表容器 */ +.llm-model-list { + height: 1fr; + overflow-y: auto; + scrollbar-size: 1 1; + border: solid #688efd; + padding: 1; + margin-bottom: 1; +} + +/* 模型项样式 */ +.llm-model-item { + padding: 0 1; + color: #ffffff; +} + +/* 选中的模型 */ +.llm-model-selected { + color: #4caf50; + text-style: bold; +} + +/* 光标所在的模型 */ +.llm-model-cursor { + background: rgba(104, 142, 253, 0.3); +} + +/* 模型详情容器 */ +.llm-model-detail { + border: solid #888888; + padding: 1; + background: rgba(73, 99, 177, 0.1); +} + +/* 详情行 */ +.llm-detail-row { + height: auto; + padding: 0; + margin: 0; +} + +/* 详情标签 */ +.llm-detail-label { + color: #888888; + width: auto; + padding-right: 1; +} + +/* 详情值 */ +.llm-detail-value { + color: #ffffff; + width: 1fr; +} + +/* 帮助文本 */ +#llm-dialog-help { + text-align: center; + color: #888888; + text-style: italic; + padding-top: 1; +} + +/* 加载状态 */ +.llm-loading, .llm-error, .llm-empty { + text-align: center; + color: #888888; + padding: 2; +} diff --git a/src/app/dialogs/__init__.py b/src/app/dialogs/__init__.py index b73f00caeed85cc2636726e7ad9c556392c6ce6c..287816b7bca18f79ef6ff03f9c098936ef8b54c3 100644 --- a/src/app/dialogs/__init__.py +++ b/src/app/dialogs/__init__.py @@ -2,5 +2,6 @@ from .agent import AgentSelectionDialog, BackendRequiredDialog from .common import ExitDialog +from .llm import LLMConfigDialog -__all__ = ["AgentSelectionDialog", "BackendRequiredDialog", "ExitDialog"] +__all__ = ["AgentSelectionDialog", "BackendRequiredDialog", "ExitDialog", "LLMConfigDialog"] diff --git a/src/app/dialogs/llm.py b/src/app/dialogs/llm.py new file mode 100644 index 0000000000000000000000000000000000000000..b32b1fb731a79957b88480b9b18fa14190b57579 --- /dev/null +++ b/src/app/dialogs/llm.py @@ -0,0 +1,349 @@ +"""LLM 模型配置对话框""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +from textual import on +from textual.containers import Container, Horizontal +from textual.screen import ModalScreen +from textual.widgets import Label, Static, TabbedContent, TabPane + +from backend.models import LLMType, ModelInfo +from i18n.manager import _ + +if TYPE_CHECKING: + from textual.app import ComposeResult + + from backend.base import LLMClientBase + from config.manager import ConfigManager + + +class LLMConfigDialog(ModalScreen): + """LLM 模型配置对话框""" + + BINDINGS: ClassVar[list[tuple[str, str, str]]] = [ + ("left", "previous_tab", _("上一个标签")), + ("right", "next_tab", _("下一个标签")), + ("up", "previous_model", _("上一个模型")), + ("down", "next_model", _("下一个模型")), + ("space", "select_model", _("选择模型")), + ("enter", "save_and_close", _("保存并关闭")), + ("escape", "cancel", _("取消")), + ] + + def __init__( + self, + config_manager: ConfigManager, + llm_client: LLMClientBase, + ) -> None: + """ + 初始化 LLM 配置对话框 + + Args: + config_manager: 配置管理器 + llm_client: LLM 客户端(用于获取模型列表) + + """ + super().__init__() + self.config_manager = config_manager + self.llm_client = llm_client + + # 模型数据 + self.all_models: list[ModelInfo] = [] + self.chat_models: list[ModelInfo] = [] + self.function_models: list[ModelInfo] = [] + + # 当前选择的模型(临时状态,未保存) + self.selected_chat_model = config_manager.get_llm_chat_model() + self.selected_function_model = config_manager.get_llm_function_model() + + # 当前标签页 + self.current_tab = "chat" + + # 当前光标位置(每个标签页独立) + self.chat_cursor = 0 + self.function_cursor = 0 + + # 加载状态 + self.models_loaded = False + self.loading_error = False + + def compose(self) -> ComposeResult: + """构建对话框""" + with Container(id="llm-dialog-screen"), Container(id="llm-dialog"): + # 标签页容器 + with TabbedContent(id="llm-tabs", initial="chat-tab"): + with TabPane(_("基础模型"), id="chat-tab"): + pass # 内容将在 on_mount 中动态添加 + with TabPane(_("工具调用"), id="function-tab"): + pass # 内容将在 on_mount 中动态添加 + # 帮助文本 + yield Static( + _("↑↓: 选择模型 ←→: 切换标签 空格: 确认 回车: 保存 ESC: 取消"), + id="llm-dialog-help", + ) + + async def on_mount(self) -> None: + """组件挂载时加载模型列表""" + await self._load_models() + self._render_all_tabs() + self._update_cursor_positions() + + async def _load_models(self) -> None: + """异步加载模型列表""" + try: + # 获取所有可用模型 + if hasattr(self.llm_client, "get_available_models"): + self.all_models = await self.llm_client.get_available_models() # type: ignore[attr-defined] + + # 按类型分类模型 + self.chat_models = [model for model in self.all_models if LLMType.CHAT in model.llm_type] + self.function_models = [model for model in self.all_models if LLMType.FUNCTION in model.llm_type] + + self.models_loaded = True + self.loading_error = False + else: + self.models_loaded = False + self.loading_error = True + + except (OSError, ValueError, RuntimeError): + self.models_loaded = False + self.loading_error = True + + def _render_all_tabs(self) -> None: + """渲染所有标签页内容""" + self._render_tab_content("chat-tab", self.chat_models, self.selected_chat_model, self.chat_cursor) + self._render_tab_content( + "function-tab", + self.function_models, + self.selected_function_model, + self.function_cursor, + ) + + def _render_tab_content( + self, + tab_id: str, + models: list[ModelInfo], + selected_llm_id: str, + cursor_index: int, + ) -> None: + """ + 渲染标签页内容 + + Args: + tab_id: 标签页 ID + models: 模型列表 + selected_llm_id: 当前选中的模型 llmId + cursor_index: 光标位置 + + """ + tab_pane = self.query_one(f"#{tab_id}", TabPane) + + # 清空现有内容 + tab_pane.remove_children() + + if not self.models_loaded: + if self.loading_error: + tab_pane.mount(Static(_("加载模型失败"), classes="llm-error")) + else: + tab_pane.mount(Static(_("加载中..."), classes="llm-loading")) + return + + if not models: + tab_pane.mount(Static(_("暂无可用模型"), classes="llm-empty")) + return + + # 渲染模型列表 + model_list_container = Container(id=f"{tab_id}-list", classes="llm-model-list") + for i, model in enumerate(models): + is_selected = model.llm_id == selected_llm_id + is_cursor = i == cursor_index + + # 构建样式类 + classes = "llm-model-item" + if is_selected: + classes += " llm-model-selected" + if is_cursor: + classes += " llm-model-cursor" + + model_item = Static(model.model_name, classes=classes) + model_list_container.mount(model_item) + + tab_pane.mount(model_list_container) + + # 显示当前光标所指模型的详细信息 + if 0 <= cursor_index < len(models): + current_model = models[cursor_index] + detail_container = self._create_model_detail(current_model) + tab_pane.mount(detail_container) + + def _create_model_detail(self, model: ModelInfo) -> Container: + """ + 创建模型详情容器 + + Args: + model: 模型信息 + + Returns: + 包含模型详情的容器 + + """ + detail_container = Container(classes="llm-model-detail") + + # 模型描述 + if model.llm_description: + detail_container.mount( + Horizontal( + Label(_("描述: "), classes="llm-detail-label"), + Static(model.llm_description, classes="llm-detail-value"), + classes="llm-detail-row", + ), + ) + + # 模型类型标签 + if model.llm_type: + type_str = ", ".join([t.value for t in model.llm_type]) + detail_container.mount( + Horizontal( + Label(_("类型: "), classes="llm-detail-label"), + Static(type_str, classes="llm-detail-value"), + classes="llm-detail-row", + ), + ) + + # 最大 token 数 + if model.max_tokens: + detail_container.mount( + Horizontal( + Label(_("最大 Token: "), classes="llm-detail-label"), + Static(str(model.max_tokens), classes="llm-detail-value"), + classes="llm-detail-row", + ), + ) + + return detail_container + + def _update_cursor_positions(self) -> None: + """根据已保存的配置更新光标位置""" + # 基础模型光标 + if self.selected_chat_model: + for i, model in enumerate(self.chat_models): + if model.llm_id == self.selected_chat_model: + self.chat_cursor = i + break + + # 工具调用模型光标 + if self.selected_function_model: + for i, model in enumerate(self.function_models): + if model.llm_id == self.selected_function_model: + self.function_cursor = i + break + + @on(TabbedContent.TabActivated) + def on_tab_changed(self, event: TabbedContent.TabActivated) -> None: + """标签页切换事件""" + tab_id = event.tab.id + if tab_id == "chat-tab": + self.current_tab = "chat" + elif tab_id == "function-tab": + self.current_tab = "function" + + def action_previous_tab(self) -> None: + """切换到上一个标签页""" + tabs = self.query_one("#llm-tabs", TabbedContent) + if self.current_tab == "chat": + tabs.active = "function-tab" + elif self.current_tab == "function": + tabs.active = "chat-tab" + + def action_next_tab(self) -> None: + """切换到下一个标签页""" + tabs = self.query_one("#llm-tabs", TabbedContent) + if self.current_tab == "chat": + tabs.active = "function-tab" + elif self.current_tab == "function": + tabs.active = "chat-tab" + + def action_previous_model(self) -> None: + """选择上一个模型""" + if not self.models_loaded: + return + + models = self._get_current_models() + if not models: + return + + if self.current_tab == "chat": + self.chat_cursor = max(0, self.chat_cursor - 1) + self._render_tab_content("chat-tab", self.chat_models, self.selected_chat_model, self.chat_cursor) + elif self.current_tab == "function": + self.function_cursor = max(0, self.function_cursor - 1) + self._render_tab_content( + "function-tab", + self.function_models, + self.selected_function_model, + self.function_cursor, + ) + + def action_next_model(self) -> None: + """选择下一个模型""" + if not self.models_loaded: + return + + models = self._get_current_models() + if not models: + return + + if self.current_tab == "chat": + self.chat_cursor = min(len(self.chat_models) - 1, self.chat_cursor + 1) + self._render_tab_content("chat-tab", self.chat_models, self.selected_chat_model, self.chat_cursor) + elif self.current_tab == "function": + self.function_cursor = min(len(self.function_models) - 1, self.function_cursor + 1) + self._render_tab_content( + "function-tab", + self.function_models, + self.selected_function_model, + self.function_cursor, + ) + + def action_select_model(self) -> None: + """确认选择当前光标所在的模型(临时选择,未保存)""" + if not self.models_loaded: + return + + models = self._get_current_models() + if not models: + return + + if self.current_tab == "chat" and 0 <= self.chat_cursor < len(self.chat_models): + self.selected_chat_model = self.chat_models[self.chat_cursor].llm_id or "" + self._render_tab_content("chat-tab", self.chat_models, self.selected_chat_model, self.chat_cursor) + elif self.current_tab == "function" and 0 <= self.function_cursor < len(self.function_models): + self.selected_function_model = self.function_models[self.function_cursor].llm_id or "" + self._render_tab_content( + "function-tab", + self.function_models, + self.selected_function_model, + self.function_cursor, + ) + + def action_save_and_close(self) -> None: + """保存配置并关闭对话框""" + # 保存所有选择 + self.config_manager.set_llm_chat_model(self.selected_chat_model) + self.config_manager.set_llm_function_model(self.selected_function_model) + + self.app.pop_screen() + + def action_cancel(self) -> None: + """取消并关闭对话框""" + self.app.pop_screen() + + def _get_current_models(self) -> list[ModelInfo]: + """获取当前标签页的模型列表""" + if self.current_tab == "chat": + return self.chat_models + if self.current_tab == "function": + return self.function_models + return [] diff --git a/src/app/settings.py b/src/app/settings.py index 4d2ec66313c7a26990d3c58b77323f18dadffb00..2158c3151dc2c691cf7626a5894659e56ae64cf3 100644 --- a/src/app/settings.py +++ b/src/app/settings.py @@ -11,7 +11,7 @@ from textual.css.query import NoMatches from textual.screen import ModalScreen from textual.widgets import Button, Input, Label, Static -from app.dialogs import ExitDialog +from app.dialogs import ExitDialog, LLMConfigDialog from backend.hermes import HermesChatClient from backend.openai import OpenAIClient from config import Backend, ConfigManager @@ -61,71 +61,8 @@ class SettingsScreen(ModalScreen): yield Container( Container( Label(_("设置"), id="settings-title"), - # 后端选择 - Horizontal( - Label(_("后端:"), classes="settings-label"), - Button( - f"{self.backend.get_display_name()}", - id="backend-btn", - classes="settings-button", - ), - classes="settings-option", - ), - # Base URL 输入 - Horizontal( - Label(_("Base URL:"), classes="settings-label"), - Input( - value=self.config_manager.get_base_url() - if self.backend == Backend.OPENAI - else self.config_manager.get_eulerintelli_url(), - classes="settings-input", - id="base-url", - ), - classes="settings-option", - ), - # API Key 输入 - Horizontal( - Label(_("API Key:"), classes="settings-label"), - Input( - value=self.config_manager.get_api_key() - if self.backend == Backend.OPENAI - else self.config_manager.get_eulerintelli_key(), - classes="settings-input", - id="api-key", - placeholder=_("API 访问密钥,可选"), - ), - classes="settings-option", - ), - # 模型选择(仅 OpenAI 后端显示) - *( - [ - Horizontal( - Label(_("模型:"), classes="settings-label"), - Input( - value=self.selected_model, - classes="settings-input", - id="model-input", - placeholder=_("模型名称,可选"), - ), - id="model-section", - classes="settings-option", - ), - ] - if self.backend == Backend.OPENAI - else [ - Horizontal( - Label(_("MCP 工具授权:"), classes="settings-label"), - Button( - _("自动执行") if self.auto_execute_status else _("手动确认"), - id="mcp-btn", - classes="settings-button", - disabled=not self.mcp_status_loaded, - ), - id="mcp-section", - classes="settings-option", - ), - ] - ), + *self._create_common_widgets(), + *self._create_backend_widgets(), # 添加一个空白区域,确保操作按钮始终可见 Static("", id="spacer"), # 操作按钮 @@ -154,6 +91,88 @@ class SettingsScreen(ModalScreen): # 确保操作按钮始终可见 self._ensure_buttons_visible() + def _create_common_widgets(self) -> list: + """创建通用的 UI 组件(所有后端共享)""" + return [ + # 后端选择 + Horizontal( + Label(_("后端:"), classes="settings-label"), + Button( + f"{self.backend.get_display_name()}", + id="backend-btn", + classes="settings-button", + ), + classes="settings-option", + ), + # Base URL 输入 + Horizontal( + Label(_("Base URL:"), classes="settings-label"), + Input( + value=self.config_manager.get_base_url() + if self.backend == Backend.OPENAI + else self.config_manager.get_eulerintelli_url(), + classes="settings-input", + id="base-url", + ), + classes="settings-option", + ), + # API Key 输入 + Horizontal( + Label(_("API Key:"), classes="settings-label"), + Input( + value=self.config_manager.get_api_key() + if self.backend == Backend.OPENAI + else self.config_manager.get_eulerintelli_key(), + classes="settings-input", + id="api-key", + placeholder=_("API 访问密钥,可选"), + ), + classes="settings-option", + ), + ] + + def _create_backend_widgets(self) -> list: + """创建后端特定的 UI 组件""" + if self.backend == Backend.OPENAI: + return [ + Horizontal( + Label(_("模型:"), classes="settings-label"), + Input( + value=self.selected_model, + classes="settings-input", + id="model-input", + placeholder=_("模型名称,可选"), + ), + id="model-section", + classes="settings-option", + ), + ] + + # EULERINTELLI 后端 + return [ + Horizontal( + Label(_("MCP 工具授权:"), classes="settings-label"), + Button( + _("自动执行") if self.auto_execute_status else _("手动确认"), + id="mcp-btn", + classes="settings-button", + disabled=not self.mcp_status_loaded, + ), + id="mcp-section", + classes="settings-option", + ), + Horizontal( + Label(_("LLM 配置:"), classes="settings-label"), + Button( + _("配置模型"), + id="llm-config-btn", + classes="settings-button", + ), + id="llm-config-section", + classes="settings-option", + ), + ] + async def load_mcp_status(self) -> None: """异步加载 MCP 工具授权状态""" try: @@ -207,90 +226,23 @@ class SettingsScreen(ModalScreen): @on(Button.Pressed, "#backend-btn") def toggle_backend(self) -> None: """切换后端""" - current = self.backend - new = Backend.EULERINTELLI if current == Backend.OPENAI else Backend.OPENAI - self.backend = new + # 切换后端类型 + self.backend = ( + Backend.EULERINTELLI if self.backend == Backend.OPENAI else Backend.OPENAI + ) - # 更新按钮文本 + # 更新后端按钮文本 backend_btn = self.query_one("#backend-btn", Button) - backend_btn.label = new.get_display_name() + backend_btn.label = self.backend.get_display_name() # 更新 URL 和 API Key - base_url = self.query_one("#base-url", Input) - api_key = self.query_one("#api-key", Input) - - if new == Backend.OPENAI: - base_url.value = self.config_manager.get_base_url() - api_key.value = self.config_manager.get_api_key() - - # 创建新的 OpenAI 客户端 - self._update_llm_client() - - # 移除 MCP 工具授权部分 - mcp_section = self.query("#mcp-section") - if mcp_section: - mcp_section[0].remove() - - # 添加模型选择部分 - if not self.query("#model-section"): - container = self.query_one("#settings-container") - spacer = self.query_one("#spacer") - model_section = Horizontal( - Label(_("模型:"), classes="settings-label"), - Input( - value=self.selected_model, - classes="settings-input", - id="model-input", - placeholder=_("模型名称,可选"), - ), - id="model-section", - classes="settings-option", - ) - - # 在 spacer 前面添加 model_section - if spacer: - container.mount(model_section, before=spacer) - else: - container.mount(model_section) - else: - base_url.value = self.config_manager.get_eulerintelli_url() - api_key.value = self.config_manager.get_eulerintelli_key() - - # 创建新的 Hermes 客户端 - self._update_llm_client() - - # 移除模型选择部分 - model_section = self.query("#model-section") - if model_section: - model_section[0].remove() - - # 添加 MCP 工具授权部分 - if not self.query("#mcp-section"): - container = self.query_one("#settings-container") - spacer = self.query_one("#spacer") - mcp_section = Horizontal( - Label(_("MCP 工具授权:"), classes="settings-label"), - Button( - _("自动执行") if self.auto_execute_status else _("手动确认"), - id="mcp-btn", - classes="settings-button", - disabled=not self.mcp_status_loaded, - ), - id="mcp-section", - classes="settings-option", - ) + self._load_config_inputs() - # 在spacer前面添加mcp_section - if spacer: - container.mount(mcp_section, before=spacer) - else: - container.mount(mcp_section) + # 更新 LLM 客户端 + self._update_llm_client() - # 重新加载 MCP 状态 - task = asyncio.create_task(self.load_mcp_status()) - # 保存任务引用 - self.background_tasks.add(task) - task.add_done_callback(self.background_tasks.discard) + # 替换后端特定的 UI 组件 + self._replace_backend_widgets() # 确保按钮可见 self._ensure_buttons_visible() @@ -309,6 +261,12 @@ class SettingsScreen(ModalScreen): self.background_tasks.add(task) task.add_done_callback(self.background_tasks.discard) + @on(Button.Pressed, "#llm-config-btn") + def open_llm_config(self) -> None: + """打开 LLM 配置对话框""" + dialog = LLMConfigDialog(self.config_manager, self.llm_client) + self.app.push_screen(dialog) + @on(Button.Pressed, "#save-btn") def save_settings(self) -> None: """保存设置""" @@ -426,6 +384,42 @@ class SettingsScreen(ModalScreen): self.background_tasks.add(task) task.add_done_callback(self.background_tasks.discard) + def _load_config_inputs(self) -> None: + """根据当前后端载入配置输入框的值""" + base_url = self.query_one("#base-url", Input) + api_key = self.query_one("#api-key", Input) + + if self.backend == Backend.OPENAI: + base_url.value = self.config_manager.get_base_url() + api_key.value = self.config_manager.get_api_key() + else: # EULERINTELLI + base_url.value = self.config_manager.get_eulerintelli_url() + api_key.value = self.config_manager.get_eulerintelli_key() + + def _replace_backend_widgets(self) -> None: + """替换后端特定的 UI 组件""" + container = self.query_one("#settings-container") + spacer = self.query_one("#spacer") + + # 移除所有后端特定的组件 + for section_id in ["#model-section", "#mcp-section", "#llm-config-section"]: + sections = self.query(section_id) + for section in sections: + section.remove() + + # 添加新的后端特定组件 + for widget in self._create_backend_widgets(): + if spacer: + container.mount(widget, before=spacer) + else: + container.mount(widget) + + # 如果是 EULERINTELLI 后端,需要重新加载 MCP 状态 + if self.backend == Backend.EULERINTELLI: + task = asyncio.create_task(self.load_mcp_status()) + self.background_tasks.add(task) + task.add_done_callback(self.background_tasks.discard) + async def _validate_configuration(self) -> None: """验证当前配置""" try: diff --git a/src/backend/__init__.py b/src/backend/__init__.py index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..89907ccd57da74b60b3bfc170e0361b5335005f1 100644 --- a/src/backend/__init__.py +++ b/src/backend/__init__.py @@ -0,0 +1,7 @@ +"""后端模块""" + +from .base import LLMClientBase +from .factory import BackendFactory +from .models import LLMType, ModelInfo + +__all__ = ["BackendFactory", "LLMClientBase", "LLMType", "ModelInfo"] diff --git a/src/backend/base.py b/src/backend/base.py index 65dab5c1a489431bc32c94a22e2aac29001075bf..395f13fc54f84a65f59462c50c3fed73f0039e10 100644 --- a/src/backend/base.py +++ b/src/backend/base.py @@ -9,6 +9,8 @@ if TYPE_CHECKING: from collections.abc import AsyncGenerator from types import TracebackType + from .models import ModelInfo + class LLMClientBase(ABC): """LLM 客户端基类""" @@ -35,12 +37,12 @@ class LLMClientBase(ABC): """ @abstractmethod - async def get_available_models(self) -> list[str]: + async def get_available_models(self) -> list[ModelInfo]: """ - 获取当前 LLM 服务中可用的模型,返回名称列表 + 获取当前 LLM 服务中可用的模型,返回模型信息列表 Returns: - list[str]: 可用的模型名称列表 + list[ModelInfo]: 可用的模型信息列表 """ diff --git a/src/backend/hermes/client.py b/src/backend/hermes/client.py index c13ab83ba899086f26cfea911695ee73bcea32af..8362096315c516e486a5427a8e1ab268ad0b3993 100644 --- a/src/backend/hermes/client.py +++ b/src/backend/hermes/client.py @@ -30,6 +30,7 @@ if TYPE_CHECKING: from types import TracebackType from backend.mcp_handler import MCPEventHandler + from backend.models import ModelInfo from .models import HermesAgent @@ -172,11 +173,11 @@ class HermesChatClient(LLMClientBase): log_exception(self.logger, "Hermes 流式聊天请求失败", e) raise - async def get_available_models(self) -> list[str]: + async def get_available_models(self) -> list[ModelInfo]: """ - 获取当前 LLM 服务中可用的模型,返回名称列表 + 获取当前 LLM 服务中可用的模型,返回模型信息列表 - 通过调用 /api/llm 接口获取可用的大模型列表。 + 通过调用 /api/llm/provider 接口获取可用的大模型列表。 如果调用失败或没有返回,使用空列表,后端接口会自动使用默认模型。 """ return await self.model_manager.get_available_models() diff --git a/src/backend/hermes/services/model.py b/src/backend/hermes/services/model.py index ba1b7afdc1e2f30d52e1d320b4fdd425d4152e68..21333608dcb7fe0c4f268d2d525f19a79d601414 100644 --- a/src/backend/hermes/services/model.py +++ b/src/backend/hermes/services/model.py @@ -10,6 +10,7 @@ from urllib.parse import urljoin import httpx from backend.hermes.constants import HTTP_OK +from backend.models import ModelInfo from log.manager import get_logger, log_api_request, log_exception if TYPE_CHECKING: @@ -24,19 +25,26 @@ class HermesModelManager: self.logger = get_logger(__name__) self.http_manager = http_manager - async def get_available_models(self) -> list[str]: + async def get_available_models(self) -> list[ModelInfo]: """ - 获取当前 LLM 服务中可用的模型,返回名称列表 + 获取当前 LLM 服务中可用的模型,返回模型信息列表 - 通过调用 /api/llm 接口获取可用的大模型列表。 + 通过调用 /api/llm/provider 接口获取可用的大模型列表。 如果调用失败或没有返回,使用空列表,后端接口会自动使用默认模型。 + + 返回的 ModelInfo 包含以下字段: + - model_name: 模型名称 + - llm_id: LLM ID + - llm_description: LLM 描述 + - llm_type: LLM 类型列表 + - max_tokens: 最大 token 数 """ start_time = time.time() self.logger.info("开始请求 Hermes 模型列表 API") try: client = await self.http_manager.get_client() - llm_url = urljoin(self.http_manager.base_url, "/api/llm") + llm_url = urljoin(self.http_manager.base_url, "/api/llm/provider") headers = self.http_manager.build_headers() response = await client.get(llm_url, headers=headers) @@ -84,14 +92,29 @@ class HermesModelManager: 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) + if not isinstance(llm_info, dict): + continue + + # modelName 是前端显示所必需的字段 + model_name = llm_info.get("modelName") + if not model_name: + continue + + # 解析并验证 llmType 字段 + llm_types = ModelInfo.parse_llm_types(llm_info.get("llmType")) + + # 构建 ModelInfo 对象 + model_info = ModelInfo( + model_name=model_name, + llm_id=llm_info.get("llmId"), + llm_description=llm_info.get("llmDescription"), + llm_type=llm_types, + max_tokens=llm_info.get("maxTokens"), + ) + models.append(model_info) # 记录成功的API请求 log_api_request( diff --git a/src/backend/models.py b/src/backend/models.py new file mode 100644 index 0000000000000000000000000000000000000000..8799269e754daa7230892eed9142b4801423decd --- /dev/null +++ b/src/backend/models.py @@ -0,0 +1,82 @@ +"""后端模型数据结构定义""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum + + +class LLMType(str, Enum): + """ + LLM 类型枚举 + + 定义了 Hermes 后端支持的 LLM 能力类型。 + """ + + CHAT = "chat" + """模型支持 Chat(聊天对话)""" + + FUNCTION = "function" + """模型支持 Function Call(函数调用)""" + + EMBEDDING = "embedding" + """模型支持 Embedding(向量嵌入)""" + + VISION = "vision" + """模型支持图片理解(视觉能力)""" + + THINKING = "thinking" + """模型支持思考推理(推理能力)""" + + +@dataclass +class ModelInfo: + """ + 模型信息数据类 + + 该类用于统一表示不同后端(OpenAI、Hermes)返回的模型信息。 + 所有后端都需要提供 modelName 字段,其他字段为可选。 + """ + + # 通用字段(所有后端都支持) + model_name: str + """模型名称,用于标识和选择模型""" + + # Hermes 特有字段 + llm_id: str | None = None + """LLM ID,Hermes 后端的模型唯一标识""" + + llm_description: str | None = None + """LLM 描述,Hermes 后端的模型说明""" + + llm_type: list[LLMType] = field(default_factory=list) + """LLM 类型列表,如 [LLMType.CHAT, LLMType.FUNCTION],Hermes 后端特有""" + + max_tokens: int | None = None + """模型支持的最大 token 数,Hermes 后端提供""" + + def __str__(self) -> str: + """返回模型的字符串表示""" + return self.model_name + + def __repr__(self) -> str: + """返回模型的详细表示""" + return f"ModelInfo(model_name={self.model_name!r}, llm_id={self.llm_id!r})" + + @staticmethod + def parse_llm_types(llm_types: list[str] | None) -> list[LLMType]: + """ + 解析 LLM 类型字符串列表,过滤掉不合法的值 + + Args: + llm_types: LLM 类型字符串列表 + + Returns: + list[LLMType]: 合法的 LLM 类型枚举列表 + + """ + if not llm_types: + return [] + + valid_values = {t.value for t in LLMType} + return [LLMType(llm_type_str) for llm_type_str in llm_types if llm_type_str in valid_values] diff --git a/src/backend/openai.py b/src/backend/openai.py index ba9a8871403e6c1e206d915616c7283a93ee3a10..486aa08dd477991948c2368622d3da1d72af7241 100644 --- a/src/backend/openai.py +++ b/src/backend/openai.py @@ -10,6 +10,7 @@ import httpx from openai import AsyncOpenAI, OpenAIError from backend.base import LLMClientBase +from backend.models import ModelInfo from log.manager import get_logger, log_api_request, log_exception if TYPE_CHECKING: @@ -176,19 +177,23 @@ class OpenAIClient(LLMClientBase): self._conversation_history.clear() self.logger.info("OpenAI 客户端对话历史记录已重置") - async def get_available_models(self) -> list[str]: + async def get_available_models(self) -> list[ModelInfo]: """ - 获取当前 LLM 服务中可用的模型,返回名称列表 + 获取当前 LLM 服务中可用的模型,返回模型信息列表 调用 LLM 服务的模型列表接口,并解析返回结果提取模型名称。 如果服务不支持模型列表接口,返回空列表。 + + 对于 OpenAI 后端,只返回基础的 model_name 字段,其他 Hermes 特有字段为 None。 """ start_time = time.time() self.logger.info("开始请求 OpenAI 模型列表 API") try: models_response = await self.client.models.list() - models = [model.id async for model in models_response] + model_names = [model.id async for model in models_response] + # 将模型名称转换为 ModelInfo 对象 + models = [ModelInfo(model_name=name) for name in model_names] # 记录成功的API请求 duration = time.time() - start_time log_api_request( diff --git a/src/config/manager.py b/src/config/manager.py index ba45454038c50b2fcfd091f4964fce348700e0d2..b9305f69f98baeda0736330ed72823de22a75785 100644 --- a/src/config/manager.py +++ b/src/config/manager.py @@ -204,6 +204,24 @@ class ConfigManager: self.data.eulerintelli.default_app = app_id self._save_settings() + def get_llm_chat_model(self) -> str: + """获取基础模型的 llmId""" + return self.data.eulerintelli.llm.chat + + def set_llm_chat_model(self, llm_id: str) -> None: + """更新基础模型的 llmId 并保存""" + self.data.eulerintelli.llm.chat = llm_id + self._save_settings() + + def get_llm_function_model(self) -> str: + """获取工具调用模型的 llmId""" + return self.data.eulerintelli.llm.function + + def set_llm_function_model(self, llm_id: str) -> None: + """更新工具调用模型的 llmId 并保存""" + self.data.eulerintelli.llm.function = llm_id + self._save_settings() + def get_locale(self) -> str: """获取当前语言环境""" return self.data.locale diff --git a/src/config/model.py b/src/config/model.py index ab986c4ea5db06ac56ca773a05551be75bd3fcff..5f95bb6722c4c296f5068d36f573d0990a9308d7 100644 --- a/src/config/model.py +++ b/src/config/model.py @@ -50,6 +50,29 @@ class OpenAIConfig: return {"base_url": self.base_url, "model": self.model, "api_key": self.api_key} +@dataclass +class LLMConfig: + """LLM 模型配置""" + + chat: str = field(default="") # 基础模型的 llmId + function: str = field(default="") # 工具调用模型的 llmId + + @classmethod + def from_dict(cls, d: dict) -> "LLMConfig": + """从字典初始化配置""" + return cls( + chat=d.get("chat", ""), + function=d.get("function", ""), + ) + + def to_dict(self) -> dict: + """转换为字典""" + return { + "chat": self.chat, + "function": self.function, + } + + @dataclass class HermesConfig: """Hermes 后端配置""" @@ -57,6 +80,7 @@ class HermesConfig: base_url: str = field(default="http://127.0.0.1:8002") api_key: str = field(default="") default_app: str = field(default="") + llm: LLMConfig = field(default_factory=LLMConfig) @classmethod def from_dict(cls, d: dict) -> "HermesConfig": @@ -65,6 +89,7 @@ class HermesConfig: base_url=d.get("base_url", cls.base_url), api_key=d.get("api_key", cls.api_key), default_app=d.get("default_app", cls.default_app), + llm=LLMConfig.from_dict(d.get("llm", {})), ) def to_dict(self) -> dict: @@ -73,6 +98,7 @@ class HermesConfig: "base_url": self.base_url, "api_key": self.api_key, "default_app": self.default_app, + "llm": self.llm.to_dict(), } diff --git a/src/i18n/locales/messages.pot b/src/i18n/locales/messages.pot index c1109567f50c75bedae803427b824d588277939c..20316321e652af6ade1cb748b868f5c54c18f676 100644 --- a/src/i18n/locales/messages.pot +++ b/src/i18n/locales/messages.pot @@ -6,7 +6,7 @@ #, fuzzy msgid "" msgstr "" -"Project-Id-Version: oi-cli 0.10.2\n" +"Project-Id-Version: oi-cli 2.0.0\n" "Report-Msgid-Bugs-To: contact@openeuler.org\n" "POT-Creation-Date: 2025-10-21 10:54+0800\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" diff --git a/tests/backend/test_model_info.py b/tests/backend/test_model_info.py new file mode 100644 index 0000000000000000000000000000000000000000..71c493dac595cf18cb5bbe7c7857047f8079113b --- /dev/null +++ b/tests/backend/test_model_info.py @@ -0,0 +1,97 @@ +""" +测试 ModelInfo 数据类 + +运行方法: + +```shell +source .venv/bin/activate && PYTHONPATH=src python tests/backend/test_model_info.py +``` +""" + +from backend.models import LLMType, ModelInfo + +# 测试常量 +GPT4_MAX_TOKENS = 8192 + + +def test_model_info_creation() -> None: + """测试创建 ModelInfo 对象""" + # OpenAI 风格(只有 model_name) + openai_model = ModelInfo(model_name="gpt-4") + assert openai_model.model_name == "gpt-4" + assert openai_model.llm_id is None + assert openai_model.llm_description is None + assert openai_model.llm_type == [] + assert openai_model.max_tokens is None + + +def test_model_info_hermes_full() -> None: + """测试创建完整的 Hermes ModelInfo 对象""" + hermes_model = ModelInfo( + model_name="gpt-4", + llm_id="gpt-4", + llm_description="OpenAI GPT-4 model", + llm_type=[LLMType.CHAT, LLMType.FUNCTION], + max_tokens=GPT4_MAX_TOKENS, + ) + assert hermes_model.model_name == "gpt-4" + assert hermes_model.llm_id == "gpt-4" + assert hermes_model.llm_description == "OpenAI GPT-4 model" + assert hermes_model.llm_type == [LLMType.CHAT, LLMType.FUNCTION] + assert hermes_model.max_tokens == GPT4_MAX_TOKENS + + +def test_model_info_string_representation() -> None: + """测试 ModelInfo 的字符串表示""" + model = ModelInfo( + model_name="gpt-3.5-turbo", + llm_id="gpt-3.5-turbo", + ) + assert str(model) == "gpt-3.5-turbo" + assert "gpt-3.5-turbo" in repr(model) + + +def test_parse_llm_types_valid() -> None: + """测试解析合法的 LLM 类型""" + # 测试所有合法类型 + valid_types = ["chat", "function", "embedding", "vision", "thinking"] + parsed = ModelInfo.parse_llm_types(valid_types) + expected = [ + LLMType.CHAT, + LLMType.FUNCTION, + LLMType.EMBEDDING, + LLMType.VISION, + LLMType.THINKING, + ] + assert len(parsed) == len(expected) + assert parsed == expected + + +def test_parse_llm_types_invalid() -> None: + """测试过滤不合法的 LLM 类型""" + # 包含合法和不合法的类型 + mixed_types = ["chat", "invalid_type", "function", "unknown", "vision"] + parsed = ModelInfo.parse_llm_types(mixed_types) + # 只保留合法的类型 + expected = [LLMType.CHAT, LLMType.FUNCTION, LLMType.VISION] + assert len(parsed) == len(expected) + assert parsed == expected + + +def test_parse_llm_types_empty() -> None: + """测试空列表和 None""" + assert ModelInfo.parse_llm_types([]) == [] + assert ModelInfo.parse_llm_types(None) == [] + + +if __name__ == "__main__": + test_model_info_creation() + test_model_info_hermes_full() + test_model_info_string_representation() + test_parse_llm_types_valid() + test_parse_llm_types_invalid() + test_parse_llm_types_empty() + # 测试完成 + import sys + + sys.stdout.write("所有测试通过!✅\n")