Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/111111666yi-cmyk_daniya-desktop-pet-2026.json

1 line
211 KiB
JSON
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "111111666yi-cmyk/daniya-desktop-pet-2026", "files": {"src/settings_window.py": {"5c229bd8577b0663a65d350451508996a63502d7": {"t": 1785674650.4320176, "n": 183104, "h": "d450bd641119d72c", "c": "from __future__ import annotations\n\nfrom pathlib import Path\nfrom typing import TYPE_CHECKING, Any, Callable\n\nfrom PySide6.QtCore import QThread, QTimer, Qt, Signal, QUrl\nfrom PySide6.QtGui import QDesktopServices, QPixmap\nfrom PySide6.QtWidgets import (\n QApplication,\n QButtonGroup,\n QCheckBox,\n QComboBox,\n QDialog,\n QDoubleSpinBox,\n QFileDialog,\n QFormLayout,\n QGroupBox,\n QHBoxLayout,\n QLabel,\n QLineEdit,\n QMessageBox,\n QPushButton,\n QScrollArea,\n QSpinBox,\n QTabWidget,\n QTextEdit,\n QVBoxLayout,\n QWidget,\n)\n\nfrom .character_pack_editor import EDITABLE_FILES, CharacterPackEditor\nfrom .diagnostics_panel import format_diagnostics, run_diagnostics\nfrom .icon_utils import icon as get_icon\nfrom .relationship_state_viewer import RelationshipStateViewer\nfrom .settings_manager import SettingsManager\nfrom .ui.liquid_glass import LiquidGlassDialog\nfrom .utils import resource_path\nfrom .llm.provider_registry import Provider, ProviderMeta\n\nif TYPE_CHECKING:\n from .app import AppController\n\n\nTAB_REGISTRY: list[dict[str, str]] = [\n {\"id\": \"model\", \"title\": \"模型与引擎\", \"icon\": \"chip\", \"mode\": \"both\", \"category\": \"basic\", \"risk_level\": \"normal\", \"builder\": \"_build_model_tab\"},\n {\"id\": \"pet\", \"title\": \"桌宠\", \"icon\": \"internet\", \"mode\": \"both\", \"category\": \"basic\", \"risk_level\": \"normal\", \"builder\": \"_build_pet_tab\"},\n {\"id\": \"character_resources\", \"title\": \"角色与资源\", \"icon\": \"document\", \"mode\": \"both\", \"category\": \"character\", \"risk_level\": \"normal\", \"builder\": \"_build_character_resources_tab\"},\n {\"id\": \"relationship_events\", \"title\": \"关系与事件\", \"icon\": \"download\", \"mode\": \"advanced\", \"category\": \"character\", \"risk_level\": \"advanced\", \"builder\": \"_build_relationship_events_tab\"},\n {\"id\": \"memory_diary\", \"title\": \"记忆与日记\", \"icon\": \"document\", \"mode\": \"both\", \"category\": \"basic\", \"risk_level\": \"normal\", \"builder\": \"_build_memory_diary_tab\"},\n {\"id\": \"growth\", \"title\": \"养成\", \"icon\": \"chip\", \"mode\": \"both\", \"category\": \"basic\", \"risk_level\": \"normal\", \"builder\": \"_build_growth_tab\"},\n {\"id\": \"environment\", \"title\": \"环境与内容\", \"icon\": \"settings\", \"mode\": \"both\", \"category\": \"basic\", \"risk_level\": \"normal\", \"builder\": \"_build_environment_tab\"},\n {\"id\": \"system\", \"title\": \"系统\", \"icon\": \"settings\", \"mode\": \"advanced\", \"category\": \"tools\", \"risk_level\": \"advanced\", \"builder\": \"_build_system_tab\"},\n {\"id\": \"reminder\", \"title\": \"提醒\", \"icon\": \"remind\", \"mode\": \"both\", \"category\": \"basic\", \"risk_level\": \"normal\", \"builder\": \"_build_reminder_tab\"},\n {\"id\": \"file_organizer\", \"title\": \"文件整理\", \"icon\": \"document\", \"mode\": \"advanced\", \"category\": \"tools\", \"risk_level\": \"dangerous\", \"builder\": \"_build_file_organizer_tab\"},\n {\"id\": \"system_status\", \"title\": \"系统状态\", \"icon\": \"laptop\", \"mode\": \"advanced\", \"category\": \"tools\", \"risk_level\": \"advanced\", \"builder\": \"_build_system_status_tab\"},\n {\"id\": \"clipboard\", \"title\": \"剪贴板\", \"icon\": \"document\", \"mode\": \"advanced\", \"category\": \"tools\", \"risk_level\": \"dangerous\", \"builder\": \"_build_clipboard_tab\"},\n {\"id\": \"focus\", \"title\": \"专注模式\", \"icon\": \"chip\", \"mode\": \"advanced\", \"category\": \"tools\", \"risk_level\": \"advanced\", \"builder\": \"_build_focus_tab\"},\n {\"id\": \"privacy\", \"title\": \"隐私与安全\", \"icon\": \"info\", \"mode\": \"both\", \"category\": \"privacy\", \"risk_level\": \"normal\", \"builder\": \"_build_privacy_tab\"},\n {\"id\": \"diagnostics\", \"title\": \"诊断\", \"icon\": \"settings\", \"mode\": \"both\", \"category\": \"diagnostics\", \"risk_level\": \"normal\", \"builder\": \"_build_diagnostics_tab\"},\n]\n\nSIMPLE_SETTINGS_TABS = {\n \"模型与引擎\",\n \"桌宠\",\n \"角色与资源\",\n \"关系与事件\",\n \"记忆与日记\",\n \"养成\",\n \"环境与内容\",\n \"提醒\",\n \"隐私与安全\",\n}\n\n\ndef normalize_settings_mode(value: Any) -> str:\n return \"advanced\" if str(value or \"\").strip().lower() == \"advanced\" else \"simple\"\n\n\nclass _ApiTestWorker(QThread):\n finished_with_result = Signal(bool, str)\n\n def __init__(\n self,\n settings_manager: SettingsManager,\n profile: dict[str, Any] | None = None,\n api_key_override: str | None = None,\n ) -> None:\n super().__init__()\n self.settings_manager = settings_manager\n self.profile = profile\n self.api_key_override = api_key_override\n\n def run(self) -> None:\n if self.profile is None:\n ok, message = self.settings_manager.test_api_connection()\n else:\n from .chat_client import mask_key\n from .llm.provider_manager import ProviderManager\n\n pm = ProviderManager(api_config=self.settings_manager.load_api_config())\n pm.model_profiles_path = self.settings_manager.model_profiles_path\n pm.env_path = self.settings_manager.env_path\n ok, message = pm.test_profile_model(\n self.profile,\n api_key_override=self.api_key_override,\n )\n provider = str(self.profile.get(\"provider\", \"\"))\n env_key_name = str(self.profile.get(\"api_key_env\", \"\"))\n raw_key = self.api_key_override or self.settings_manager.current_api_key(env_key_name)\n if provider not in (Provider.OLLAMA,) and raw_key:\n message += f\" (key={mask_key(raw_key)})\"\n self.finished_with_result.emit(ok, message)\n\n\nclass _DiagnosticsWorker(QThread):\n finished_with_text = Signal(str)\n\n def __init__(self, settings_manager: SettingsManager, controller: \"AppController\") -> None:\n super().__init__()\n self.settings_manager = settings_manager\n self.controller = controller\n\n def run(self) -> None:\n chat_client = getattr(self.controller, \"chat_client\", None)\n results = run_diagnostics(self.settings_manager, getattr(self.controller, \"asset_manager\", None), chat_client=chat_client)\n text = format_diagnostics(results)\n startup_timer = getattr(self.controller, \"startup_timer\", None)\n if startup_timer is not None:\n text += \"\\n\\n\" + startup_timer.format_summary()\n self.finished_with_text.emit(text)\n\n\nclass _DiaryWorker(QThread):\n finished_with_result = Signal(object)\n\n def __init__(self, controller: \"AppController\", days: int) -> None:\n super().__init__()\n self.controller = controller\n self.days = days\n\n def run(self) -> None:\n try:\n result = self.controller.observation_diary.generate(days=self.days)\n except Exception as exc:\n result = {\n \"ok\": False,\n \"message\": f\"观察日记生成失败:{exc.__class__.__name__}\",\n \"source\": \"error\",\n \"event_count\": 0,\n }\n self.finished_with_result.emit(result)\n\n\nclass _OllamaHealthWorker(QThread):\n finished_with_result = Signal(bool, str)\n\n def __init__(self, base_url: str = \"http://localhost:11434\") -> None:\n super().__init__()\n self.base_url = base_url\n\n def run(self) -> None:\n import requests\n try:\n resp = requests.get(self.base_url, timeout=5)\n if resp.status_code == 200 and \"Ollama\" in resp.text:\n self.finished_with_result.emit(True, \"Ollama 服务已运行\")\n else:\n self.finished_with_result.emit(False, \"Ollama 服务未响应,请先启动 Ollama。\")\n except Exception as e:\n self.finished_with_result.emit(False, f\"无法连接 Ollama: {e}\")\n\n\nclass _OllamaPullWorker(QThread):\n progress_update = Signal(str)\n finished_with_result = Signal(bool, str)\n\n def __init__(self, model_name: str, base_url: str = \"http://localhost:11434\") -> None:\n super().__init__()\n self.model_name = model_name\n self.base_url = base_url\n self._cancel = False\n\n def cancel(self) -> None:\n self._cancel = True\n self.progress_update.emit(\"正在取消...\")\n\n def run(self) -> None:\n import subprocess\n import shutil\n ollama = shutil.which(\"ollama\")\n if not ollama:\n self.finished_with_result.emit(False, \"未找到 ollama 命令,请先安装 Ollama。\")\n return\n try:\n self.progress_update.emit(f\"正在拉取 {self.model_name} ...\")\n proc = subprocess.Popen(\n [ollama, \"pull\", self.model_name],\n stdout=subprocess.PIPE, stderr=subprocess.STDOUT,\n text=True, bufsize=1,\n )\n for line in proc.stdout:\n if self._cancel:\n proc.terminate()\n self.finished_with_result.emit(False, \"拉取已取消。\")\n return\n line = line.strip()\n if line:\n self.progress_update.emit(line)\n proc.wait()\n if self._cancel:\n self.finished_with_result.emit(False, \"拉取已取消。\")\n elif proc.returncode == 0:\n self.finished_with_result.emit(True, f\"模型 {self.model_name} 拉取完成。\")\n else:\n self.finished_with_result.emit(False, f\"拉取失败 (exit code {proc.returncode})\")\n except Exception as exc:\n self.finished_with_result.emit(False, f\"拉取出错: {exc}\")\n\n\n_RELATIONSHIP_LABELS = {\n \"character_id\": \"角色\",\n \"relationship_stage\": \"关系阶段\",\n \"week\": \"当前周目\",\n \"weekly_action_points\": \"本周行动点\",\n \"max_weekly_action_points\": \"行动点上限\",\n \"affection\": \"好感\",\n \"familiarity\": \"熟悉\",\n \"trust\": \"信任\",\n \"dependency\": \"依赖\",\n \"heartbeat\": \"心跳\",\n \"jealousy\": \"吃醋\",\n \"boundary\": \"边界感\",\n \"empathy_load\": \"共情负荷\",\n \"softness_leak\": \"软化泄露\",\n \"defense_level\": \"防御强度\",\n \"stay_tendency\": \"留下倾向\",\n \"last_update_reason\": \"最近变化\",\n}\n\n_RELATIONSHIP_ORDER = [\n \"character_id\",\n \"relationship_stage\",\n \"week\",\n \"weekly_action_points\",\n \"max_weekly_action_points\",\n \"affection\",\n \"familiarity\",\n \"trust\",\n \"dependency\",\n \"heartbeat\",\n \"jealousy\",\n \"boundary\",\n \"empathy_load\",\n \"softness_leak\",\n \"defense_level\",\n \"stay_tendency\",\n \"last_update_reason\",\n]\n\n_EVENT_LABELS = {\n \"user_drag\": \"拖拽互动\",\n \"user_click\": \"点击互动\",\n \"idle_chat\": \"空闲对话\",\n \"user_message\": \"主动对话\",\n \"birthday\": \"生日事件\",\n \"reminder\": \"提醒事件\",\n \"local\": \"本地记录\",\n}\n\n_DATA_FILE_LABELS = {\n \"relationship_state\": \"关系状态\",\n \"event_log\": \"事件日志\",\n \"user_memory\": \"用户记忆\",\n}\n\n_PACK_FILE_DESCRIPTIONS = {\n \"character.yaml\": \"角色身份、核心人格、能力、禁用行为。\",\n \"speech.yaml\": \"说话方式、特殊回应、语言过滤规则。\",\n \"relationship.yaml\": \"关系阶段、数值字段、升级条件。\",\n \"events.yaml\": \"点击、拖拽、回归、生日等事件规则。\",\n \"lore.md\": \"完整剧情与人格背景正文,只按需检索。\",\n \"lore_index.yaml\": \"剧情标签、触发词、剧透等级和检索规则。\",\n \"actions.yaml\": \"动作状态到素材的映射。\",\n \"prompt_pack.md\": \"说话方式参考和角色片段。\",\n \"story.yaml\": \"剧情阅读章节,属于角色包内容。\",\n}\n\n\ndef _api_key_placeholder(masked_key: str | None) -> str:\n if masked_key and masked_key != \"<empty>\":\n return \"输入新密钥;留空则继续使用已保存的密钥\"\n return \"输入密钥;保存后会写入本地\"\n\n\ndef _saved_api_key_status(masked_key: str | None) -> str:\n if masked_key and masked_key != \"<empty>\":\n return f\"已保存密钥:{masked_key}(仅显示脱敏值)\"\n return \"未保存密钥;输入后点击「保存」\"\n\n\ndef _format_relationship_summary(state: dict[str, Any]) -> str:\n if not state:\n return \"关系状态不可读或为空。\"\n lines = []\n for key in _RELATIONSHIP_ORDER:\n if key in state:\n lines.append(f\"{_RELATIONSHIP_LABELS[key]}:{state[key]}\")\n hidden = [key for key in state if key not in _RELATIONSHIP_ORDER]\n if hidden:\n lines.append(f\"其他内部字段:{len(hidden)} 项,可导出关系状态查看原始 JSON。\")\n return \"\\n\".join(lines)\n\n\ndef _format_relationship_effect(effect: Any) -> str:\n if not isinstance(effect, dict) or not effect:\n return \"关系无明显变化\"\n parts = []\n for key, value in effect.items():\n label = _RELATIONSHIP_LABELS.get(key, key)\n if isinstance(value, (int, float)):\n parts.append(f\"{label} {value:+g}\")\n else:\n parts.append(f\"{label}: {value}\")\n return \",\".join(parts)\n\n\ndef _format_event_log_summary(events: list[Any]) -> str:\n valid_events = [event for event in events if isinstance(event, dict)]\n if not valid_events:\n return \"暂无事件记录。\"\n lines = []\n for event in valid_events[-20:]:\n event_id = event.get(\"event_id\")\n source = event.get(\"source\")\n if not event_id or event_id == \"None\":\n event_name = _EVENT_LABELS.get(str(source), \"普通记录\")\n else:\n event_name = _EVENT_LABELS.get(str(event_id), str(event_id))\n timestamp = str(event.get(\"timestamp\") or \"未知时间\")\n effect = _format_relationship_effect(event.get(\"relationship_effect\"))\n stage_before = event.get(\"stage_before\")\n stage_after = event.get(\"stage_after\")\n stage_text = \"\"\n if stage_before and stage_after and stage_before != stage_after:\n stage_text = f\";阶段 {stage_before} → {stage_after}\"\n lore_used = event.get(\"lore_fragments_used\")\n lore_text = \"\"\n if isinstance(lore_used, list) and lore_used:\n lore_text = f\";调用剧情片段 {len(lore_used)} 个\"\n lines.append(f\"{timestamp}|{event_name}|{effect}{stage_text}{lore_text}\")\n return \"\\n\".join(lines)\n\n\ndef _format_user_memory_summary(profile: dict[str, str], memory: dict[str, Any], notes: list[str]) -> str:\n lines = [\n \"用户档案\",\n f\"- 称呼:{profile.get('user_name', '你')}\",\n f\"- 生日(月日):{profile.get('birthday') or '未填写'}\",\n f\"- 关系:{profile.get('relationship', '陪伴角色与用户')}\",\n f\"- 偏好风格:{profile.get('style', '')}\",\n \"\",\n \"自动记忆\",\n ]\n preferences = memory.get(\"user_preferences\")\n if isinstance(preferences, dict) and preferences:\n for key, value in sorted(preferences.items()):\n lines.append(f\"- 偏好 {key}: {value}\")\n phrases = memory.get(\"important_user_phrases\")\n if isinstance(phrases, list) and phrases:\n lines.append(\"- 重要表达:\" + \"、\".join(str(item) for item in phrases[-10:]))\n unlocked_lore = memory.get(\"unlocked_lore\")\n if isinstance(unlocked_lore, list) and unlocked_lore:\n lines.append(\"- 已解锁剧情:\" + \"、\".join(str(item) for item in unlocked_lore[-10:]))\n last_events = memory.get(\"last_events\")\n if isinstance(last_events, list) and last_events:\n lines.append(\"- 最近事件:\" + \"、\".join(str(item) for item in last_events[-10:]))\n if len(lines) == 6:\n lines.append(\"- 暂无自动记忆。\")\n lines.extend([\"\", \"手动备忘\"])\n if notes:\n lines.extend(f\"- {note}\" for note in notes[-12:])\n else:\n lines.append(\"- 暂无手动备忘。\")\n return \"\\n\".join(lines)\n\n\ndef _format_long_term_memory_summary(records: list[dict[str, Any]]) -> str:\n if not records:\n return \"暂无对话级长期记忆。启用后,新完成的对话会保存在本机。\"\n lines = [f\"本机共保存 {len(records)} 条对话级长期记忆。\", \"\"]\n for record in records[-50:][::-1]:\n timestamp = str(record.get(\"timestamp\", \"\"))[:19] or \"未知时间\"\n user = \" \".join(str(record.get(\"user\", \"\")).split())[:220]\n assistant = \" \".join(str(record.get(\"assistant\", \"\")).split())[:260]\n lines.append(f\"{timestamp}\")\n lines.append(f\"- 你:{user or '(空)'}\")\n lines.append(f\"- 达妮娅:{assistant or '(空)'}\")\n lines.append(\"\")\n return \"\\n\".join(lines).rstrip()\n\n\ndef _format_diary_summary(records: list[dict[str, Any]]) -> str:\n if not records:\n return \"暂无观察日记。只有手动生成且服务商真实返回成功的内容才会保存。\"\n lines: list[str] = []\n for record in records[-20:][::-1]:\n timestamp = str(record.get(\"timestamp\", \"\"))[:19] or \"未知时间\"\n event_count = record.get(\"event_count\", 0)\n source = str(record.get(\"source\", \"unknown\"))\n text = str(record.get(\"text\", \"\")).strip()\n lines.extend(\n [\n f\"{timestamp}|{event_count} 条互动|来源 {source}\",\n text or \"(空)\",\n \"\",\n ]\n )\n return \"\\n\".join(lines).rstrip()\n\n\ndef _read_recent_text_lines(path: Path, limit: int) -> list[str]:\n try:\n lines = path.read_text(encoding=\"utf-8\").splitlines()\n except OSError:\n return []\n cleaned = [line.strip() for line in lines if line.strip()]\n return cleaned[-limit:]\n\n\ndef _format_data_status_summary(status: dict[str, Any], paths: dict[str, Path]) -> str:\n lines = [f\"数据目录:{'可用' if status.get('exists') else '尚未创建'}\"]\n for key, path in paths.items():\n exists = path.exists()\n readable = status.get(key + \"_readable\")\n error = status.get(key + \"_error\")\n label = _DATA_FILE_LABELS.get(key, key)\n if error:\n state = f\"异常({error})\"\n elif exists and readable:\n state = \"可读\"\n elif exists:\n state = \"存在但不可读\"\n else:\n state = \"暂无记录,运行后会自动生成\"\n lines.append(f\"{label}:{state}\")\n return \"\\n\".join(lines)\n\n\ndef _format_pack_file_summary(name: str, text: str, editable: bool) -> str:\n description = _PACK_FILE_DESCRIPTIONS.get(name, \"角色包文件。\")\n permission = \"可编辑\" if editable else \"只读\"\n return f\"{description}({permission})\"\n\n\nclass SettingsWindow(LiquidGlassDialog):\n def __init__(self, controller: \"AppController\", parent: QWidget | None = None) -> None:\n super().__init__(parent, title=\"设置中心\")\n self.controller = controller\n self.settings_manager = SettingsManager(controller.config_manager)\n char_id = \"daniya\"\n if hasattr(controller, \"daniya_adapter\") and hasattr(controller.daniya_adapter, \"character_pack\"):\n pack = controller.daniya_adapter.character_pack\n if hasattr(pack, \"character_id\") and isinstance(pack.character_id, str):\n char_id = pack.character_id\n self.character_editor = CharacterPackEditor(character_id=char_id)\n self.relationship_viewer = RelationshipStateViewer(character_id=char_id)\n self.api_worker: _ApiTestWorker | None = None\n self.diagnostics_worker: _DiagnosticsWorker | None = None\n self.diary_worker: _DiaryWorker | None = None\n self.ollama_worker: _OllamaPullWorker | None = None\n self.ollama_health_worker: _OllamaHealthWorker | None = None\n self._lazy_tab_loaders: dict[str, Callable[[], None]] = {}\n self._lazy_loaded_tabs: set[str] = set()\n self.resize(860, 640)\n\n layout = QVBoxLayout()\n\n mode_row = QHBoxLayout()\n self._mode_simple_btn = QPushButton(\"简单\")\n self._mode_advanced_btn = QPushButton(\"进阶\")\n for btn in (self._mode_simple_btn, self._mode_advanced_btn):\n btn.setCheckable(True)\n btn.setFixedHeight(28)\n btn.setStyleSheet(\"QPushButton { padding: 2px 16px; } QPushButton:checked { font-weight: bold; background: #0366d6; color: white; border-radius: 4px; }\")\n self._mode_hint = QLabel()\n self._mode_hint.setWordWrap(True)\n self._mode_hint.setStyleSheet(\"color: gray; font-size: 11px; margin-left: 8px;\")\n mode_row.addWidget(self._mode_simple_btn)\n mode_row.addWidget(self._mode_advanced_btn)\n mode_row.addWidget(self._mode_hint, 1)\n layout.addLayout(mode_row)\n\n self.tabs = QTabWidget()\n self.tabs.setTabPosition(QTabWidget.TabPosition.South)\n layout.addWidget(self.tabs)\n\n self._tab_widgets: dict[str, QWidget] = {}\n self._tab_entries: list[dict[str, str]] = list(TAB_REGISTRY)\n for entry in self._tab_entries:\n builder = getattr(self, entry[\"builder\"], None)\n if builder:\n builder()\n\n app_config = self.settings_manager.load_app_config()\n sui = app_config.get(\"settings_ui\", {})\n if not isinstance(sui, dict):\n sui = {}\n # Prefer the top-level mode during startup so legacy configs and\n # stale checked-in settings_ui values cannot force the wrong layout.\n initial_mode = normalize_settings_mode(\n app_config.get(\"settings_mode\", sui.get(\"mode\", \"simple\"))\n )\n self._apply_settings_mode(initial_mode)\n\n self.tabs.currentChanged.connect(self._on_tab_changed)\n self._mode_simple_btn.clicked.connect(lambda: self._switch_settings_mode(\"simple\"))\n self._mode_advanced_btn.clicked.connect(lambda: self._switch_settings_mode(\"advanced\"))\n\n close = QPushButton(\"关闭\")\n close.clicked.connect(self.accept)\n layout.addWidget(close, alignment=Qt.AlignmentFlag.AlignRight)\n self.setLayout(layout)\n\n def _register_tab(self, tab_id: str, widget: QWidget, icon_name: str, title: str) -> None:\n self._tab_widgets[tab_id] = widget\n widget.setProperty(\"_tab_id\", tab_id)\n widget.setProperty(\"_tab_icon\", icon_name)\n widget.setProperty(\"_tab_title\", title)\n\n def _apply_settings_mode(self, mode: str) -> None:\n self._mode_simple_btn.setChecked(mode == \"simple\")\n self._mode_advanced_btn.setChecked(mode == \"advanced\")\n if mode == \"simple\":\n self._mode_hint.setText(\"只显示常用设置。高级功能可在「进阶」中切换。\")\n else:\n self._mode_hint.setText(\"包含服务商详细配置、数据管理、诊断和实验功能,请谨慎修改。\")\n self.tabs.blockSignals(True)\n while self.tabs.count():\n self.tabs.removeTab(0)\n for entry in self._tab_entries:\n tab_id = entry[\"id\"]\n widget = self._tab_widgets.get(tab_id)\n if widget is None:\n continue\n entry_mode = entry.get(\"mode\", \"both\")\n if entry_mode == \"both\" or entry_mode == mode:\n self.tabs.addTab(widget, get_icon(entry[\"icon\"]), entry[\"title\"])\n self.tabs.blockSignals(False)\n\n def _on_tab_changed(self, index: int) -> None:\n if index < 0:\n return\n widget = self.tabs.widget(index)\n if widget is None:\n return\n tab_id = widget.property(\"_tab_id\")\n if tab_id and tab_id not in self._lazy_loaded_tabs:\n self._lazy_loaded_tabs.add(tab_id)\n loader = self._lazy_tab_loaders.get(tab_id)\n if loader:\n QTimer.singleShot(0, loader)\n\n def _switch_settings_mode(self, mode: str) -> None:\n self._apply_settings_mode(mode)\n app_config = self.settings_manager.load_app_config()\n app_config[\"settings_mode\"] = mode\n sui = app_config.get(\"settings_ui\")\n if not isinstance(sui, dict):\n sui = {}\n sui[\"mode\"] = mode\n sui[\"show_advanced\"] = mode == \"advanced\"\n app_config[\"settings_ui\"] = sui\n self.settings_manager.save_app_config(app_config)\n\n def _build_model_tab(self) -> None:\n tab = QWidget()\n main_layout = QVBoxLayout(tab)\n\n scroll = QScrollArea()\n scroll.setWidgetResizable(True)\n scroll_content = QWidget()\n scroll_layout = QVBoxLayout(scroll_content)\n\n # ── 当前生效状态 ──\n self.active_profile_status = QLabel()\n self.active_profile_status.setWordWrap(True)\n self._refresh_active_status()\n scroll_layout.addWidget(self.active_profile_status)\n mm_config = self.settings_manager.load_multimodal_config()\n tts_value = str(mm_config.get(\"tts\", \"none\"))\n self.voice_status_label = QLabel(\n \"语音状态:未启用;主线仅保留配置接口。\"\n if tts_value == \"none\"\n else f\"语音状态:已保存 {tts_value} 配置;本阶段不新增语音执行能力。\"\n )\n self.voice_status_label.setWordWrap(True)\n scroll_layout.addWidget(self.voice_status_label)\n self._build_profile_switcher(scroll_layout)\n\n # 云端 API 配置\n api_group = QGroupBox(\"云端配置\")\n api_group_layout = QVBoxLayout(api_group)\n self._build_api_section(api_group_layout)\n scroll_layout.addWidget(api_group)\n\n # 本地部署与引擎配置\n self.local_model_group = QGroupBox(\"本地模型\")\n local_group_layout = QVBoxLayout(self.local_model_group)\n self._build_local_model_section(local_group_layout)\n scroll_layout.addWidget(self.local_model_group)\n\n # 多模态配置\n self._build_multimodal_section(scroll_layout)\n\n scroll.setWidget(scroll_content)\n main_layout.addWidget(scroll)\n self._register_tab(\"model\", tab, \"chip\", \"模型与引擎\")\n\n def _build_api_section(self, parent_layout: Any) -> None:\n form = QFormLayout()\n self.api = self.settings_manager.load_api_config()\n active = self.api.get(\"active_provider\", \"deepseek\")\n providers = self.api.get(\"providers\", {})\n prov_conf = providers.get(active, {})\n\n # 全部云端 Provider(显示名 → key)\n all_cloud = Provider.all_cloud() + [Provider.OPENAI_COMPATIBLE]\n self._provider_display_map: dict[str, str] = {}\n display_items: list[str] = []\n for k in all_cloud:\n display = f\"{ProviderMeta.get_display_name(k)}\"\n self._provider_display_map[display] = k\n display_items.append(display)\n\n self.provider_input = QComboBox()\n self.provider_input.addItems(display_items)\n # 反查当前 active 对应的显示名\n active_display = next((d for d, k in self._provider_display_map.items() if k == active), display_items[0])\n self.provider_input.setCurrentText(active_display)\n self.base_url_input = QLineEdit(str(prov_conf.get(\"base_url\", \"\")))\n self.model_input = QLineEdit(str(prov_conf.get(\"model\", \"\")))\n self.auth_header_input = QComboBox()\n self.auth_header_input.addItems([\"bearer\", \"api-key\", \"x-api-key\", \"none\"])\n self.auth_header_input.setCurrentText(str(prov_conf.get(\"auth_header\") or ProviderMeta.get_auth_header(active)))\n self.api_key_input = QLineEdit()\n self.api_key_input.setEchoMode(QLineEdit.EchoMode.Password)\n self.saved_api_key_label = QLabel()\n self.saved_api_key_label.setWordWrap(True)\n\n # 小眼睛切换明文/密码\n self.api_key_toggle_btn = QPushButton(\"显\")\n self.api_key_toggle_btn.setFixedWidth(32)\n self.api_key_toggle_btn.setCheckable(True)\n self.api_key_toggle_btn.toggled.connect(self._toggle_api_key_visibility)\n key_widget = QWidget()\n key_col = QVBoxLayout(key_widget)\n key_col.setContentsMargins(0, 0, 0, 0)\n key_row = QHBoxLayout()\n key_row.setContentsMargins(0, 0, 0, 0)\n key_row.addWidget(self.api_key_input)\n key_row.addWidget(self.api_key_toggle_btn)\n key_col.addLayout(key_row)\n key_col.addWidget(self.saved_api_key_label)\n self._update_saved_api_key_label(active, prov_conf)\n\n self.local_mode_input = QCheckBox(\"启用本地离线模式\")\n self.local_mode_input.setChecked(bool(self.api.get(\"local_mode\", False)))\n self.api_result = QLabel(\"\")\n self.api_result.setWordWrap(True)\n\n self.provider_input.currentTextChanged.connect(self._on_provider_changed)\n\n form.addRow(\"服务商\", self.provider_input)\n form.addRow(\"地址\", self.base_url_input)\n form.addRow(\"模型\", self.model_input)\n form.addRow(\"认证\", self.auth_header_input)\n form.addRow(\"密钥\", key_widget)\n form.addRow(\"本地模式\", self.local_mode_input)\n parent_layout.addLayout(form)\n\n # 按钮行 1:保存 / 测试 / 激活\n btn1 = QHBoxLayout()\n save = QPushButton(\"保存\"); save.setIcon(get_icon(\"save\"))\n test = QPushButton(\"测试连接\"); test.setIcon(get_icon(\"refresh\"))\n activate = QPushButton(\"设为当前模型\"); activate.setIcon(get_icon(\"chip\"))\n save.clicked.connect(self._save_api_settings)\n test.clicked.connect(self._test_api_connection)\n activate.clicked.connect(self._activate_cloud_profile)\n btn1.addWidget(save); btn1.addWidget(test); btn1.addWidget(activate); btn1.addStretch(1)\n parent_layout.addLayout(btn1)\n\n # 按钮行 2:清除 / 重置\n btn2 = QHBoxLayout()\n clear_key = QPushButton(\"清除当前密钥\"); clear_key.setIcon(get_icon(\"settings\"))\n clear_key.clicked.connect(self._clear_api_key)\n reset_prov = QPushButton(\"重置此服务商\"); reset_prov.setIcon(get_icon(\"refresh\"))\n reset_prov.clicked.connect(self._reset_current_provider)\n btn2.addWidget(clear_key); btn2.addWidget(reset_prov); btn2.addStretch(1)\n parent_layout.addLayout(btn2)\n\n # \"有问题?\" 帮助入口\n help_btn = QPushButton(\"有问题?\"); help_btn.setIcon(get_icon(\"info\"))\n help_btn.setStyleSheet(\"QPushButton { color: #0366d6; border: 1px solid #0366d6; border-radius: 4px; padding: 4px 12px; } QPushButton:hover { background: #f0f7ff; }\")\n help_btn.clicked.connect(self._show_api_help)\n parent_layout.addWidget(help_btn, alignment=Qt.AlignmentFlag.AlignLeft)\n\n parent_layout.addWidget(self.api_result)\n parent_layout.addStretch(1)\n\n def _build_multimodal_section(self, parent_layout: Any) -> None:\n \"\"\"多模态配置 — 语音模式面板 + 图像/视频标记为未来功能。\"\"\"\n app_config = self.settings_manager.load_app_config()\n tts_config = app_config.get(\"tts\", {})\n voice_config = app_config.get(\"voice\", {})\n clip_cfg = voice_config.get(\"clip_pack\", {})\n api_cfg = voice_config.get(\"api_tts\", {})\n gpt_cfg = voice_config.get(\"local_gpt_sovits\", {})\n\n\n # ── 语音模式选择 ──\n mode_group = QGroupBox(\"语音合成\")\n mode_layout = QVBoxLayout(mode_group)\n\n mode_hint = QLabel(\"\")\n mode_hint.setWordWrap(True)\n mode_hint.setStyleSheet(\"color: #555; padding: 4px;\")\n mode_layout.addWidget(mode_hint)\n\n mode_form = QFormLayout()\n self.voice_mode_combo = QComboBox()\n self.voice_mode_combo.addItem(\"关闭\", \"off\")\n self.voice_mode_combo.addItem(\"预录语音包\", \"clip_pack\")\n self.voice_mode_combo.addItem(\"云端语音合成(进阶,自备密钥)\", \"api_tts\")\n self.voice_mode_combo.addItem(\"本地 GPT-SoVITS 动态合成(进阶)\", \"local_gpt_sovits\")\n current_mode = voice_config.get(\"mode\", \"off\")\n for i in range(self.voice_mode_combo.count()):\n if self.voice_mode_combo.itemData(i) == current_mode:\n self.voice_mode_combo.setCurrentIndex(i)\n break\n self.voice_mode_combo.currentIndexChanged.connect(self._on_voice_mode_changed)\n mode_form.addRow(\"语音模式\", self.voice_mode_combo)\n mode_layout.addLayout(mode_form)\n\n self.voice_status_label = QLabel(\"状态:检查中…\")\n self.voice_status_label.setWordWrap(True)\n mode_layout.addWidget(self.voice_status_label)\n\n # ── 语音包面板 (clip_pack) ──\n self._clip_pack_group = QGroupBox(\"预录语音包\")\n clip_layout = QVBoxLayout(self._clip_pack_group)\n clip_hint = QLabel(\"推荐普通用户使用。导入官方语音包后即可离线播放固定语音。\")\n clip_hint.setWordWrap(True)\n clip_hint.setStyleSheet(\"color: #555; padding: 2px;\")\n clip_layout.addWidget(clip_hint)\n clip_form = QFormLayout()\n self.clip_pack_id_edit = QLineEdit(str(clip_cfg.get(\"clip_pack_id\", \"daniya_clip_pack_v1\")))\n self.clip_pack_id_edit.setPlaceholderText(\"语音包 ID,如 daniya_clip_pack_v1\")\n self.clip_volume = QSpinBox(); self.clip_volume.setRange(0, 100)\n self.clip_volume.setValue(int(clip_cfg.get(\"volume\", 70)))\n clip_form.addRow(\"语音包 ID\", self.clip_pack_id_edit)\n clip_form.addRow(\"音量\", self.clip_volume)\n clip_layout.addLayout(clip_form)\n clip_btn_row = QHBoxLayout()\n clip_import_btn = QPushButton(\"导入语音包 (ZIP/文件夹)\"); clip_import_btn.setIcon(get_icon(\"download\"))\n clip_import_btn.clicked.connect(self._import_clip_pack)\n clip_verify_btn = QPushButton(\"校验语音包\"); clip_verify_btn.setIcon(get_icon(\"info\"))\n clip_verify_btn.clicked.connect(self._verify_clip_pack)\n clip_test_btn = QPushButton(\"测试播放\"); clip_test_btn.setIcon(get_icon(\"chip\"))\n clip_test_btn.clicked.connect(self._test_clip_play)\n for b in [clip_import_btn, clip_verify_btn, clip_test_btn]:\n clip_btn_row.addWidget(b)\n clip_btn_row.addStretch(1)\n clip_layout.addLayout(clip_btn_row)\n mode_layout.addWidget(self._clip_pack_group)\n\n # ── API TTS 面板 (api_tts) ──\n self._api_tts_group = QGroupBox(\"云端语音合成(进阶,自备密钥)\")\n api_layout = QVBoxLayout(self._api_tts_group)\n api_warn = QLabel(\"需自备第三方语音合成服务。\")\n api_warn.setWordWrap(True)\n api_warn.setStyleSheet(\"color: #c57600; padding: 4px;\")\n api_layout.addWidget(api_warn)\n api_form = QFormLayout()\n self.api_tts_provider = QComboBox()\n self.api_tts_provider.addItem(\"自定义 API\", \"custom\")\n self.api_tts_provider.addItem(\"Fish Audio\", \"fish_audio\")\n self.api_tts_provider.addItem(\"MiniMax\", \"minimax\")\n cur_provider = api_cfg.get(\"provider\", \"custom\")\n for i in range(self.api_tts_provider.count()):\n if self.api_tts_provider.itemData(i) == cur_provider:\n self.api_tts_provider.setCurrentIndex(i)\n break\n self.api_tts_endpoint = QLineEdit(str(api_cfg.get(\"endpoint\", \"\")))\n self.api_tts_endpoint.setPlaceholderText(\"https://api.example.com/tts\")\n self.api_tts_key = QLineEdit(str(api_cfg.get(\"api_key\", \"\")))\n self.api_tts_key.setEchoMode(QLineEdit.EchoMode.Password)\n self.api_tts_key.setPlaceholderText(\"sk-...\")\n self.api_tts_voice_id = QLineEdit(str(api_cfg.get(\"voice_id\", \"\")))\n self.api_tts_voice_id.setPlaceholderText(\"声音 ID\")\n self.api_tts_timeout = QSpinBox(); self.api_tts_timeout.setRange(5, 120)\n self.api_tts_timeout.setValue(int(api_cfg.get(\"timeout_sec\", 30)))\n self.api_tts_cache = QCheckBox(\"启用缓存\")\n self.api_tts_cache.setChecked(bool(api_cfg.get(\"cache_enabled\", True)))\n self.api_tts_volume = QSpinBox(); self.api_tts_volume.setRange(0, 100)\n self.api_tts_volume.setValue(int(api_cfg.get(\"volume\", 70)))\n api_form.addRow(\"提供商\", self.api_tts_provider)\n api_form.addRow(\"端点地址\", self.api_tts_endpoint)\n api_form.addRow(\"密钥\", self.api_tts_key)\n api_form.addRow(\"声音 ID\", self.api_tts_voice_id)\n api_form.addRow(\"超时(秒)\", self.api_tts_timeout)\n api_form.addRow(\"\", self.api_tts_cache)\n api_form.addRow(\"音量\", self.api_tts_volume)\n api_layout.addLayout(api_form)\n api_test_btn = QPushButton(\"测试播放\"); api_test_btn.setIcon(get_icon(\"chip\"))\n api_test_btn.clicked.connect(self._test_api_tts_play)\n api_layout.addWidget(api_test_btn, alignment=Qt.AlignmentFlag.AlignLeft)\n mode_layout.addWidget(self._api_tts_group)\n\n # ── GPT-SoVITS 面板 (local_gpt_sovits) ──\n self._gpt_sovits_group = QGroupBox(\"本地 GPT-SoVITS 动态合成(进阶)\")\n gpt_layout = QVBoxLayout(self._gpt_sovits_group)\n adv_warn = QLabel(\"需先在本机启动推理服务。\")\n adv_warn.setWordWrap(True)\n adv_warn.setStyleSheet(\"color: #c57600; padding: 4px;\")\n gpt_layout.addWidget(adv_warn)\n\n self.tts_enabled_check = QCheckBox(\"启用 TTS 语音播报\")\n self.tts_enabled_check.setChecked(bool(tts_config.get(\"enabled\", False)))\n gpt_layout.addWidget(self.tts_enabled_check)\n\n gpt_form = QFormLayout()\n self.tts_voice_id = QLineEdit(str(gpt_cfg.get(\"voice_id\", tts_config.get(\"voice_id\", \"daniya_voice_v1\"))))\n self.tts_voice_id.setPlaceholderText(\"声线包 ID,如 daniya_voice_v1\")\n self.tts_api_url = QLineEdit(str(gpt_cfg.get(\"api_base_url\", tts_config.get(\"local_api_base_url\", \"http://127.0.0.1:9880\"))))\n self.tts_volume = QSpinBox(); self.tts_volume.setRange(0, 100)\n self.tts_volume.setValue(int(gpt_cfg.get(\"volume\", tts_config.get(\"volume\", 70))))\n gpt_form.addRow(\"声线包 ID\", self.tts_voice_id)\n gpt_form.addRow(\"合成服务地址\", self.tts_api_url)\n gpt_form.addRow(\"音量\", self.tts_volume)\n gpt_layout.addLayout(gpt_form)\n\n self.tts_status_label = QLabel(\"状态:检查中…\")\n self.tts_status_label.setWordWrap(True)\n gpt_layout.addWidget(self.tts_status_label)\n\n btn_row = QHBoxLayout()\n import_btn = QPushButton(\"导入本地声线包\"); import_btn.setIcon(get_icon(\"download\"))\n import_btn.clicked.connect(self._import_voice_pack)\n verify_btn = QPushButton(\"校验声线包\"); verify_btn.setIcon(get_icon(\"info\"))\n verify_btn.clicked.connect(self._verify_voice_pack)\n check_engine_btn = QPushButton(\"检查 GPT-SoVITS 服务\"); check_engine_btn.setIcon(get_icon(\"internet\"))\n check_engine_btn.clicked.connect(self._check_tts_engine)\n test_btn = QPushButton(\"测试播放\"); test_btn.setIcon(get_icon(\"chip\"))\n test_btn.clicked.connect(self._test_tts_play)\n clear_cache_btn = QPushButton(\"清理 TTS 缓存\"); clear_cache_btn.setIcon(get_icon(\"refresh\"))\n clear_cache_btn.clicked.connect(self._clear_tts_cache)\n for b in [import_btn, verify_btn, check_engine_btn, test_btn, clear_cache_btn]:\n btn_row.addWidget(b)\n btn_row.addStretch(1)\n gpt_layout.addLayout(btn_row)\n mode_layout.addWidget(self._gpt_sovits_group)\n\n save_voice_btn = QPushButton(\"保存语音设置\"); save_voice_btn.setIcon(get_icon(\"save\"))\n save_voice_btn.clicked.connect(self._save_voice_settings)\n mode_layout.addWidget(save_voice_btn, alignment=Qt.AlignmentFlag.AlignLeft)\n\n parent_layout.addWidget(mode_group)\n self._on_voice_mode_changed()\n self._refresh_tts_status()\n\n # ── 图像生成 — 未来功能 ──\n img_group = QGroupBox(\"图像生成\")\n img_layout = QVBoxLayout(img_group)\n img_label = QLabel(\"未来功能,当前版本不可用。\")\n img_label.setStyleSheet(\"color: gray; padding: 12px;\")\n img_layout.addWidget(img_label)\n parent_layout.addWidget(img_group)\n\n # ── 视频生成 — 未来功能 ──\n vid_group = QGroupBox(\"视频生成\")\n vid_layout = QVBoxLayout(vid_group)\n vid_label = QLabel(\"未来功能,当前版本不可用。\")\n vid_label.setStyleSheet(\"color: gray; padding: 12px;\")\n vid_layout.addWidget(vid_label)\n parent_layout.addWidget(vid_group)\n parent_layout.addStretch(1)\n\n def _on_voice_mode_changed(self) -> None:\n mode = self.voice_mode_combo.currentData()\n self._clip_pack_group.setVisible(mode == \"clip_pack\")\n self._api_tts_group.setVisible(mode == \"api_tts\")\n self._gpt_sovits_group.setVisible(mode == \"local_gpt_sovits\")\n if mode == \"off\":\n self.voice_status_label.setText(\"语音已关闭。\")\n elif mode == \"clip_pack\":\n self._refresh_clip_pack_status()\n elif mode == \"api_tts\":\n self._refresh_api_tts_status()\n elif mode == \"local_gpt_sovits\":\n self._refresh_tts_status()\n\n def _refresh_clip_pack_status(self) -> None:\n from core.tts.clip_pack import ClipPackVoiceService\n pack_id = self.clip_pack_id_edit.text().strip()\n svc = ClipPackVoiceService()\n ok, errors = svc.verify_pack(pack_id)\n if ok:\n self.voice_status_label.setText(f\"语音包 \\\"{pack_id}\\\" 校验通过。\")\n elif errors and errors[0] == \"CLIP_PACK_NOT_FOUND\":\n self.voice_status_label.setText(f\"语音包 \\\"{pack_id}\\\" 未安装。请先导入语音包。\")\n else:\n self.voice_status_label.setText(f\"语音包 \\\"{pack_id}\\\" 校验失败:{'; '.join(errors[:3])}\")\n\n def _refresh_api_tts_status(self) -> None:\n from core.tts.api_tts_adapter import mask_key\n endpoint = self.api_tts_endpoint.text().strip()\n key = self.api_tts_key.text().strip()\n if not endpoint:\n self.voice_status_label.setText(\"云端语音合成:未配置端点。请填写端点地址和密钥。\")\n else:\n masked = mask_key(key) if key else \"未设置\"\n self.voice_status_label.setText(f\"云端语音合成:端点 {endpoint}\\n密钥 {masked}\")\n\n def _import_clip_pack(self) -> None:\n from pathlib import Path as P\n import json, os, shutil, zipfile\n\n clip_root = P(__file__).resolve().parents[1] / \"assets\" / \"voice_clips\"\n clip_root.mkdir(parents=True, exist_ok=True)\n\n path, _ = QFileDialog.getOpenFileName(\n self, \"选择语音包 ZIP(取消后可选择文件夹)\", \"\",\n \"ZIP 语音包 (*.zip)\",\n )\n\n if path and path.lower().endswith(\".zip\"):\n tmp_dir = clip_root / f\"_importing_{os.getpid()}\"\n try:\n with zipfile.ZipFile(path, \"r\") as zf:\n for info in zf.infolist():\n if info.filename.startswith(\"/\") or \"..\" in info.filename:\n QMessageBox.warning(\n self, \"导入失败\",\n f\"ZIP 包含不安全路径:{info.filename}\",\n )\n return\n zf.extractall(tmp_dir)\n\n candidates = list(tmp_dir.rglob(\"manifest.json\"))\n if not candidates:\n QMessageBox.warning(self, \"导入失败\", \"ZIP 中没有 manifest.json。\")\n return\n\n pack_root = candidates[0].parent\n manifest = json.loads(candidates[0].read_text(encoding=\"utf-8\"))\n pack_id = manifest.get(\"clip_pack_id\", pack_root.name)\n dest = clip_root / pack_id\n\n if dest.exists():\n reply = QMessageBox.question(\n self, \"覆盖确认\",\n f\"语音包 \\\"{pack_id}\\\" 已存在。是否覆盖?\",\n QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,\n )\n if reply != QMessageBox.StandardButton.Yes:\n return\n shutil.rmtree(dest)\n\n if pack_root == tmp_dir:\n tmp_dir.rename(dest)\n else:\n shutil.move(str(pack_root), str(dest))\n\n QMessageBox.information(\n self, \"导入成功\", f\"语音包 \\\"{pack_id}\\\" 导入成功。\",\n )\n self.clip_pack_id_edit.setText(pack_id)\n self._refresh_clip_pack_status()\n except zipfile.BadZipFile:\n QMessageBox.warning(self, \"导入失败\", \"无效的 ZIP 文件。\")\n except Exception as exc:\n QMessageBox.warning(self, \"导入失败\", str(exc))\n finally:\n if tmp_dir.exists():\n shutil.rmtree(tmp_dir, ignore_errors=True)\n return\n\n dir_path = QFileDialog.getExistingDirectory(self, \"选择语音包文件夹\")\n if not dir_path:\n return\n src = P(dir_path)\n if not (src / \"manifest.json\").exists():\n QMessageBox.warning(self, \"导入失败\", \"所选文件夹中没有 manifest.json。\")\n return\n try:\n manifest = json.loads((src / \"manifest.json\").read_text(encoding=\"utf-8\"))\n pack_id = manifest.get(\"clip_pack_id\", src.name)\n except Exception:\n pack_id = src.name\n dest = clip_root / pack_id\n if dest.exists():\n reply = QMessageBox.question(\n self, \"覆盖确认\",\n f\"语音包 \\\"{pack_id}\\\" 已存在。是否覆盖?\",\n QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,\n )\n if reply != QMessageBox.StandardButton.Yes:\n return\n shutil.rmtree(dest)\n shutil.copytree(src, dest)\n QMessageBox.information(self, \"导入成功\", f\"语音包 \\\"{pack_id}\\\" 导入成功。\")\n self.clip_pack_id_edit.setText(pack_id)\n self._refresh_clip_pack_status()\n\n def _verify_clip_pack(self) -> None:\n from core.tts.clip_pack import ClipPackVoiceService\n pack_id = self.clip_pack_id_edit.text().strip()\n svc = ClipPackVoiceService()\n ok, errors = svc.verify_pack(pack_id)\n if ok:\n QMessageBox.information(self, \"校验结果\", f\"语音包 \\\"{pack_id}\\\" 校验通过。\")\n else:\n QMessageBox.warning(self, \"校验结果\", f\"语音包 \\\"{pack_id}\\\" 校验失败:\\n\" + \"\\n\".join(errors))\n self._refresh_clip_pack_status()\n\n def _test_clip_play(self) -> None:\n from core.tts.clip_pack import ClipPackVoiceService\n from core.tts.audio_player import AudioPlayer\n pack_id = self.clip_pack_id_edit.text().strip()\n player = AudioPlayer(self)\n player.set_volume(self.clip_volume.value())\n svc = ClipPackVoiceService(audio_player=player)\n ok, msg = svc.test_play(pack_id)\n if ok:\n QMessageBox.information(self, \"测试播放\", \"语音包测试播放成功。\")\n else:\n QMessageBox.warning(self, \"测试播放\", f\"测试播放失败:{msg}\")\n\n def _test_api_tts_play(self) -> None:\n from core.tts.api_tts_adapter import ApiTTSAdapter, ApiTTSError\n from core.tts.audio_player import AudioPlayer\n cfg = {\n \"provider\": self.api_tts_provider.currentData(),\n \"endpoint\": self.api_tts_endpoint.text().strip(),\n \"api_key\": self.api_tts_key.text().strip(),\n \"voice_id\": self.api_tts_voice_id.text().strip(),\n \"timeout_sec\": self.api_tts_timeout.value(),\n \"cache_enabled\": self.api_tts_cache.isChecked(),\n }\n if not cfg[\"endpoint\"]:\n QMessageBox.warning(self, \"测试失败\", \"请先填写 API 端点。\")\n return\n try:\n adapter = ApiTTSAdapter(cfg)\n wav = adapter.synthesize(\"测试语音播放\")\n if wav and wav.exists():\n player = AudioPlayer(self)\n player.set_volume(self.api_tts_volume.value())\n player.play_wav(wav)\n QMessageBox.information(self, \"测试播放\", \"云端语音合成测试播放成功。\")\n else:\n QMessageBox.warning(self, \"测试播放\", \"服务端未返回音频数据。\")\n except ApiTTSError as exc:\n QMessageBox.warning(self, \"测试播放\", f\"云端语音合成测试失败:{exc}\")\n except Exception as exc:\n QMessageBox.warning(self, \"测试播放\", f\"测试异常:{exc}\")\n\n def _save_voice_settings(self) -> None:\n config = self.settings_manager.load_app_config()\n mode = self.voice_mode_combo.currentData()\n prev_voice = config.get(\"voice\", {})\n config[\"voice\"] = {\n \"mode\": mode,\n \"clip_pack\": {\n \"clip_pack_id\": self.clip_pack_id_edit.text().strip(),\n \"remote_manifest_url\": prev_voice.get(\"clip_pack\", {}).get(\"remote_manifest_url\", \"\"),\n \"auto_download\": False,\n \"volume\": self.clip_volume.value(),\n },\n \"api_tts\": {\n \"provider\": self.api_tts_provider.currentData(),\n \"endpoint\": self.api_tts_endpoint.text().strip(),\n \"api_key\": self.api_tts_key.text().strip(),\n \"voice_id\": self.api_tts_voice_id.text().strip(),\n \"timeout_sec\": self.api_tts_timeout.value(),\n \"cache_enabled\": self.api_tts_cache.isChecked(),\n \"volume\": self.api_tts_volume.value(),\n },\n \"local_gpt_sovits\": {\n \"voice_id\": self.tts_voice_id.text().strip(),\n \"api_base_url\": self.tts_api_url.text().strip(),\n \"volume\": self.tts_volume.value(),\n \"cache_enabled\": True,\n },\n }\n if mode == \"local_gpt_sovits\":\n config[\"tts\"] = {\n \"enabled\": self.tts_enabled_check.isChecked(),\n \"voice_id\": self.tts_voice_id.text().strip(),\n \"engine\": \"gpt-sovits\",\n \"local_api_base_url\": self.tts_api_url.text().strip(),\n \"volume\": self.tts_volume.value(),\n \"cache_enabled\": True,\n \"max_text_length\": 120,\n \"interaction_probability\": 0.25,\n }\n if hasattr(self.controller, \"tts_service\") and self.controller.tts_service is not None:\n self.controller.tts_service.update_config(config[\"tts\"])\n self.controller.tts_service.audio_player.set_volume(self.tts_volume.value())\n self.settings_manager.save_app_config(config)\n if hasattr(self.controller, \"voice_router\"):\n self.controller.voice_router.update_config(config)\n if hasattr(self.controller, \"_audio_player\"):\n vol = {\"clip_pack\": self.clip_volume, \"api_tts\": self.api_tts_volume, \"local_gpt_sovits\": self.tts_volume}.get(mode)\n if vol:\n self.controller._audio_player.set_volume(vol.value())\n self.voice_status_label.setText(\"语音设置已保存。\")\n self._on_voice_mode_changed()\n\n def _refresh_tts_status(self) -> None:\n from core.tts.voice_asset_manager import VoiceAssetManager, VoiceStatus\n mgr = VoiceAssetManager()\n voice_id = self.tts_voice_id.text().strip()\n voices = mgr.list_installed_voices()\n if not voices:\n self.tts_status_label.setText(\"声线包状态:未导入\\n请先导入 GPT-SoVITS 声线包。\")\n return\n status, errors = mgr.verify_voice(voice_id)\n lines = [f\"声线包状态:{status.value}\"]\n if errors:\n lines.append(f\"问题:{'; '.join(errors[:3])}\")\n installed = \", \".join(v.display_name for v in voices)\n lines.append(f\"已安装声线:{installed}\")\n self.tts_status_label.setText(\"\\n\".join(lines))\n\n def _import_voice_pack(self) -> None:\n from core.tts.voice_asset_manager import VoiceAssetManager\n path, _ = QFileDialog.getOpenFileName(self, \"选择声线包 ZIP\", \"\", \"ZIP 文件 (*.zip)\")\n if not path:\n return\n try:\n mgr = VoiceAssetManager()\n voice = mgr.import_from_local_zip(Path(path))\n QMessageBox.information(self, \"导入成功\", f\"声线包 \\\"{voice.display_name}\\\" 导入成功。\")\n self._refresh_tts_status()\n except Exception as exc:\n QMessageBox.warning(self, \"导入失败\", str(exc))\n\n def _verify_voice_pack(self) -> None:\n from core.tts.voice_asset_manager import VoiceAssetManager\n mgr = VoiceAssetManager()\n voice_id = self.tts_voice_id.text().strip()\n status, errors = mgr.verify_voice(voice_id)\n if not errors:\n QMessageBox.information(self, \"校验结果\", f\"声线包 \\\"{voice_id}\\\" 校验通过。\")\n else:\n QMessageBox.warning(self, \"校验结果\", f\"声线包 \\\"{voice_id}\\\" 校验失败:\\n\" + \"\\n\".join(errors))\n self._refresh_tts_status()\n\n def _check_tts_engine(self) -> None:\n from core.tts.gpt_sovits_adapter import GPTSoVITSAdapter\n url = self.tts_api_url.text().strip()\n adapter = GPTSoVITSAdapter(api_base_url=url)\n if adapter.health_check():\n QMessageBox.information(self, \"引擎状态\", f\"GPT-SoVITS 服务运行中({url})。\")\n else:\n QMessageBox.warning(self, \"引擎状态\", f\"GPT-SoVITS 服务未运行或不可达({url})。\")\n\n def _test_tts_play(self) -> None:\n if not hasattr(self.controller, \"tts_service\") or self.controller.tts_service is None:\n QMessageBox.warning(self, \"测试失败\", \"TTS 服务未初始化,请先保存设置并重启应用。\")\n return\n ok, msg = self.controller.tts_service.test_play(self.tts_voice_id.text().strip())\n if ok:\n QMessageBox.information(self, \"测试播放\", \"测试播放成功。\")\n else:\n QMessageBox.warning(self, \"测试播放\", f\"测试播放失败:{msg}\")\n\n def _save_tts_settings(self) -> None:\n self._save_voice_settings()\n\n def _clear_tts_cache(self) -> None:\n if hasattr(self.controller, \"tts_service\") and self.controller.tts_service is not None:\n count = self.controller.tts_service.clear_cache()\n QMessageBox.information(self, \"缓存清理\", f\"已清理 {count} 个缓存文件。\")\n else:\n from core.tts.gpt_sovits_adapter import GPTSoVITSAdapter\n count = GPTSoVITSAdapter().clear_cache()\n QMessageBox.information(self, \"缓存清理\", f\"已清理 {count} 个缓存文件。\")\n\n def _build_profile_switcher(self, parent_layout: Any) -> None:\n self.profile_switch_group = QGroupBox(\"文本模型切换\")\n layout = QVBoxLayout(self.profile_switch_group)\n row = QHBoxLayout()\n self.profile_switch_combo = QComboBox()\n self.profile_switch_combo.setMinimumWidth(360)\n self.profile_switch_btn = QPushButton(\"直接切换\"); self.profile_switch_btn.setIcon(get_icon(\"chip\"))\n self.profile_enable_btn = QPushButton(\"启用\"); self.profile_enable_btn.setIcon(get_icon(\"save\"))\n self.profile_disable_btn = QPushButton(\"停用\"); self.profile_disable_btn.setIcon(get_icon(\"settings\"))\n row.addWidget(self.profile_switch_combo, 1)\n row.addWidget(self.profile_switch_btn)\n row.addWidget(self.profile_enable_btn)\n row.addWidget(self.profile_disable_btn)\n layout.addLayout(row)\n\n self.profile_switch_status = QLabel(\"历史与已保存模型可在这里直接切换。\")\n self.profile_switch_status.setWordWrap(True)\n layout.addWidget(self.profile_switch_status)\n\n self.profile_switch_btn.clicked.connect(self._switch_selected_text_profile)\n self.profile_enable_btn.clicked.connect(lambda: self._set_selected_text_profile_enabled(True))\n self.profile_disable_btn.clicked.connect(lambda: self._set_selected_text_profile_enabled(False))\n parent_layout.addWidget(self.profile_switch_group)\n self._refresh_profile_switcher()\n\n def _refresh_profile_switcher(self) -> None:\n combo = getattr(self, \"profile_switch_combo\", None)\n if combo is None:\n return\n current_data = combo.currentData()\n combo.blockSignals(True)\n combo.clear()\n\n profiles_data = self.settings_manager.load_model_profiles()\n active_id = profiles_data.get(\"active_text_profile_id\", \"\")\n profiles = [p for p in profiles_data.get(\"profiles\", []) if _profile_has_text(p)]\n by_id = {str(p.get(\"id\", \"\")): p for p in profiles if p.get(\"id\")}\n history = profiles_data.get(\"profile_history\", {}).get(\"text\", [])\n ordered_ids: list[str] = []\n for profile_id in [active_id] + [str(item) for item in history] + [str(p.get(\"id\", \"\")) for p in profiles]:\n if profile_id and profile_id in by_id and profile_id not in ordered_ids:\n ordered_ids.append(profile_id)\n\n for profile_id in ordered_ids:\n profile = by_id[profile_id]\n marker = \"当前\" if profile_id == active_id else (\"停用\" if profile.get(\"enabled\", True) is False else \"可用\")\n source = \"本地\" if profile.get(\"source\") == \"local\" else \"云端\"\n name = str(profile.get(\"name\") or profile_id)\n model = str(profile.get(\"model\") or \"\")\n combo.addItem(f\"[{marker}] {source} · {name} · {model}\", profile_id)\n\n if current_data:\n index = combo.findData(current_data)\n if index >= 0:\n combo.setCurrentIndex(index)\n combo.blockSignals(False)\n\n def _switch_selected_text_profile(self) -> None:\n profile_id = self.profile_switch_combo.currentData()\n if not profile_id:\n self.profile_switch_status.setText(\"状态:没有可切换的文本模型。\")\n self.profile_switch_status.setStyleSheet(\"color: red;\")\n return\n self._do_switch_profile(str(profile_id), str(profile_id), self.profile_switch_status)\n\n def _set_selected_text_profile_enabled(self, enabled: bool) -> None:\n profile_id = self.profile_switch_combo.currentData()\n if not profile_id:\n self.profile_switch_status.setText(\"状态:没有选中的文本模型。\")\n self.profile_switch_status.setStyleSheet(\"color: red;\")\n return\n ok, msg = self.settings_manager.set_profile_enabled(str(profile_id), enabled, slot=\"text\")\n self._refresh_active_status()\n self._refresh_profile_switcher()\n self.profile_switch_status.setText(f\"状态:{msg}\")\n self.profile_switch_status.setStyleSheet(\"color: green;\" if ok else \"color: red;\")\n\n def _build_local_model_section(self, parent_layout: Any) -> None:\n from .model_catalog import ModelCatalog\n\n # ── 服务配置表单 ──\n form = QFormLayout()\n\n self.local_service_combo = QComboBox()\n self.local_service_combo.addItems([\"Ollama\", \"LM Studio\", \"llama.cpp server\", \"OpenAI-compatible local\", \"Custom\"])\n\n self.local_base_url = QLineEdit()\n self.local_base_url.setPlaceholderText(\"http://localhost:11434\")\n\n self.local_model_list = QComboBox()\n self.local_model_list.setEditable(True)\n\n form.addRow(\"服务类型\", self.local_service_combo)\n form.addRow(\"地址\", self.local_base_url)\n form.addRow(\"模型\", self.local_model_list)\n parent_layout.addLayout(form)\n\n btn_layout = QHBoxLayout()\n self.fetch_models_btn = QPushButton(\"拉取模型列表\"); self.fetch_models_btn.setIcon(get_icon(\"download\"))\n self.test_local_btn = QPushButton(\"测试服务连接\"); self.test_local_btn.setIcon(get_icon(\"refresh\"))\n self.save_local_btn = QPushButton(\"保存本地模型\"); self.save_local_btn.setIcon(get_icon(\"save\"))\n self.activate_local_btn = QPushButton(\"设为当前模型\"); self.activate_local_btn.setIcon(get_icon(\"chip\"))\n btn_layout.addWidget(self.fetch_models_btn)\n btn_layout.addWidget(self.test_local_btn)\n btn_layout.addWidget(self.save_local_btn)\n btn_layout.addWidget(self.activate_local_btn)\n btn_layout.addStretch(1)\n parent_layout.addLayout(btn_layout)\n\n self.local_status = QLabel(\"状态:未测试\")\n self.local_status.setWordWrap(True)\n parent_layout.addWidget(self.local_status)\n\n self.fetch_models_btn.clicked.connect(self._fetch_local_models)\n self.test_local_btn.clicked.connect(self._test_local_service)\n self.save_local_btn.clicked.connect(self._save_local_model_settings)\n self.activate_local_btn.clicked.connect(self._activate_local_profile)\n\n # 本地模型重置按钮\n local_reset_btn = QPushButton(\"清空本地配置\"); local_reset_btn.setIcon(get_icon(\"refresh\"))\n local_reset_btn.clicked.connect(self._clear_local_config)\n parent_layout.addWidget(local_reset_btn, alignment=Qt.AlignmentFlag.AlignLeft)\n\n # ── 推荐模型目录 ──\n catalog = ModelCatalog(root=self.settings_manager.root)\n recommended = catalog.get_recommended_models()\n\n if recommended:\n rec_group = QGroupBox(\"推荐模型(内置目录)\")\n rec_layout = QVBoxLayout(rec_group)\n\n scroll = QScrollArea()\n scroll.setWidgetResizable(True)\n scroll.setMinimumHeight(220)\n scroll.setMaximumHeight(380)\n cards_widget = QWidget()\n cards_layout = QVBoxLayout(cards_widget)\n cards_layout.setSpacing(8)\n\n for m in recommended:\n card = self._build_model_card(m)\n cards_layout.addWidget(card)\n\n cards_layout.addStretch(1)\n scroll.setWidget(cards_widget)\n rec_layout.addWidget(scroll)\n parent_layout.addWidget(rec_group)\n\n # ── 许可证与下载器 ──\n lic_label = QLabel(\n \"使用本地模型前请确认已阅读并同意对应模型的许可证条款。\\n\"\n \"点击下方按钮可查看模型详情并启动 Ollama 拉取。\"\n )\n lic_label.setWordWrap(True)\n lic_label.setStyleSheet(\n \"color: #856404; background-color: #fff3cd; border: 1px solid #ffeeba; padding: 10px; margin-top: 15px;\"\n )\n parent_layout.addWidget(lic_label)\n\n self.downloader_btn = QPushButton(\"打开内置下载器\"); self.downloader_btn.setIcon(get_icon(\"download\"))\n self.downloader_btn.clicked.connect(self._open_model_downloader)\n parent_layout.addWidget(self.downloader_btn, alignment=Qt.AlignmentFlag.AlignLeft)\n\n # 导入自定义模型\n import_model_btn = QPushButton(\"导入自定义模型\"); import_model_btn.setIcon(get_icon(\"upload\"))\n import_model_btn.clicked.connect(self._import_custom_model)\n parent_layout.addWidget(import_model_btn, alignment=Qt.AlignmentFlag.AlignLeft)\n\n parent_layout.addStretch(1)\n\n def _build_model_card(self, m: dict) -> QWidget:\n card = QWidget()\n card.setStyleSheet(\n \"QWidget#modelCard { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 10px; }\"\n )\n card.setObjectName(\"modelCard\")\n\n layout = QVBoxLayout(card)\n layout.setContentsMargins(10, 8, 10, 8)\n\n # 第一行:模型名 + 厂商\n header = QHBoxLayout()\n name_label = QLabel(f\"<b>{m.get('display_name', m.get('id', ''))}</b>\")\n vendor_label = QLabel(f\"{m.get('vendor', '')} | {m.get('size_class', '')} | ~{m.get('estimated_disk_size', '')}\")\n vendor_label.setStyleSheet(\"color: #6c757d;\")\n header.addWidget(name_label)\n header.addStretch(1)\n header.addWidget(vendor_label)\n layout.addLayout(header)\n\n # 第二行:硬件要求\n hw = m.get(\"recommended_hardware\", \"\")\n if hw:\n hw_label = QLabel(f\"硬件:{hw}\")\n hw_label.setStyleSheet(\"color: #495057; font-size: 12px;\")\n layout.addWidget(hw_label)\n\n # 第三行:许可证\n lic = m.get(\"license_name\", \"\")\n lic_url = m.get(\"license_url\", \"\")\n if lic:\n lic_text = f\"许可:<a href='{lic_url}'>{lic}</a>\" if lic_url else f\"许可:{lic}\"\n lic_label = QLabel(lic_text)\n lic_label.setOpenExternalLinks(True)\n lic_label.setStyleSheet(\"font-size: 12px;\")\n layout.addWidget(lic_label)\n\n # 第四行:适用场景\n rec_for = m.get(\"recommended_for\", [])\n if rec_for:\n tags = \", \".join(rec_for)\n tags_label = QLabel(f\"适用:{tags}\")\n tags_label.setStyleSheet(\"color: #28a745; font-size: 12px;\")\n layout.addWidget(tags_label)\n\n # 按钮行\n btn_row = QHBoxLayout()\n select_btn = QPushButton(\"选择此模型\")\n select_btn.setFixedWidth(100)\n select_btn.clicked.connect(lambda: self._on_select_recommended_model(m))\n\n pull_btn = QPushButton(\"Ollama 拉取\"); pull_btn.setIcon(get_icon(\"download\"))\n pull_btn.setFixedWidth(100)\n pull_btn.clicked.connect(lambda: self._on_ollama_pull_model(m))\n\n official_url = m.get(\"official_url\", \"\")\n if official_url:\n official_btn = QPushButton(\"官方页面\")\n official_btn.setFixedWidth(80)\n official_btn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(official_url)))\n btn_row.addWidget(official_btn)\n\n lic_url = m.get(\"license_url\", \"\")\n if lic_url:\n lic_btn = QPushButton(\"许可证\")\n lic_btn.setFixedWidth(70)\n lic_btn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(lic_url)))\n btn_row.addWidget(lic_btn)\n\n detail_btn = QPushButton(\"详情\")\n detail_btn.setFixedWidth(50)\n detail_btn.clicked.connect(lambda: self._on_show_model_detail(m))\n\n btn_row.addWidget(select_btn)\n btn_row.addWidget(pull_btn)\n btn_row.addWidget(detail_btn)\n btn_row.addStretch(1)\n layout.addLayout(btn_row)\n\n return card\n\n def _on_select_recommended_model(self, m: dict) -> None:\n ollama_method = None\n for dm in m.get(\"download_methods\", []):\n if dm.get(\"type\") == \"ollama\":\n ollama_method = dm\n break\n\n model_name = ollama_method[\"model\"] if ollama_method else m.get(\"display_name\", \"\")\n\n # 自动设置服务类型为 Ollama 和默认 URL\n self.local_service_combo.setCurrentText(\"Ollama\")\n self.local_base_url.setText(\"http://localhost:11434\")\n self.local_model_list.setCurrentText(model_name)\n self.local_status.setText(f\"状态:已选择 {model_name}(请点击「保存本地模型」完成配置)\")\n self.local_status.setStyleSheet(\"color: green;\")\n\n def _on_ollama_pull_model(self, m: dict) -> None:\n ollama_method = None\n for dm in m.get(\"download_methods\", []):\n if dm.get(\"type\") == \"ollama\":\n ollama_method = dm\n break\n\n if not ollama_method:\n QMessageBox.information(self, \"提示\", \"该模型暂不支持 Ollama 一键拉取,请通过详情页手动下载。\")\n return\n\n model_name = ollama_method[\"model\"]\n lic_name = m.get(\"license_name\", \"未知许可证\")\n lic_url = m.get(\"license_url\", \"\")\n disk_size = m.get(\"estimated_disk_size\", \"未知\")\n hardware = m.get(\"recommended_hardware\", \"未知\")\n\n # 许可证确认(含磁盘和硬件提示)\n msg = (\n f\"即将通过 Ollama 拉取模型:\\n\\n\"\n f\" 模型:{model_name}\\n\"\n f\" 磁盘占用:~{disk_size}\\n\"\n f\" 硬件要求:{hardware}\\n\"\n f\" 许可证:{lic_name}\\n\\n\"\n )\n if lic_url:\n msg += f\"许可证详情:{lic_url}\\n\\n\"\n msg += \"请确认您已阅读并同意该模型的许可证条款。\\n继续拉取?\"\n\n reply = QMessageBox.question(\n self, \"许可证确认\", msg,\n QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,\n )\n if reply != QMessageBox.StandardButton.Yes:\n return\n\n # 先检查 Ollama 是否运行\n self.local_status.setText(\"状态:正在检测 Ollama 服务...\")\n self.local_status.setStyleSheet(\"color: #007bff;\")\n self.downloader_btn.setEnabled(False)\n\n self.ollama_health_worker = _OllamaHealthWorker()\n self.ollama_health_worker.finished_with_result.connect(\n lambda ok, msg: self._on_health_check_done(ok, msg, model_name)\n )\n self.ollama_health_worker.finished.connect(self.ollama_health_worker.deleteLater)\n self.ollama_health_worker.start()\n\n def _on_health_check_done(self, ok: bool, msg: str, model_name: str) -> None:\n if not ok:\n self.downloader_btn.setEnabled(True)\n self.local_status.setText(f\"状态:{msg}\")\n self.local_status.setStyleSheet(\"color: red;\")\n return\n\n self.local_status.setText(f\"状态:正在拉取 {model_name} ...\")\n self.local_status.setStyleSheet(\"color: #007bff;\")\n\n self.ollama_worker = _OllamaPullWorker(model_name)\n self.ollama_worker.progress_update.connect(\n lambda text: self.local_status.setText(f\"状态:[拉取中] {text}\")\n )\n self.ollama_worker.finished_with_result.connect(self._on_ollama_pull_finished)\n self.ollama_worker.finished.connect(self.ollama_worker.deleteLater)\n self.ollama_worker.start()\n\n def _on_ollama_pull_finished(self, ok: bool, msg: str) -> None:\n self.downloader_btn.setEnabled(True)\n if ok:\n self.local_status.setText(f\"状态:{msg}\")\n self.local_status.setStyleSheet(\"color: green;\")\n # 自动刷新模型列表\n self._fetch_local_models()\n else:\n self.local_status.setText(f\"状态:{msg}\")\n self.local_status.setStyleSheet(\"color: red;\")\n\n def _on_show_model_detail(self, m: dict) -> None:\n lines = [\n f\"模型:{m.get('display_name', '')}\",\n f\"厂商:{m.get('vendor', '')}\",\n f\"参数量:{m.get('size_class', '')}\",\n f\"磁盘占用:~{m.get('estimated_disk_size', '')}\",\n f\"硬件要求:{m.get('recommended_hardware', '')}\",\n f\"许可证:{m.get('license_name', '')}\",\n f\"许可链接:{m.get('license_url', '')}\",\n f\"官网:{m.get('official_url', '')}\",\n f\"文档:{m.get('docs_url', '')}\",\n \"\",\n \"下载方式:\",\n ]\n for dm in m.get(\"download_methods\", []):\n t = dm.get(\"type\", \"\")\n if t == \"ollama\":\n lines.append(f\" Ollama: ollama pull {dm.get('model', '')}\")\n elif t == \"direct_gguf\":\n lines.append(f\" GGUF 直链: {dm.get('filename', '')}\")\n elif t == \"external_link\":\n lines.append(f\" 外部链接: {dm.get('label', '')} - {dm.get('model_id', '')}\")\n\n QMessageBox.information(self, f\"模型详情 - {m.get('display_name', '')}\", \"\\n\".join(lines))\n\n def _open_model_downloader(self) -> None:\n from .model_catalog import ModelCatalog\n catalog = ModelCatalog(root=self.settings_manager.root)\n recommended = catalog.get_recommended_models()\n\n if not recommended:\n QMessageBox.information(self, \"内置下载器\", \"当前推荐模型目录为空。\")\n return\n\n dialog = QDialog(self)\n dialog.setWindowTitle(\"内置下载器 - 选择模型\")\n dialog.setMinimumWidth(550)\n dialog.setWindowFlags(dialog.windowFlags() | Qt.WindowMinimizeButtonHint | Qt.WindowMaximizeButtonHint)\n dlg_layout = QVBoxLayout(dialog)\n\n dlg_layout.addWidget(QLabel(\"请选择要查看/下载的推荐模型:\"))\n\n combo = QComboBox()\n for m in recommended:\n combo.addItem(\n f\"{m.get('display_name', '')} ({m.get('vendor', '')}, ~{m.get('estimated_disk_size', '')})\",\n m.get(\"id\"),\n )\n dlg_layout.addWidget(combo)\n\n # 模型信息展示\n info_text = QTextEdit()\n info_text.setReadOnly(True)\n info_text.setMaximumHeight(180)\n\n def _update_info():\n idx = combo.currentIndex()\n if idx < 0:\n return\n m = recommended[idx]\n lines = [\n f\"名称: {m.get('display_name', '')}\",\n f\"厂商: {m.get('vendor', '')} | 参数量: {m.get('size_class', '')} | 磁盘: ~{m.get('estimated_disk_size', '')}\",\n f\"硬件要求: {m.get('recommended_hardware', '')}\",\n f\"许可证: {m.get('license_name', '')}\",\n f\"许可链接: {m.get('license_url', '')}\",\n f\"官网: {m.get('official_url', '')}\",\n \"\",\n \"下载方式:\",\n ]\n for dm in m.get(\"download_methods\", []):\n t = dm.get(\"type\", \"\")\n if t == \"ollama\":\n lines.append(f\" • Ollama: ollama pull {dm.get('model', '')}\")\n elif t == \"direct_gguf\":\n lines.append(f\" • GGUF 直链: {dm.get('filename', '')}\")\n elif t == \"external_link\":\n lines.append(f\" • {dm.get('label', '')}: {dm.get('model_id', '')}\")\n info_text.setPlainText(\"\\n\".join(lines))\n\n combo.currentIndexChanged.connect(_update_info)\n _update_info()\n dlg_layout.addWidget(info_text)\n\n # 三个许可证确认勾选框\n dlg_layout.addWidget(QLabel(\"<b>使用前请确认:</b>\"))\n cb1 = QCheckBox(\"我已阅读并同意该模型的许可证条款\")\n cb2 = QCheckBox(\"我理解商业使用需自行确认授权\")\n cb3 = QCheckBox(\"我理解本项目不随包分发模型权重\")\n dlg_layout.addWidget(cb1)\n dlg_layout.addWidget(cb2)\n dlg_layout.addWidget(cb3)\n\n # 按钮行\n btn_row = QHBoxLayout()\n pull_btn = QPushButton(\"Ollama 拉取\"); pull_btn.setIcon(get_icon(\"download\"))\n pull_btn.setEnabled(False)\n select_btn = QPushButton(\"选择并填入配置\"); select_btn.setIcon(get_icon(\"chip\"))\n cancel_btn = QPushButton(\"取消\"); cancel_btn.setIcon(get_icon(\"settings\"))\n\n def _on_check_changed():\n all_checked = cb1.isChecked() and cb2.isChecked() and cb3.isChecked()\n pull_btn.setEnabled(all_checked)\n\n cb1.toggled.connect(lambda _: _on_check_changed())\n cb2.toggled.connect(lambda _: _on_check_changed())\n cb3.toggled.connect(lambda _: _on_check_changed())\n\n def _do_pull():\n idx = combo.currentIndex()\n if idx >= 0:\n dialog.accept()\n self._on_ollama_pull_model(recommended[idx])\n\n def _do_select():\n idx = combo.currentIndex()\n if idx >= 0:\n dialog.accept()\n self._on_select_recommended_model(recommended[idx])\n\n pull_btn.clicked.connect(_do_pull)\n select_btn.clicked.connect(_do_select)\n cancel_btn.clicked.connect(dialog.reject)\n\n btn_row.addWidget(pull_btn)\n btn_row.addWidget(select_btn)\n btn_row.addStretch(1)\n btn_row.addWidget(cancel_btn)\n dlg_layout.addLayout(btn_row)\n\n dialog.exec()\n\n def _fetch_local_models(self) -> None:\n from .local_model_manager import LocalModelManager\n service = self.local_service_combo.currentText()\n url = self.local_base_url.text()\n self.local_status.setText(\"状态:正在获取...\")\n try:\n models = LocalModelManager.fetch_model_list(service, url)\n self.local_model_list.clear()\n if models:\n self.local_model_list.addItems(models)\n self.local_status.setText(f\"状态:成功获取 {len(models)} 个模型\")\n else:\n self.local_status.setText(\"状态:未获取到模型列表,请检查服务或手动输入模型名\")\n except Exception as e:\n self.local_status.setText(f\"状态:获取失败 ({str(e)})\")\n\n def _test_local_service(self) -> None:\n from .local_model_manager import LocalModelManager\n service = self.local_service_combo.currentText()\n url = self.local_base_url.text()\n self.local_status.setText(\"状态:正在连接...\")\n ok, msg = LocalModelManager.test_connection(service, url)\n self.local_status.setText(f\"状态:{msg}\")\n if ok:\n self.local_status.setStyleSheet(\"color: green;\")\n else:\n self.local_status.setStyleSheet(\"color: red;\")\n\n def _save_local_model_settings(self) -> str | None:\n service = self.local_service_combo.currentText()\n url = self.local_base_url.text().strip()\n model = self.local_model_list.currentText().strip()\n\n if not url or not model:\n self.local_status.setText(\"状态:请填写地址和模型名称\")\n self.local_status.setStyleSheet(\"color: red;\")\n return\n\n provider = ProviderMeta.service_label_to_key(service)\n\n self.settings_manager.save_local_model_profile(\n provider=provider,\n base_url=url,\n model=model,\n service_label=service,\n )\n target_id = ProviderMeta.make_profile_id(provider, model)\n self.local_status.setText(f\"状态:已保存 {provider} → {model};点击「设为当前模型」后才会验证并生效。\")\n self.local_status.setStyleSheet(\"color: green;\")\n self._refresh_active_status()\n self._refresh_profile_switcher()\n return target_id\n\n def _refresh_active_status(self) -> None:\n \"\"\"刷新顶部文本模型状态标签。\"\"\"\n api_config = self.settings_manager.load_api_config()\n local_mode = bool(api_config.get(\"local_mode\", False))\n profiles_data = self.settings_manager.load_model_profiles()\n active_id = profiles_data.get(\"active_text_profile_id\", \"\")\n profiles = profiles_data.get(\"profiles\", [])\n active_profile = next((p for p in profiles if p.get(\"id\") == active_id), None)\n\n if not active_profile:\n self.active_profile_status.setText(\"当前文本模型:无;云端未连接。\")\n self.active_profile_status.setStyleSheet(\n \"background: #f8d7da; border: 1px solid #f5c6cb; padding: 8px; margin-bottom: 10px; color: #721c24;\"\n )\n return\n\n name = active_profile.get(\"name\", active_id)\n model = active_profile.get(\"model\", \"\")\n source = active_profile.get(\"source\", \"cloud\")\n source_label = \"本地\" if source == \"local\" else \"云端\"\n\n if local_mode:\n text = (\n f\"当前文本模型:{name} ({model}) [{source_label}];\"\n \"本地离线模式已开启,云端服务商当前不会实际调用。\"\n )\n self.active_profile_status.setText(text)\n self.active_profile_status.setStyleSheet(\n \"background: #fff3cd; border: 1px solid #ffeeba; padding: 8px; margin-bottom: 10px; color: #856404;\"\n )\n return\n\n text = f\"当前文本模型:{name} ({model}) [{source_label}];上次切换通过,实时连通性请看下方「测试连接」。\"\n self.active_profile_status.setText(text)\n self.active_profile_status.setStyleSheet(\n \"background: #e7f1ff; border: 1px solid #b6d4fe; padding: 8px; margin-bottom: 10px; color: #084298;\"\n )\n\n def _activate_cloud_profile(self) -> None:\n \"\"\"保存云端 API 设置并切换为当前生效模型。\"\"\"\n target_id = self._save_api_settings(notify=False, reload_client=False)\n provider = self._current_provider_key()\n self._do_switch_profile(target_id, f\"云端 {provider}\", self.api_result)\n\n def _activate_local_profile(self) -> None:\n \"\"\"保存本地模型设置并切换为当前生效模型。\"\"\"\n target_id = self._save_local_model_settings()\n if not target_id:\n return\n\n service = self.local_service_combo.currentText()\n model = self.local_model_list.currentText().strip()\n\n self._do_switch_profile(target_id, f\"本地 {service} → {model}\", self.local_status)\n\n def _do_switch_profile(self, target_id: str, label: str, status_label: QLabel | None = None) -> bool:\n \"\"\"执行模型切换,失败时回退。\"\"\"\n target_status = status_label or self.local_status\n ok, msg = self.settings_manager.activate_text_profile(target_id)\n\n if ok:\n self._refresh_active_status()\n self._refresh_profile_switcher()\n self.local_mode_input.setChecked(False)\n self.controller.chat_client.reload()\n target_status.setText(f\"状态:已切换至 {label} ✓ 已生效\")\n target_status.setStyleSheet(\"color: green;\")\n return True\n else:\n self._refresh_active_status()\n self._refresh_profile_switcher()\n target_status.setText(f\"状态:切换失败 ({msg}) — 当前生效模型未改变\")\n target_status.setStyleSheet(\"color: red;\")\n return False\n\n def _build_pet_tab(self) -> None:\n tab = QWidget()\n layout = QVBoxLayout(tab)\n form = QFormLayout()\n app_config = self.settings_manager.load_app_config()\n pet = app_config.get(\"pet\", {})\n window = app_config.get(\"window\", {})\n\n self.pet_size = QSpinBox()\n self.pet_size.setRange(int(pet.get(\"min_pet_height\", 80)), int(pet.get(\"max_pet_height\", 160)))\n self.pet_size.setValue(int(pet.get(\"pet_height\", 96)))\n self.show_input = QCheckBox(\"显示输入框\")\n self.show_input.setChecked(bool(window.get(\"show_input\", True)))\n self.always_on_top = QCheckBox(\"保持置顶\")\n self.always_on_top.setChecked(bool(window.get(\"always_on_top\", True)))\n self.opacity = QSpinBox()\n self.opacity.setRange(30, 100)\n self.opacity.setSuffix(\"%\")\n self.opacity.setValue(int(window.get(\"opacity_percent\", 100)))\n self.idle_chat = QCheckBox(\"启用闲聊\")\n self.idle_chat.setChecked(bool(app_config.get(\"idle_chat_enabled\", False)))\n self.idle_minutes = QSpinBox()\n self.idle_minutes.setRange(1, 240)\n self.idle_minutes.setValue(int(app_config.get(\"idle_chat_minutes\", 10)))\n self.idle_behavior = QCheckBox(\"启用空闲小动作\")\n self.idle_behavior.setChecked(bool(app_config.get(\"idle_behavior_enabled\", False)))\n self.idle_behavior_seconds = QSpinBox()\n self.idle_behavior_seconds.setRange(600, 3600)\n self.idle_behavior_seconds.setSuffix(\" 秒\")\n self.idle_behavior_seconds.setValue(int(app_config.get(\"idle_behavior_seconds\", 600)))\n self.hourly_chime = QCheckBox(\"整点报时\")\n self.hourly_chime.setChecked(bool(app_config.get(\"hourly_chime_enabled\", False)))\n self.edge_peek = QCheckBox(\"左右边缘趴墙\")\n self.edge_peek.setToolTip(\"开启后,把达妮娅拖到屏幕左右边缘时会半隐藏趴在边上;默认关闭,避免首次运行时自己动。\")\n self.edge_peek.setChecked(bool(pet.get(\"edge_peek_enabled\", False)))\n self.day_night = QCheckBox(\"昼夜作息\")\n self.day_night.setChecked(bool(app_config.get(\"day_night_enabled\", True)))\n self.renderer_combo = QComboBox()\n self.renderer_combo.addItem(\"PNG 帧渲染\", \"png\")\n self.renderer_combo.addItem(\"形变混合(呼吸/眨眼/交叉淡入)\", \"morph_blend\")\n self.renderer_combo.addItem(\"Live2D 预览(缺资源自动回退)\", \"live2d_preview\")\n current_renderer = app_config.get(\"renderer_type\", \"png\")\n idx = self.renderer_combo.findData(current_renderer)\n if idx >= 0:\n self.renderer_combo.setCurrentIndex(idx)\n\n form.addRow(\"桌宠大小\", self.pet_size)\n form.addRow(\"输入框\", self.show_input)\n form.addRow(\"置顶\", self.always_on_top)\n form.addRow(\"透明度\", self.opacity)\n form.addRow(\"渲染器\", self.renderer_combo)\n form.addRow(\"闲聊\", self.idle_chat)\n form.addRow(\"闲聊间隔\", self.idle_minutes)\n form.addRow(\"空闲小动作\", self.idle_behavior)\n form.addRow(\"小动作等待\", self.idle_behavior_seconds)\n form.addRow(\"整点报时\", self.hourly_chime)\n form.addRow(\"趴墙\", self.edge_peek)\n form.addRow(\"昼夜作息\", self.day_night)\n layout.addLayout(form)\n save = QPushButton(\"保存并尽量即时生效\"); save.setIcon(get_icon(\"save\"))\n save.clicked.connect(self._save_pet_settings)\n self.pet_timer_hint = QLabel(\"部分定时器配置需重启后生效。\")\n self.pet_timer_hint.setWordWrap(True)\n self.pet_result = QLabel(\"\")\n self.pet_result.setWordWrap(True)\n layout.addWidget(save, alignment=Qt.AlignmentFlag.AlignLeft)\n layout.addWidget(self.pet_timer_hint)\n layout.addWidget(self.pet_result)\n layout.addStretch(1)\n self._register_tab(\"pet\", tab, \"internet\", \"桌宠\")\n\n def _build_actions_tab(self) -> None:\n \"\"\"已合并到 _build_character_resources_tab\"\"\"\n pass\n\n def _build_character_tab(self) -> None:\n \"\"\"已合并到 _build_character_resources_tab\"\"\"\n pass\n\n def _build_character_resources_tab(self) -> None:\n tab = QWidget()\n layout = QVBoxLayout(tab)\n\n # 最顶部的角色信息区域\n char_group = QGroupBox(\"当前角色信息\")\n char_layout = QFormLayout(char_group)\n\n self.char_id_label = QLabel()\n self.char_path_label = QLabel()\n self.char_status_label = QLabel()\n self.char_status_label.setWordWrap(True)\n\n # 角色选择下拉框\n self.char_selector = QComboBox()\n\n btn_char_row = QHBoxLayout()\n switch_char_btn = QPushButton(\"切换并加载角色\")\n switch_char_btn.clicked.connect(self._switch_character)\n reload_char_btn = QPushButton(\"重新加载当前角色\")\n reload_char_btn.clicked.connect(self._reload_character)\n btn_char_row.addWidget(switch_char_btn)\n btn_char_row.addWidget(reload_char_btn)\n btn_char_row.addStretch(1)\n\n char_layout.addRow(\"当前角色\", self.char_id_label)\n char_layout.addRow(\"校验状态\", self.char_status_label)\n char_layout.addRow(\"切换角色\", self.char_selector)\n char_layout.addRow(\"\", btn_char_row)\n\n layout.addWidget(char_group)\n\n # 上半部分:动作资源\n self.actions_group = QGroupBox(\"动作资源\")\n actions_layout = QVBoxLayout(self.actions_group)\n self.action_status = QTextEdit()\n self.action_status.setReadOnly(True)\n self.action_combo = QComboBox()\n self.action_combo.addItems([\"idle\", \"talk\", \"clicked\", \"drag\", \"sleep\", \"happy\", \"remind\", \"soft_idle\", \"close_idle\", \"bubble\", \"look_away\"])\n btn_row = QHBoxLayout()\n reload_btn = QPushButton(\"重载动作资源\"); reload_btn.setIcon(get_icon(\"refresh\"))\n test_btn = QPushButton(\"测试动作\"); test_btn.setIcon(get_icon(\"chip\"))\n self.action_details_btn = QPushButton(\"显示原始详情\")\n self.action_details_btn.setIcon(get_icon(\"info\"))\n reload_btn.clicked.connect(self._reload_actions)\n test_btn.clicked.connect(self._test_action)\n self.action_details_btn.clicked.connect(self._toggle_action_details)\n btn_row.addWidget(self.action_combo)\n btn_row.addWidget(test_btn)\n btn_row.addWidget(reload_btn)\n btn_row.addWidget(self.action_details_btn)\n btn_row.addStretch(1)\n self.action_raw_details = QTextEdit()\n self.action_raw_details.setReadOnly(True)\n self.action_raw_details.setVisible(False)\n self.action_raw_details.setMaximumHeight(160)\n actions_layout.addLayout(btn_row)\n actions_layout.addWidget(self.action_status)\n actions_layout.addWidget(self.action_raw_details)\n layout.addWidget(self.actions_group)\n\n # 下半部分:角色包编辑器\n self.pack_group = QGroupBox(\"角色包编辑器\")\n pack_layout = QVBoxLayout(self.pack_group)\n self.character_status = QLabel(\"\")\n self.character_status.setWordWrap(True)\n self.pack_file_combo = QComboBox()\n self.pack_file_combo.addItems([\"character.yaml\", \"speech.yaml\", \"relationship.yaml\", \"events.yaml\", \"lore.md\", \"lore_index.yaml\", \"actions.yaml\", \"story.yaml\"])\n self.pack_file_combo.currentTextChanged.connect(self._load_pack_file)\n self.pack_summary_text = QTextEdit()\n self.pack_summary_text.setReadOnly(True)\n self.pack_summary_text.setMaximumHeight(120)\n self.pack_editor_text = QTextEdit()\n self.pack_editor_text.setVisible(False)\n pack_btn_row = QHBoxLayout()\n save = QPushButton(\"备份并保存 YAML\"); save.setIcon(get_icon(\"save\"))\n validate = QPushButton(\"重新校验\"); validate.setIcon(get_icon(\"info\"))\n open_file = QPushButton(\"打开文件\"); open_file.setIcon(get_icon(\"document\"))\n self.pack_raw_toggle_btn = QPushButton(\"显示原始文件\")\n self.pack_raw_toggle_btn.setIcon(get_icon(\"info\"))\n save.clicked.connect(self._save_pack_file)\n validate.clicked.connect(self._refresh_character_status)\n open_file.clicked.connect(self._open_pack_file)\n self.pack_raw_toggle_btn.clicked.connect(self._toggle_pack_raw_file)\n pack_btn_row.addWidget(self.pack_file_combo)\n pack_btn_row.addWidget(save)\n pack_btn_row.addWidget(validate)\n pack_btn_row.addWidget(open_file)\n pack_btn_row.addWidget(self.pack_raw_toggle_btn)\n pack_btn_row.addStretch(1)\n pack_layout.addWidget(self.character_status)\n pack_layout.addLayout(pack_btn_row)\n pack_layout.addWidget(self.pack_summary_text)\n pack_layout.addWidget(self.pack_editor_text)\n layout.addWidget(self.pack_group)\n\n self._register_tab(\"character_resources\", tab, \"document\", \"角色与资源\")\n self._refresh_action_status()\n self._refresh_character_status()\n self._load_pack_file(self.pack_file_combo.currentText())\n self._refresh_character_list()\n self._refresh_char_info()\n\n def _refresh_character_list(self) -> None:\n self.char_selector.clear()\n from core.character_loader import discover_character_ids\n self.char_selector.addItems(discover_character_ids())\n curr = self.controller.app_config.get(\"current_character\", \"daniya\")\n index = self.char_selector.findText(curr)\n if index >= 0:\n self.char_selector.setCurrentIndex(index)\n\n def _refresh_char_info(self) -> None:\n adapter = self.controller.daniya_adapter\n char_pack = adapter.character_pack\n self.char_id_label.setText(char_pack.character_id)\n self.char_path_label.setText(str(char_pack.root))\n\n if adapter.load_errors:\n self.char_status_label.setText(\"异常 (已回退)\\n\" + \"\\n\".join(adapter.load_errors))\n self.char_status_label.setStyleSheet(\"color: red;\")\n else:\n self.char_status_label.setText(\"正常\")\n self.char_status_label.setStyleSheet(\"color: green;\")\n\n def _switch_character(self) -> None:\n target = self.char_selector.currentText()\n if not target:\n return\n QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)\n try:\n self.controller.reload_character(target)\n self._refresh_char_info()\n self._refresh_character_status()\n self._refresh_action_status()\n self._load_pack_file(self.pack_file_combo.currentText())\n index = self.char_selector.findText(self.controller.daniya_adapter.character_pack.character_id)\n if index >= 0:\n self.char_selector.setCurrentIndex(index)\n finally:\n QApplication.restoreOverrideCursor()\n\n def _reload_character(self) -> None:\n QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)\n try:\n self.controller.reload_character()\n self._refresh_char_info()\n self._refresh_character_status()\n self._refresh_action_status()\n self._load_pack_file(self.pack_file_combo.currentText())\n index = self.char_selector.findText(self.controller.daniya_adapter.character_pack.character_id)\n if index >= 0:\n self.char_selector.setCurrentIndex(index)\n finally:\n QApplication.restoreOverrideCursor()\n\n def _build_relationship_tab(self) -> None:\n \"\"\"已合并到 _build_relationship_events_tab\"\"\"\n pass\n\n def _build_events_tab(self) -> None:\n \"\"\"已合并到 _build_relationship_events_tab\"\"\"\n pass\n\n def _build_relationship_events_tab(self) -> None:\n tab = QWidget()\n layout = QVBoxLayout(tab)\n\n profile = self.controller.profile_manager.load()\n profile_group = QGroupBox(\"用户档案\")\n profile_layout = QFormLayout(profile_group)\n self.profile_user_name = QLineEdit(profile.get(\"user_name\", \"你\"))\n self.profile_birthday = QLineEdit(profile.get(\"birthday\", \"\"))\n self.profile_birthday.setPlaceholderText(\"月-日,例如 03-14\")\n self.profile_birthday.setMaxLength(5)\n self.profile_relationship = QLineEdit(profile.get(\"relationship\", \"陪伴角色与用户\"))\n self.profile_style = QLineEdit(profile.get(\"style\", \"\"))\n profile_layout.addRow(\"用户称呼\", self.profile_user_name)\n profile_layout.addRow(\"生日(月-日,可留空)\", self.profile_birthday)\n profile_layout.addRow(\"关系设定\", self.profile_relationship)\n profile_layout.addRow(\"期望风格\", self.profile_style)\n profile_hint = QLabel(\"档案仅保存在本地。\")\n profile_hint.setWordWrap(True)\n profile_layout.addRow(\"\", profile_hint)\n profile_save = QPushButton(\"保存用户档案\")\n profile_save.setIcon(get_icon(\"save\"))\n profile_save.clicked.connect(self._save_profile_settings)\n self.profile_status = QLabel()\n self.profile_status.setWordWrap(True)\n profile_layout.addRow(\"\", profile_save)\n profile_layout.addRow(\"\", self.profile_status)\n layout.addWidget(profile_group)\n\n self.relationship_group = QGroupBox(\"关系状态\")\n rel_layout = QVBoxLayout(self.relationship_group)\n self.relationship_text = QTextEdit()\n self.relationship_text.setReadOnly(True)\n rel_buttons = QHBoxLayout()\n refresh = QPushButton(\"刷新\"); refresh.setIcon(get_icon(\"refresh\"))\n export = QPushButton(\"导出关系状态\"); export.setIcon(get_icon(\"upload\"))\n reset = QPushButton(\"备份后重置\"); reset.setIcon(get_icon(\"protect\"))\n self.relationship_raw_toggle_btn = QPushButton(\"显示原始数据\")\n self.relationship_raw_toggle_btn.setIcon(get_icon(\"info\"))\n refresh.clicked.connect(self._refresh_relationship)\n export.clicked.connect(self._export_relationship)\n reset.clicked.connect(self._reset_relationship)\n self.relationship_raw_toggle_btn.clicked.connect(self._toggle_relationship_raw)\n rel_buttons.addWidget(refresh)\n rel_buttons.addWidget(export)\n rel_buttons.addWidget(reset)\n rel_buttons.addWidget(self.relationship_raw_toggle_btn)\n rel_buttons.addStretch(1)\n self.relationship_raw_text = QTextEdit()\n self.relationship_raw_text.setReadOnly(True)\n self.relationship_raw_text.setVisible(False)\n self.relationship_raw_text.setMaximumHeight(160)\n rel_layout.addLayout(rel_buttons)\n rel_layout.addWidget(self.relationship_text)\n rel_layout.addWidget(self.relationship_raw_text)\n layout.addWidget(self.relationship_group)\n\n memory_group = QGroupBox(\"记忆备忘录\")\n memory_layout = QVBoxLayout(memory_group)\n self.memory_text = QTextEdit()\n self.memory_text.setReadOnly(True)\n memory_buttons = QHBoxLayout()\n memory_refresh = QPushButton(\"刷新记忆\"); memory_refresh.setIcon(get_icon(\"refresh\"))\n memory_refresh.clicked.connect(self._refresh_memory)\n self.memory_note_input = QLineEdit()\n self.memory_note_input.setPlaceholderText(\"写一条希望达妮娅记住的事\")\n memory_add = QPushButton(\"记住这条\"); memory_add.setIcon(get_icon(\"save\"))\n memory_add.clicked.connect(self._add_memory_note)\n memory_clear = QPushButton(\"清空记忆\"); memory_clear.setIcon(get_icon(\"settings\"))\n memory_clear.clicked.connect(self._clear_memory)\n memory_buttons.addWidget(memory_refresh)\n memory_buttons.addWidget(self.memory_note_input, 1)\n memory_buttons.addWidget(memory_add)\n memory_buttons.addWidget(memory_clear)\n memory_layout.addLayout(memory_buttons)\n memory_layout.addWidget(self.memory_text)\n layout.addWidget(memory_group)\n\n self.events_group = QGroupBox(\"事件日志\")\n evt_layout = QVBoxLayout(self.events_group)\n self.events_text = QTextEdit()\n self.events_text.setReadOnly(True)\n evt_buttons = QHBoxLayout()\n evt_refresh = QPushButton(\"刷新事件\"); evt_refresh.setIcon(get_icon(\"refresh\"))\n self.events_raw_toggle_btn = QPushButton(\"显示原始日志\")\n self.events_raw_toggle_btn.setIcon(get_icon(\"info\"))\n evt_refresh.clicked.connect(self._refresh_events)\n self.events_raw_toggle_btn.clicked.connect(self._toggle_events_raw)\n evt_buttons.addWidget(evt_refresh)\n evt_buttons.addWidget(self.events_raw_toggle_btn)\n evt_buttons.addStretch(1)\n self.events_raw_text = QTextEdit()\n self.events_raw_text.setReadOnly(True)\n self.events_raw_text.setVisible(False)\n self.events_raw_text.setMaximumHeight(180)\n evt_layout.addLayout(evt_buttons)\n evt_layout.addWidget(self.events_text)\n evt_layout.addWidget(self.events_raw_text)\n layout.addWidget(self.events_group)\n\n self._register_tab(\"relationship_events\", tab, \"download\", \"关系与事件\")\n self._lazy_tab_loaders[\"relationship_events\"] = self._refresh_relationship_bundle\n\n def _build_memory_diary_tab(self) -> None:\n config = self.settings_manager.load_app_config()\n memory_config = config.get(\"memory_features\", {})\n if not isinstance(memory_config, dict):\n memory_config = {}\n\n tab = QWidget()\n layout = QVBoxLayout(tab)\n\n long_term_group = QGroupBox(\"对话级长期记忆\")\n long_term_layout = QVBoxLayout(long_term_group)\n long_term_form = QFormLayout()\n self.long_term_memory_enabled = QCheckBox(\n \"启用本地长期记忆(仅记录启用后的新对话)\"\n )\n self.long_term_memory_enabled.setChecked(\n bool(memory_config.get(\"long_term_enabled\", False))\n )\n self.long_term_memory_top_k = QSpinBox()\n self.long_term_memory_top_k.setRange(1, 10)\n self.long_term_memory_top_k.setValue(\n int(memory_config.get(\"long_term_top_k\", 3))\n )\n self.long_term_memory_max_entries = QSpinBox()\n self.long_term_memory_max_entries.setRange(20, 2000)\n self.long_term_memory_max_entries.setValue(\n int(memory_config.get(\"long_term_max_entries\", 500))\n )\n long_term_form.addRow(\"\", self.long_term_memory_enabled)\n long_term_form.addRow(\"每次检索数量\", self.long_term_memory_top_k)\n long_term_form.addRow(\"本机最多保存\", self.long_term_memory_max_entries)\n long_term_layout.addLayout(long_term_form)\n long_term_hint = QLabel(\"记忆保存在本地,不会存入敏感信息。\")\n long_term_hint.setWordWrap(True)\n long_term_layout.addWidget(long_term_hint)\n long_term_buttons = QHBoxLayout()\n save_memory = QPushButton(\"保存设置\")\n save_memory.setIcon(get_icon(\"save\"))\n save_memory.clicked.connect(self._save_memory_diary_settings)\n refresh_memory = QPushButton(\"刷新记录\")\n refresh_memory.setIcon(get_icon(\"refresh\"))\n refresh_memory.clicked.connect(self._refresh_memory_diary)\n clear_long_term = QPushButton(\"清空长期记忆\")\n clear_long_term.setIcon(get_icon(\"protect\"))\n clear_long_term.clicked.connect(self._clear_long_term_memory)\n long_term_buttons.addWidget(save_memory)\n long_term_buttons.addWidget(refresh_memory)\n long_term_buttons.addWidget(clear_long_term)\n long_term_buttons.addStretch(1)\n long_term_layout.addLayout(long_term_buttons)\n self.long_term_memory_text = QTextEdit()\n self.long_term_memory_text.setReadOnly(True)\n long_term_layout.addWidget(self.long_term_memory_text)\n layout.addWidget(long_term_group)\n\n diary_group = QGroupBox(\"观察日记\")\n diary_layout = QVBoxLayout(diary_group)\n diary_form = QFormLayout()\n self.observation_diary_enabled = QCheckBox(\n \"允许手动把近期互动发送给当前文本模型生成日记\"\n )\n self.observation_diary_enabled.setChecked(\n bool(memory_config.get(\"diary_enabled\", False))\n )\n self.observation_diary_days = QSpinBox()\n self.observation_diary_days.setRange(1, 30)\n self.observation_diary_days.setSuffix(\" 天\")\n self.observation_diary_days.setValue(\n int(memory_config.get(\"diary_days\", 3))\n )\n diary_form.addRow(\"\", self.observation_diary_enabled)\n diary_form.addRow(\"读取最近互动\", self.observation_diary_days)\n diary_layout.addLayout(diary_form)\n diary_hint = QLabel(\"需手动触发,生成前会二次确认。\")\n diary_hint.setWordWrap(True)\n diary_layout.addWidget(diary_hint)\n diary_buttons = QHBoxLayout()\n generate_diary = QPushButton(\"生成观察日记\")\n generate_diary.setIcon(get_icon(\"document\"))\n generate_diary.clicked.connect(self._generate_observation_diary)\n clear_diary = QPushButton(\"清空观察日记\")\n clear_diary.setIcon(get_icon(\"protect\"))\n clear_diary.clicked.connect(self._clear_observation_diary)\n diary_buttons.addWidget(generate_diary)\n diary_buttons.addWidget(clear_diary)\n diary_buttons.addStretch(1)\n diary_layout.addLayout(diary_buttons)\n self.observation_diary_status = QLabel()\n self.observation_diary_status.setWordWrap(True)\n diary_layout.addWidget(self.observation_diary_status)\n self.observation_diary_text = QTextEdit()\n self.observation_diary_text.setReadOnly(True)\n diary_layout.addWidget(self.observation_diary_text)\n layout.addWidget(diary_group)\n\n self._register_tab(\"memory_diary\", tab, \"document\", \"记忆与日记\")\n self._lazy_tab_loaders[\"memory_diary\"] = self._refresh_memory_diary\n\n def _build_system_tab(self) -> None:\n tab = QWidget()\n layout = QVBoxLayout(tab)\n\n data_group = QGroupBox(\"数据管理\")\n data_layout = QVBoxLayout(data_group)\n self.data_text = QTextEdit()\n self.data_text.setReadOnly(True)\n data_buttons = QHBoxLayout()\n refresh_data = QPushButton(\"刷新数据状态\"); refresh_data.setIcon(get_icon(\"refresh\"))\n backup = QPushButton(\"导出备份\"); backup.setIcon(get_icon(\"save\"))\n open_dir = QPushButton(\"打开数据目录\"); open_dir.setIcon(get_icon(\"laptop\"))\n self.data_raw_toggle_btn = QPushButton(\"显示原始详情\")\n self.data_raw_toggle_btn.setIcon(get_icon(\"info\"))\n refresh_data.clicked.connect(self._refresh_data)\n backup.clicked.connect(self._backup_data)\n open_dir.clicked.connect(lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(str(self.relationship_viewer.data_dir))))\n self.data_raw_toggle_btn.clicked.connect(self._toggle_data_raw)\n data_buttons.addWidget(refresh_data)\n data_buttons.addWidget(backup)\n data_buttons.addWidget(open_dir)\n data_buttons.addWidget(self.data_raw_toggle_btn)\n data_buttons.addStretch(1)\n self.data_raw_text = QTextEdit()\n self.data_raw_text.setReadOnly(True)\n self.data_raw_text.setVisible(False)\n self.data_raw_text.setMaximumHeight(160)\n data_layout.addLayout(data_buttons)\n data_layout.addWidget(self.data_text)\n data_layout.addWidget(self.data_raw_text)\n layout.addWidget(data_group)\n\n onboarding_group = QGroupBox(\"首次启动向导\")\n onboarding_layout = QVBoxLayout(onboarding_group)\n onboarding_layout.addWidget(QLabel(\"重新查看新手流程和初始配置。\"))\n open_wizard = QPushButton(\"重新打开首次启动向导\"); open_wizard.setIcon(get_icon(\"info\"))\n open_wizard.clicked.connect(self._open_first_run_wizard)\n onboarding_layout.addWidget(open_wizard, alignment=Qt.AlignmentFlag.AlignLeft)\n layout.addWidget(onboarding_group)\n\n # 使用帮助(可折叠)\n help_group = QGroupBox(\"使用帮助\")\n help_layout = QVBoxLayout(help_group)\n self.help_toggle_btn = QPushButton(\"展开使用帮助 ▼\"); self.help_toggle_btn.setIcon(get_icon(\"info\"))\n self.help_toggle_btn.setStyleSheet(\"QPushButton { text-align: left; font-size: 12px; color: #0366d6; border: none; }\")\n self.help_toggle_btn.clicked.connect(self._toggle_help)\n help_layout.addWidget(self.help_toggle_btn)\n\n self.help_content = QWidget()\n self.help_content.setVisible(False)\n help_content_layout = QVBoxLayout(self.help_content)\n guide_text = QTextEdit()\n guide_text.setReadOnly(True)\n guide_text.setMaximumHeight(360)\n guide_text.setMarkdown(\n \"### 模型配置\\n\\n\"\n \"1. **保存** 只保存服务商、地址、模型、认证方式和密钥,不立即切换当前模型。\\n\"\n \"2. **设为当前模型** 会先验证连接;验证失败不会切换,也不会覆盖上一套可用模型。\\n\"\n \"3. 密钥行上方显示已保存的脱敏密钥;输入框只用于填写新密钥,留空保存会沿用旧密钥。\\n\"\n \"4. 认证方式要和服务商一致:多数服务商使用默认方式即可。\\n\"\n \"5. 文本模型、语音、图像、视频是独立槽位;切换文本模型不会改动语音或图像配置。\\n\\n\"\n \"### 本地模型\\n\\n\"\n \"6. 本地服务要先启动,再拉取模型列表或手动填模型名。\\n\"\n \"7. 本地离线模式只表示云端不可用时允许回退,本地模型本身仍需 **测试服务连接** 和 **设为当前模型**。\\n\\n\"\n \"### 切换与历史\\n\\n\"\n \"8. 上方 **文本模型切换** 用于直接切换、启用、停用;历史会保留最近使用过的文本模型。\\n\"\n \"9. 顶部状态只表示当前选择的文本模型;实时连通性以 **测试连接** 结果为准。\\n\\n\"\n \"### 窗口\\n\\n\"\n \"10. 设置中心是独立窗口,可最小化后从任务栏找回;右键角色仍可再次唤起并恢复原窗口。\\n\\n\"\n \"### 角色与资源\\n\\n\"\n \"11. 前台只显示摘要;原始配置文件、日志和路径放在 **显示原始** 按钮里。\\n\"\n \"12. 可编辑文件只有角色、语言、关系、事件四类配置;剧情、故事、动作默认只读。\\n\\n\"\n \"### 关系与事件\\n\\n\"\n \"13. 关系数值不是攻略条,而是达妮娅防御、信任、共情负荷和留下倾向的运行状态。\\n\"\n \"14. 事件日志记录点击、拖拽、回归、情绪与剧情触发;前台翻译成可读摘要,原始日志仅用于排查。\\n\\n\"\n \"### 数据安全\\n\\n\"\n \"15. 敏感数据文件夹不应提交到版本管理;导出备份会放在本地备份目录。\"\n )\n help_content_layout.addWidget(guide_text)\n help_layout.addWidget(self.help_content)\n layout.addWidget(help_group)\n\n self._register_tab(\"system\", tab, \"settings\", \"系统\")\n self._lazy_tab_loaders[\"system\"] = self._refresh_data\n\n def _build_reminder_tab(self) -> None:\n config = self.settings_manager.load_app_config()\n tab = QWidget()\n layout = QVBoxLayout(tab)\n description = QLabel(\"\")\n description.setWordWrap(True)\n layout.addWidget(description)\n form = QFormLayout()\n self.reminder_enabled = QCheckBox(\"启用到期提醒\")\n self.reminder_enabled.setChecked(bool(config.get(\"reminder_enabled\", True)))\n self.natural_reminder_enabled = QCheckBox(\"启用自然语言提醒识别\")\n self.natural_reminder_enabled.setChecked(bool(config.get(\"natural_reminder_enabled\", True)))\n form.addRow(\"提醒服务\", self.reminder_enabled)\n form.addRow(\"自然语言\", self.natural_reminder_enabled)\n layout.addLayout(form)\n self.reminder_status = QLabel()\n self.reminder_status.setWordWrap(True)\n buttons = QHBoxLayout()\n save = QPushButton(\"保存提醒设置\"); save.setIcon(get_icon(\"save\"))\n reset = QPushButton(\"恢复提醒默认\"); reset.setIcon(get_icon(\"refresh\"))\n save.clicked.connect(self._save_reminder_settings)\n reset.clicked.connect(self._reset_reminder_defaults)\n buttons.addWidget(save)\n buttons.addWidget(reset)\n buttons.addStretch(1)\n layout.addLayout(buttons)\n layout.addWidget(self.reminder_status)\n layout.addStretch(1)\n self._refresh_reminder_status()\n self._register_tab(\"reminder\", tab, \"remind\", \"提醒\")\n\n def _build_growth_tab(self) -> None:\n config = self.settings_manager.load_app_config()\n growth = config.get(\"growth\", {})\n if not isinstance(growth, dict):\n growth = {}\n tab = QWidget()\n layout = QVBoxLayout(tab)\n description = QLabel(\"纯本地功能,数据不会上传。\")\n description.setWordWrap(True)\n layout.addWidget(description)\n self.growth_enabled = QCheckBox(\"启用本地养成\")\n self.growth_enabled.setChecked(bool(growth.get(\"enabled\", False)))\n self.growth_status = QLabel()\n self.growth_status.setWordWrap(True)\n buttons = QHBoxLayout()\n save = QPushButton(\"保存养成设置\")\n save.setIcon(get_icon(\"save\"))\n open_center = QPushButton(\"打开养成中心\")\n open_center.setIcon(get_icon(\"protect\"))\n reset = QPushButton(\"恢复关闭\")\n reset.setIcon(get_icon(\"refresh\"))\n save.clicked.connect(self._save_growth_settings)\n open_center.clicked.connect(self.controller.open_growth_center)\n reset.clicked.connect(self._reset_growth_settings)\n buttons.addWidget(save)\n buttons.addWidget(open_center)\n buttons.addWidget(reset)\n buttons.addStretch(1)\n layout.addWidget(self.growth_enabled)\n layout.addLayout(buttons)\n layout.addWidget(self.growth_status)\n layout.addStretch(1)\n self._refresh_growth_status()\n self._register_tab(\"growth\", tab, \"chip\", \"养成\")\n\n def _build_environment_tab(self) -> None:\n config = self.settings_manager.load_app_config()\n environment = config.get(\"environment\", {})\n if not isinstance(environment, dict):\n environment = {}\n tab = QWidget()\n layout = QVBoxLayout(tab)\n description = QLabel(\"\")\n description.setWordWrap(True)\n layout.addWidget(description)\n\n weather_group = QGroupBox(\"天气\")\n weather_layout = QVBoxLayout(weather_group)\n weather_header = QHBoxLayout()\n umbrella = QLabel()\n pixmap = QPixmap(str(resource_path(\"assets\", \"icons\", \"weather_umbrella.png\")))\n if not pixmap.isNull():\n umbrella.setPixmap(\n pixmap.scaled(\n 64,\n 64,\n Qt.AspectRatioMode.KeepAspectRatio,\n Qt.TransformationMode.SmoothTransformation,\n )\n )\n weather_header.addWidget(umbrella)\n weather_form = QFormLayout()\n self.weather_enabled = QCheckBox(\"启用低频天气感知\")\n self.weather_enabled.setChecked(bool(environment.get(\"weather_enabled\", False)))\n self.weather_location_configured = QCheckBox(\"坐标已由我确认\")\n self.weather_location_configured.setChecked(\n bool(environment.get(\"weather_location_configured\", False))\n )\n self.weather_location_name = QLineEdit(\n str(environment.get(\"weather_location_name\", \"\"))\n )\n self.weather_location_name.setPlaceholderText(\"可选备注,例如:上海\")\n self.weather_latitude = QDoubleSpinBox()\n self.weather_latitude.setRange(-90.0, 90.0)\n self.weather_latitude.setDecimals(4)\n self.weather_latitude.setValue(float(environment.get(\"weather_latitude\", 0.0)))\n self.weather_longitude = QDoubleSpinBox()\n self.weather_longitude.setRange(-180.0, 180.0)\n self.weather_longitude.setDecimals(4)\n self.weather_longitude.setValue(float(environment.get(\"weather_longitude\", 0.0)))\n self.weather_interval = QSpinBox()\n self.weather_interval.setRange(900, 21600)\n self.weather_interval.setSuffix(\" 秒\")\n self.weather_interval.setValue(\n int(environment.get(\"weather_interval_seconds\", 1800))\n )\n self.weather_notify_rain = QCheckBox(\"下雨时提醒带伞\")\n self.weather_notify_rain.setChecked(\n bool(environment.get(\"weather_notify_rain\", True))\n )\n weather_form.addRow(\"天气服务\", self.weather_enabled)\n weather_form.addRow(\"位置备注\", self.weather_location_name)\n weather_form.addRow(\"纬度\", self.weather_latitude)\n weather_form.addRow(\"经度\", self.weather_longitude)\n weather_form.addRow(\"位置确认\", self.weather_location_configured)\n weather_form.addRow(\"读取间隔\", self.weather_interval)\n weather_form.addRow(\"降雨提醒\", self.weather_notify_rain)\n weather_header.addLayout(weather_form, 1)\n weather_layout.addLayout(weather_header)\n weather_buttons = QHBoxLayout()\n save_weather = QPushButton(\"保存环境设置\")\n save_weather.setIcon(get_icon(\"save\"))\n test_weather = QPushButton(\"读取一次天气\")\n test_weather.setIcon(get_icon(\"refresh\"))\n save_weather.clicked.connect(self._save_environment_settings)\n test_weather.clicked.connect(self._test_weather)\n weather_buttons.addWidget(save_weather)\n weather_buttons.addWidget(test_weather)\n weather_buttons.addStretch(1)\n weather_layout.addLayout(weather_buttons)\n self.weather_result = QLabel(\"尚未读取天气。\")\n self.weather_result.setWordWrap(True)\n weather_layout.addWidget(self.weather_result)\n layout.addWidget(weather_group)\n\n media_group = QGroupBox(\"媒体与音乐\")\n media_layout = QVBoxLayout(media_group)\n self.media_presence_enabled = QCheckBox(\n \"启用播放器进程感知(不读取媒体标题)\"\n )\n self.media_presence_enabled.setChecked(\n bool(environment.get(\"media_presence_enabled\", False))\n )\n self.media_interval = QSpinBox()\n self.media_interval.setRange(30, 3600)\n self.media_interval.setSuffix(\" 秒\")\n self.media_interval.setValue(int(environment.get(\"media_interval_seconds\", 60)))\n media_form = QFormLayout()\n media_form.addRow(\"媒体感知\", self.media_presence_enabled)\n media_form.addRow(\"扫描间隔\", self.media_interval)\n media_layout.addLayout(media_form)\n scan_media = QPushButton(\"扫描一次受支持播放器\")\n scan_media.setIcon(get_icon(\"info\"))\n scan_media.clicked.connect(self._test_media_presence)\n media_layout.addWidget(scan_media, alignment=Qt.AlignmentFlag.AlignLeft)\n self.media_result = QLabel(\"尚未扫描。\")\n self.media_result.setWordWrap(True)\n media_layout.addWidget(self.media_result)\n layout.addWidget(media_group)\n\n ambient_group = QGroupBox(\"随机事件小剧场\")\n ambient_layout = QVBoxLayout(ambient_group)\n self.ambient_events_enabled = QCheckBox(\"启用低频本地小剧场\")\n self.ambient_events_enabled.setChecked(\n bool(environment.get(\"ambient_events_enabled\", False))\n )\n self.ambient_interval = QSpinBox()\n self.ambient_interval.setRange(900, 21600)\n self.ambient_interval.setSuffix(\" 秒\")\n self.ambient_interval.setValue(\n int(environment.get(\"ambient_event_interval_seconds\", 1800))\n )\n ambient_form = QFormLayout()\n ambient_form.addRow(\"小剧场\", self.ambient_events_enabled)\n ambient_form.addRow(\"触发间隔\", self.ambient_interval)\n ambient_layout.addLayout(ambient_form)\n preview = QPushButton(\"试播一条\")\n preview.setIcon(get_icon(\"protect\"))\n preview.clicked.connect(self._preview_ambient_event)\n ambient_layout.addWidget(preview, alignment=Qt.AlignmentFlag.AlignLeft)\n self.ambient_result = QLabel(\"事件内容来自当前角色包。\")\n self.ambient_result.setWordWrap(True)\n ambient_layout.addWidget(self.ambient_result)\n layout.addWidget(ambient_group)\n layout.addStretch(1)\n\n self.controller.weather_manager.snapshot_ready.connect(\n self._show_weather_snapshot\n )\n self.controller.weather_manager.request_failed.connect(\n self._show_weather_error\n )\n self._register_tab(\"environment\", tab, \"settings\", \"环境与内容\")\n\n def _build_file_organizer_tab(self) -> None:\n config = self.settings_manager.load_app_config()\n tab = QWidget()\n layout = QVBoxLayout(tab)\n description = QLabel(\n \"高风险功能,默认关闭。只会在你主动选择源目录和目标目录后生成预览;执行前仍需二次确认。\"\n )\n description.setWordWrap(True)\n layout.addWidget(description)\n form = QFormLayout()\n self.file_organizer_enabled = QCheckBox(\"启用文件整理助手(预览)\")\n self.file_organizer_enabled.setChecked(bool(config.get(\"file_organizer_enabled\", False)))\n open_organizer = QPushButton(\"打开文件整理助手\"); open_organizer.setIcon(get_icon(\"document\"))\n open_organizer.clicked.connect(self.controller.open_file_organizer)\n file_row = QHBoxLayout()\n file_row.addWidget(self.file_organizer_enabled)\n file_row.addWidget(open_organizer)\n file_row.addStretch(1)\n form.addRow(\"文件整理\", file_row)\n layout.addLayout(form)\n self.file_organizer_status = QLabel()\n self.file_organizer_status.setWordWrap(True)\n buttons = QHBoxLayout()\n save = QPushButton(\"保存文件整理设置\"); save.setIcon(get_icon(\"save\"))\n reset = QPushButton(\"恢复安全默认\"); reset.setIcon(get_icon(\"refresh\"))\n save.clicked.connect(self._save_integrated_features)\n reset.clicked.connect(self._reset_file_organizer_defaults)\n buttons.addWidget(save)\n buttons.addWidget(reset)\n buttons.addStretch(1)\n layout.addLayout(buttons)\n layout.addWidget(self.file_organizer_status)\n layout.addStretch(1)\n self._refresh_integrated_feature_status()\n self._register_tab(\"file_organizer\", tab, \"document\", \"文件整理\")\n\n def _build_system_status_tab(self) -> None:\n config = self.settings_manager.load_app_config()\n tab = QWidget()\n layout = QVBoxLayout(tab)\n description = QLabel(\"\")\n description.setWordWrap(True)\n layout.addWidget(description)\n form = QFormLayout()\n self.system_status_enabled = QCheckBox(\"启用系统状态感知\")\n self.system_status_enabled.setChecked(bool(config.get(\"system_status_enabled\", False)))\n self.system_status_interval = QSpinBox()\n self.system_status_interval.setRange(300, 7200)\n self.system_status_interval.setSuffix(\" 秒\")\n self.system_status_interval.setValue(int(config.get(\"system_status_interval_seconds\", 300)))\n self.system_status_cooldown = QSpinBox()\n self.system_status_cooldown.setRange(300, 7200)\n self.system_status_cooldown.setSuffix(\" 秒\")\n self.system_status_cooldown.setValue(int(config.get(\"system_status_cooldown_seconds\", 300)))\n self.system_status_cpu = QSpinBox(); self.system_status_cpu.setRange(1, 100); self.system_status_cpu.setSuffix(\"%\")\n self.system_status_cpu.setValue(int(config.get(\"system_status_cpu_threshold\", 90)))\n self.system_status_memory = QSpinBox(); self.system_status_memory.setRange(1, 100); self.system_status_memory.setSuffix(\"%\")\n self.system_status_memory.setValue(int(config.get(\"system_status_memory_threshold\", 90)))\n self.system_status_battery = QSpinBox(); self.system_status_battery.setRange(1, 100); self.system_status_battery.setSuffix(\"%\")\n self.system_status_battery.setValue(int(config.get(\"system_status_battery_threshold\", 20)))\n self.system_status_network = QCheckBox(\"检测网络断开\")\n self.system_status_network.setChecked(bool(config.get(\"system_status_network_check_enabled\", False)))\n form.addRow(\"系统状态\", self.system_status_enabled)\n form.addRow(\"状态检查间隔\", self.system_status_interval)\n form.addRow(\"状态提醒冷却\", self.system_status_cooldown)\n form.addRow(\"处理器阈值\", self.system_status_cpu)\n form.addRow(\"内存阈值\", self.system_status_memory)\n form.addRow(\"电池阈值\", self.system_status_battery)\n form.addRow(\"网络检查\", self.system_status_network)\n layout.addLayout(form)\n self.system_status_result = QLabel()\n self.system_status_result.setWordWrap(True)\n buttons = QHBoxLayout()\n save = QPushButton(\"保存系统状态设置\"); save.setIcon(get_icon(\"save\"))\n test = QPushButton(\"读取一次当前状态\"); test.setIcon(get_icon(\"info\"))\n reset = QPushButton(\"恢复安全默认\"); reset.setIcon(get_icon(\"refresh\"))\n save.clicked.connect(self._save_integrated_features)\n test.clicked.connect(self._test_system_status)\n reset.clicked.connect(self._reset_system_status_defaults)\n buttons.addWidget(save)\n buttons.addWidget(test)\n buttons.addWidget(reset)\n buttons.addStretch(1)\n layout.addLayout(buttons)\n layout.addWidget(self.system_status_result)\n layout.addStretch(1)\n self._refresh_integrated_feature_status()\n self._register_tab(\"system_status\", tab, \"laptop\", \"系统状态\")\n\n def _build_clipboard_tab(self) -> None:\n config = self.settings_manager.load_app_config()\n tab = QWidget()\n layout = QVBoxLayout(tab)\n description = QLabel(\"\")\n description.setWordWrap(True)\n layout.addWidget(description)\n form = QFormLayout()\n self.clipboard_enabled = QCheckBox(\"启用剪贴板互动\")\n self.clipboard_enabled.setChecked(bool(config.get(\"clipboard_interaction_enabled\", False)))\n self.clipboard_max_chars = QSpinBox()\n self.clipboard_max_chars.setRange(100, 10000)\n self.clipboard_max_chars.setValue(int(config.get(\"clipboard_max_chars\", 1000)))\n self.clipboard_show_preview = QCheckBox(\"显示截断预览\")\n self.clipboard_show_preview.setChecked(bool(config.get(\"clipboard_show_preview\", False)))\n self.clipboard_allow_api = QCheckBox(\"允许确认后进入 API 对话\")\n self.clipboard_allow_api.setChecked(bool(config.get(\"clipboard_allow_api_after_confirm\", True)))\n self.clipboard_sensitive_block = QCheckBox(\"启用敏感内容拦截\")\n self.clipboard_sensitive_block.setChecked(bool(config.get(\"clipboard_sensitive_block_enabled\", True)))\n form.addRow(\"剪贴板\", self.clipboard_enabled)\n form.addRow(\"剪贴板最大字符\", self.clipboard_max_chars)\n form.addRow(\"剪贴板预览\", self.clipboard_show_preview)\n form.addRow(\"剪贴板 API\", self.clipboard_allow_api)\n form.addRow(\"敏感拦截\", self.clipboard_sensitive_block)\n layout.addLayout(form)\n self.clipboard_result = QLabel()\n self.clipboard_result.setWordWrap(True)\n buttons = QHBoxLayout()\n save = QPushButton(\"保存剪贴板设置\"); save.setIcon(get_icon(\"save\"))\n clear = QPushButton(\"清空互动状态\"); clear.setIcon(get_icon(\"refresh\"))\n test = QPushButton(\"测试本地拦截\"); test.setIcon(get_icon(\"info\"))\n reset = QPushButton(\"恢复隐私默认\"); reset.setIcon(get_icon(\"refresh\"))\n save.clicked.connect(self._save_integrated_features)\n clear.clicked.connect(self._clear_clipboard_state)\n test.clicked.connect(self._test_clipboard_filter)\n reset.clicked.connect(self._reset_clipboard_defaults)\n buttons.addWidget(save)\n buttons.addWidget(clear)\n buttons.addWidget(test)\n buttons.addWidget(reset)\n buttons.addStretch(1)\n layout.addLayout(buttons)\n layout.addWidget(self.clipboard_result)\n layout.addStretch(1)\n self._refresh_integrated_feature_status()\n self._register_tab(\"clipboard\", tab, \"document\", \"剪贴板\")\n\n def _build_focus_tab(self) -> None:\n config = self.settings_manager.load_app_config()\n tab = QWidget()\n layout = QVBoxLayout(tab)\n description = QLabel(\"\")\n description.setWordWrap(True)\n layout.addWidget(description)\n form = QFormLayout()\n self.focus_enabled = QCheckBox(\"启用专注 / 游戏模式\")\n self.focus_enabled.setChecked(bool(config.get(\"focus_mode_enabled\", False)))\n self.focus_manual = QCheckBox(\"手动进入专注模式\")\n self.focus_manual.setChecked(bool(config.get(\"focus_mode_manual\", False)))\n self.focus_auto = QCheckBox(\"按进程白名单自动进入\")\n self.focus_auto.setChecked(bool(config.get(\"focus_mode_auto_game_detect\", False)))\n self.focus_whitelist = QTextEdit()\n self.focus_whitelist.setMaximumHeight(70)\n whitelist = config.get(\"focus_mode_process_whitelist\", [])\n self.focus_whitelist.setPlainText(\"\\n\".join(str(item) for item in whitelist if str(item).strip()))\n self.focus_silence_idle = QCheckBox(\"静默闲聊/空闲动作\")\n self.focus_silence_idle.setChecked(bool(config.get(\"focus_mode_silence_idle_chat\", True)))\n self.focus_silence_hourly = QCheckBox(\"静默整点报时\")\n self.focus_silence_hourly.setChecked(bool(config.get(\"focus_mode_silence_hourly_chime\", True)))\n self.focus_silence_edge = QCheckBox(\"静默左右边缘趴墙\")\n self.focus_silence_edge.setChecked(bool(config.get(\"focus_mode_silence_edge_peek\", True)))\n self.focus_silence_system = QCheckBox(\"静默系统状态提醒\")\n self.focus_silence_system.setChecked(bool(config.get(\"focus_mode_silence_system_status\", True)))\n self.focus_silence_clipboard = QCheckBox(\"静默剪贴板提示\")\n self.focus_silence_clipboard.setChecked(bool(config.get(\"focus_mode_silence_clipboard\", True)))\n self.focus_silence_environment = QCheckBox(\"静默天气、媒体和小剧场提示\")\n self.focus_silence_environment.setChecked(\n bool(config.get(\"focus_mode_silence_environment\", True))\n )\n self.focus_allow_important = QCheckBox(\"允许重要提醒\")\n self.focus_allow_important.setChecked(bool(config.get(\"focus_mode_allow_important_reminders\", True)))\n form.addRow(\"专注模式\", self.focus_enabled)\n form.addRow(\"手动专注\", self.focus_manual)\n form.addRow(\"自动检测\", self.focus_auto)\n form.addRow(\"进程白名单\", self.focus_whitelist)\n form.addRow(\"专注静默\", self.focus_silence_idle)\n form.addRow(\"\", self.focus_silence_hourly)\n form.addRow(\"\", self.focus_silence_edge)\n form.addRow(\"\", self.focus_silence_system)\n form.addRow(\"\", self.focus_silence_clipboard)\n form.addRow(\"\", self.focus_silence_environment)\n form.addRow(\"重要提醒\", self.focus_allow_important)\n layout.addLayout(form)\n self.focus_result = QLabel()\n self.focus_result.setWordWrap(True)\n buttons = QHBoxLayout()\n save = QPushButton(\"保存专注模式设置\"); save.setIcon(get_icon(\"save\"))\n exit_focus = QPushButton(\"退出专注模式\"); exit_focus.setIcon(get_icon(\"refresh\"))\n reset = QPushButton(\"恢复专注默认\"); reset.setIcon(get_icon(\"refresh\"))\n save.clicked.connect(self._save_integrated_features)\n exit_focus.clicked.connect(self._exit_focus_mode)\n reset.clicked.connect(self._reset_focus_defaults)\n buttons.addWidget(save)\n buttons.addWidget(exit_focus)\n buttons.addWidget(reset)\n buttons.addStretch(1)\n layout.addLayout(buttons)\n layout.addWidget(self.focus_result)\n layout.addStretch(1)\n self._refresh_integrated_feature_status()\n self._register_tab(\"focus\", tab, \"chip\", \"专注模式\")\n\n def _build_privacy_tab(self) -> None:\n tab = QWidget()\n layout = QVBoxLayout(tab)\n group = QGroupBox(\"本地数据与隐私边界\")\n group_layout = QVBoxLayout(group)\n text = QLabel(\n \"密钥、聊天记录、提醒、记忆和关系状态只保存在本机运行态目录。\"\n \"敏感数据文件夹不应进入版本管理。剪贴板、系统状态和文件整理均默认关闭。\"\n )\n text.setWordWrap(True)\n group_layout.addWidget(text)\n self.privacy_status = QLabel()\n self.privacy_status.setWordWrap(True)\n group_layout.addWidget(self.privacy_status)\n buttons = QHBoxLayout()\n refresh = QPushButton(\"刷新隐私状态\"); refresh.setIcon(get_icon(\"refresh\"))\n defaults = QPushButton(\"关闭高风险功能\"); defaults.setIcon(get_icon(\"settings\"))\n refresh.clicked.connect(self._refresh_privacy_status)\n defaults.clicked.connect(self._restore_privacy_defaults)\n buttons.addWidget(refresh)\n buttons.addWidget(defaults)\n buttons.addStretch(1)\n group_layout.addLayout(buttons)\n layout.addWidget(group)\n layout.addStretch(1)\n self._refresh_privacy_status()\n self._register_tab(\"privacy\", tab, \"info\", \"隐私与安全\")\n\n def _build_diagnostics_tab(self) -> None:\n tab = QWidget()\n layout = QVBoxLayout(tab)\n description = QLabel(\"诊断只显示脱敏后的配置、资源和运行态结果,不显示完整密钥。\")\n description.setWordWrap(True)\n layout.addWidget(description)\n self.diagnostics_text = QTextEdit()\n self.diagnostics_text.setReadOnly(True)\n run = QPushButton(\"运行诊断\"); run.setIcon(get_icon(\"settings\"))\n run.clicked.connect(self._run_diagnostics)\n layout.addWidget(run, alignment=Qt.AlignmentFlag.AlignLeft)\n layout.addWidget(self.diagnostics_text)\n self._register_tab(\"diagnostics\", tab, \"settings\", \"诊断\")\n\n def _save_reminder_settings(self) -> None:\n config = self.settings_manager.load_app_config()\n config[\"reminder_enabled\"] = self.reminder_enabled.isChecked()\n config[\"natural_reminder_enabled\"] = self.natural_reminder_enabled.isChecked()\n self.settings_manager.save_app_config(config)\n self.controller.app_config.update(config)\n self._refresh_reminder_status()\n\n def _save_growth_settings(self) -> None:\n enabled = self.growth_enabled.isChecked()\n self.controller.growth_manager.set_enabled(enabled)\n self.controller.app_config.setdefault(\"growth\", {})[\"enabled\"] = enabled\n self.controller.window.set_context_menu(self.controller.menu_manager.create_menu())\n self._refresh_growth_status()\n\n def _reset_growth_settings(self) -> None:\n self.growth_enabled.setChecked(False)\n self._save_growth_settings()\n\n def _refresh_growth_status(self) -> None:\n state = self.controller.growth_manager.snapshot(\n self.controller.affinity_manager.value()\n )\n self.growth_status.setText(\n f\"当前状态:{'已启用' if self.growth_enabled.isChecked() else '已关闭'};\"\n f\"硬币 {state['coins']};成长等级 {state['level']};\"\n f\"背包 {sum(state['inventory'].values())} 件;\"\n f\"衣柜 {len(state['owned_outfits'])} 套。\"\n )\n\n def _save_environment_settings(self) -> None:\n config = self.settings_manager.load_app_config()\n environment = config.setdefault(\"environment\", {})\n environment[\"weather_enabled\"] = self.weather_enabled.isChecked()\n environment[\"weather_location_configured\"] = (\n self.weather_location_configured.isChecked()\n )\n environment[\"weather_location_name\"] = self.weather_location_name.text().strip()\n environment[\"weather_latitude\"] = self.weather_latitude.value()\n environment[\"weather_longitude\"] = self.weather_longitude.value()\n environment[\"weather_interval_seconds\"] = self.weather_interval.value()\n environment[\"weather_notify_rain\"] = self.weather_notify_rain.isChecked()\n environment[\"media_presence_enabled\"] = (\n self.media_presence_enabled.isChecked()\n )\n environment[\"media_interval_seconds\"] = self.media_interval.value()\n environment[\"ambient_events_enabled\"] = self.ambient_events_enabled.isChecked()\n environment[\"ambient_event_interval_seconds\"] = self.ambient_interval.value()\n self.settings_manager.save_app_config(config)\n self.controller.app_config.update(config)\n self.controller.apply_integrated_feature_config()\n self.weather_result.setText(\n \"环境设置已保存;关闭的服务不会运行,天气坐标不会发送给聊天模型。\"\n )\n if hasattr(self, \"privacy_status\"):\n self._refresh_privacy_status()\n\n def _test_weather(self) -> None:\n self._save_environment_settings()\n if self.controller.weather_manager.refresh(force=True):\n self.weather_result.setText(\"正在后台读取 Open-Meteo 天气……\")\n\n def _show_weather_snapshot(self, payload: object) -> None:\n if not isinstance(payload, dict):\n return\n location = self.weather_location_name.text().strip() or \"已确认坐标\"\n rain_text = \";当前有降水\" if payload.get(\"is_raining\") else \"\"\n self.weather_result.setText(\n f\"{location}:{payload.get('description', '未知天气')},\"\n f\"{float(payload.get('temperature_c', 0.0)):.1f}°C,\"\n f\"降水 {float(payload.get('precipitation_mm', 0.0)):.1f} mm\"\n f\"{rain_text}。来源:{payload.get('source', 'Open-Meteo')}。\"\n )\n\n def _show_weather_error(self, message: str) -> None:\n self.weather_result.setText(message)\n\n def _test_media_presence(self) -> None:\n player = self.controller.media_presence_manager.detect_now()\n self.media_result.setText(\n f\"检测到受支持播放器:{player}。未读取标题或内容。\"\n if player\n else \"未检测到受支持播放器;未读取任何窗口标题或媒体内容。\"\n )\n\n def _preview_ambient_event(self) -> None:\n event = self.controller.ambient_event_theater.trigger_once(force=True)\n self.ambient_result.setText(\n str(event.get(\"text\", \"\")) if isinstance(event, dict) else \"当前角色包没有可用事件。\"\n )\n\n def _reset_reminder_defaults(self) -> None:\n self.reminder_enabled.setChecked(True)\n self.natural_reminder_enabled.setChecked(True)\n self._save_reminder_settings()\n\n def _refresh_reminder_status(self) -> None:\n enabled = self.reminder_enabled.isChecked()\n natural = self.natural_reminder_enabled.isChecked()\n self.reminder_status.setText(\n f\"当前状态:到期提醒{'已启用' if enabled else '已关闭'};自然语言识别{'已启用' if natural else '已关闭'}。\"\n )\n\n def _reset_file_organizer_defaults(self) -> None:\n self.file_organizer_enabled.setChecked(False)\n self._save_integrated_features()\n\n def _reset_system_status_defaults(self) -> None:\n self.system_status_enabled.setChecked(False)\n self.system_status_interval.setValue(300)\n self.system_status_cooldown.setValue(300)\n self.system_status_cpu.setValue(90)\n self.system_status_memory.setValue(90)\n self.system_status_battery.setValue(20)\n self.system_status_network.setChecked(False)\n self._save_integrated_features()\n\n def _test_system_status(self) -> None:\n status = self.controller.system_status_manager.get_current_status()\n battery = \"不可用\" if status.battery_percent is None else f\"{status.battery_percent:.0f}%\"\n self.system_status_result.setText(\n f\"本地读取结果:CPU {status.cpu_percent:.0f}%;内存 {status.memory_percent:.0f}%;\"\n f\"磁盘 {status.disk_percent:.0f}%;电池 {battery}。本次结果未发送给服务商。\"\n )\n\n def _clear_clipboard_state(self) -> None:\n self.controller.clipboard_interaction.last_text = \"\"\n self.clipboard_result.setText(\"剪贴板互动状态已清空;未读取或保存当前剪贴板正文。\")\n\n def _test_clipboard_filter(self) -> None:\n result = self.controller.clipboard_interaction.check_text(\"测试文本,不包含敏感信息。\")\n self.clipboard_result.setText(f\"本地过滤测试:{result.get('message', '完成')} 未调用服务商。\")\n\n def _reset_clipboard_defaults(self) -> None:\n self.clipboard_enabled.setChecked(False)\n self.clipboard_max_chars.setValue(1000)\n self.clipboard_show_preview.setChecked(False)\n self.clipboard_allow_api.setChecked(True)\n self.clipboard_sensitive_block.setChecked(True)\n self._save_integrated_features()\n\n def _exit_focus_mode(self) -> None:\n self.focus_manual.setChecked(False)\n self.controller.focus_mode_manager.exit_focus_mode()\n self._save_integrated_features()\n\n def _reset_focus_defaults(self) -> None:\n self.focus_enabled.setChecked(False)\n self.focus_manual.setChecked(False)\n self.focus_auto.setChecked(False)\n self.focus_whitelist.clear()\n self.focus_silence_idle.setChecked(True)\n self.focus_silence_hourly.setChecked(True)\n self.focus_silence_edge.setChecked(True)\n self.focus_silence_system.setChecked(True)\n self.focus_silence_clipboard.setChecked(True)\n self.focus_silence_environment.setChecked(True)\n self.focus_allow_important.setChecked(True)\n self._save_integrated_features()\n\n def _refresh_integrated_feature_status(self) -> None:\n if hasattr(self, \"file_organizer_status\"):\n self.file_organizer_status.setText(\n f\"当前状态:{'已启用,仍需手动选择目录和确认执行' if self.file_organizer_enabled.isChecked() else '已关闭'}。\"\n )\n if hasattr(self, \"system_status_result\") and not self.system_status_result.text():\n self.system_status_result.setText(\n f\"当前状态:{'已启用低频本地检查' if self.system_status_enabled.isChecked() else '已关闭'}。\"\n )\n if hasattr(self, \"clipboard_result\") and not self.clipboard_result.text():\n self.clipboard_result.setText(\n f\"当前状态:{'已启用本地文本监听' if self.clipboard_enabled.isChecked() else '已关闭,不监听剪贴板'}。\"\n )\n if hasattr(self, \"focus_result\"):\n active = bool(getattr(self.controller.focus_mode_manager, \"is_active\", False))\n self.focus_result.setText(\n f\"当前状态:配置{'已启用' if self.focus_enabled.isChecked() else '已关闭'};\"\n f\"运行态{'正在专注' if active else '未进入专注'}。\"\n )\n\n def _refresh_privacy_status(self) -> None:\n config = self.settings_manager.load_app_config()\n active = [\n name\n for name, enabled in (\n (\"文件整理\", config.get(\"file_organizer_enabled\", False)),\n (\"系统状态\", config.get(\"system_status_enabled\", False)),\n (\"剪贴板\", config.get(\"clipboard_interaction_enabled\", False)),\n (\"专注模式\", config.get(\"focus_mode_enabled\", False)),\n (\n \"天气\",\n isinstance(config.get(\"environment\"), dict)\n and config[\"environment\"].get(\"weather_enabled\", False),\n ),\n (\n \"媒体感知\",\n isinstance(config.get(\"environment\"), dict)\n and config[\"environment\"].get(\"media_presence_enabled\", False),\n ),\n (\n \"随机小剧场\",\n isinstance(config.get(\"environment\"), dict)\n and config[\"environment\"].get(\"ambient_events_enabled\", False),\n ),\n (\n \"长期记忆\",\n isinstance(config.get(\"memory_features\"), dict)\n and config[\"memory_features\"].get(\"long_term_enabled\", False),\n ),\n (\n \"观察日记\",\n isinstance(config.get(\"memory_features\"), dict)\n and config[\"memory_features\"].get(\"diary_enabled\", False),\n ),\n )\n if bool(enabled)\n ]\n self.privacy_status.setText(\n \"高风险功能状态:\" + (\"、\".join(active) + \" 已启用。\" if active else \"均为关闭。\")\n )\n\n def _restore_privacy_defaults(self) -> None:\n config = self.settings_manager.load_app_config()\n config[\"file_organizer_enabled\"] = False\n config[\"system_status_enabled\"] = False\n config[\"clipboard_interaction_enabled\"] = False\n config[\"focus_mode_enabled\"] = False\n config[\"focus_mode_manual\"] = False\n config[\"focus_mode_auto_game_detect\"] = False\n environment = config.setdefault(\"environment\", {})\n environment[\"weather_enabled\"] = False\n environment[\"media_presence_enabled\"] = False\n environment[\"ambient_events_enabled\"] = False\n memory_features = config.setdefault(\"memory_features\", {})\n memory_features[\"long_term_enabled\"] = False\n memory_features[\"diary_enabled\"] = False\n self.settings_manager.save_app_config(config)\n self.controller.app_config.update(config)\n self.controller.apply_integrated_feature_config()\n if hasattr(self, \"file_organizer_enabled\"):\n self.file_organizer_enabled.setChecked(False)\n self.system_status_enabled.setChecked(False)\n self.clipboard_enabled.setChecked(False)\n self.focus_enabled.setChecked(False)\n self.focus_manual.setChecked(False)\n self.focus_auto.setChecked(False)\n if hasattr(self, \"weather_enabled\"):\n self.weather_enabled.setChecked(False)\n self.media_presence_enabled.setChecked(False)\n self.ambient_events_enabled.setChecked(False)\n if hasattr(self, \"long_term_memory_enabled\"):\n self.long_term_memory_enabled.setChecked(False)\n self.observation_diary_enabled.setChecked(False)\n self._refresh_integrated_feature_status()\n self._refresh_privacy_status()\n\n def _save_integrated_features(self) -> None:\n config = self.settings_manager.load_app_config()\n config[\"file_organizer_enabled\"] = self.file_organizer_enabled.isChecked()\n config[\"system_status_enabled\"] = self.system_status_enabled.isChecked()\n config[\"system_status_interval_seconds\"] = self.system_status_interval.value()\n config[\"system_status_cooldown_seconds\"] = self.system_status_cooldown.value()\n config[\"system_status_cpu_threshold\"] = self.system_status_cpu.value()\n config[\"system_status_memory_threshold\"] = self.system_status_memory.value()\n config[\"system_status_battery_threshold\"] = self.system_status_battery.value()\n config[\"system_status_network_check_enabled\"] = self.system_status_network.isChecked()\n config[\"clipboard_interaction_enabled\"] = self.clipboard_enabled.isChecked()\n config[\"clipboard_max_chars\"] = self.clipboard_max_chars.value()\n config[\"clipboard_show_preview\"] = self.clipboard_show_preview.isChecked()\n config[\"clipboard_allow_api_after_confirm\"] = self.clipboard_allow_api.isChecked()\n config[\"clipboard_sensitive_block_enabled\"] = self.clipboard_sensitive_block.isChecked()\n config[\"focus_mode_enabled\"] = self.focus_enabled.isChecked()\n config[\"focus_mode_manual\"] = self.focus_manual.isChecked()\n config[\"focus_mode_auto_game_detect\"] = self.focus_auto.isChecked()\n config[\"focus_mode_process_whitelist\"] = [\n line.strip() for line in self.focus_whitelist.toPlainText().splitlines() if line.strip()\n ]\n config[\"focus_mode_silence_idle_chat\"] = self.focus_silence_idle.isChecked()\n config[\"focus_mode_silence_hourly_chime\"] = self.focus_silence_hourly.isChecked()\n config[\"focus_mode_silence_edge_peek\"] = self.focus_silence_edge.isChecked()\n config[\"focus_mode_silence_system_status\"] = self.focus_silence_system.isChecked()\n config[\"focus_mode_silence_clipboard\"] = self.focus_silence_clipboard.isChecked()\n config[\"focus_mode_silence_environment\"] = self.focus_silence_environment.isChecked()\n config[\"focus_mode_allow_important_reminders\"] = self.focus_allow_important.isChecked()\n self.settings_manager.save_app_config(config)\n self.controller.app_config.update(config)\n self.controller.apply_integrated_feature_config()\n self.controller.window.set_context_menu(self.controller.menu_manager.create_menu())\n self._refresh_integrated_feature_status()\n if hasattr(self, \"privacy_status\"):\n self._refresh_privacy_status()\n\n def _masked_api_key_for_provider(self, provider: str, prov_conf: dict[str, Any] | None = None) -> str:\n from .chat_client import mask_key\n\n env_key = str((prov_conf or {}).get(\"api_key_env\") or ProviderMeta.get_api_key_env(provider))\n return mask_key(self.settings_manager.current_api_key(env_key))\n\n def _update_saved_api_key_label(self, provider: str, prov_conf: dict[str, Any] | None = None) -> None:\n masked = self._masked_api_key_for_provider(provider, prov_conf)\n self.api_key_input.setPlaceholderText(_api_key_placeholder(masked))\n self.saved_api_key_label.setText(_saved_api_key_status(masked))\n if masked and masked != \"<empty>\":\n self.saved_api_key_label.setStyleSheet(\"color: #22863a; font-size: 11px;\")\n else:\n self.saved_api_key_label.setStyleSheet(\"color: #6a737d; font-size: 11px;\")\n\n def _toggle_api_key_visibility(self, checked: bool) -> None:\n self.api_key_input.setEchoMode(QLineEdit.EchoMode.Normal if checked else QLineEdit.EchoMode.Password)\n self.api_key_toggle_btn.setText(\"隐\" if checked else \"显\")\n\n def _current_provider_key(self) -> str:\n return self._provider_display_map.get(self.provider_input.currentText(), \"deepseek\")\n\n def _on_provider_changed(self, display_or_id: str) -> None:\n key = self._provider_display_map.get(display_or_id, display_or_id)\n prov_conf = self.api.get(\"providers\", {}).get(key, {})\n meta = ProviderMeta.get(key)\n self.base_url_input.setText(str(prov_conf.get(\"base_url\") or meta.get(\"base_url\", \"\")))\n self.model_input.setText(str(prov_conf.get(\"model\") or meta.get(\"default_model\", \"\")))\n self.auth_header_input.setCurrentText(str(prov_conf.get(\"auth_header\") or meta.get(\"auth_header\", \"bearer\")))\n self._update_saved_api_key_label(key, prov_conf)\n\n def _save_api_settings(self, notify: bool = True, reload_client: bool = True) -> str:\n api_key = self.api_key_input.text()\n provider = self._current_provider_key()\n base_url = self.base_url_input.text().strip()\n model = self.model_input.text().strip()\n self.settings_manager.save_api_settings(\n provider=provider,\n base_url=base_url,\n model=model,\n api_key=api_key if api_key else None,\n auth_header=self.auth_header_input.currentText(),\n local_mode=self.local_mode_input.isChecked(),\n )\n if reload_client:\n self.controller.chat_client.reload()\n self.api_key_input.clear()\n self.api = self.settings_manager.load_api_config()\n self._update_saved_api_key_label(provider, self.api.get(\"providers\", {}).get(provider, {}))\n if notify:\n self.api_result.setText(\"设置已保存;密钥已写入本地或保持原值。点击「设为当前模型」后才会验证并生效。\")\n self.controller.window.speak(\"……设置保存好了。希望你没填错。\")\n self.controller.window.animation_manager.trigger_happy()\n self._refresh_active_status()\n self._refresh_profile_switcher()\n return ProviderMeta.make_profile_id(provider)\n\n def _test_api_connection(self) -> None:\n if self.api_worker is not None and self.api_worker.isRunning():\n return\n self.api_result.setText(\"正在后台测试连接...\")\n api_key = self.api_key_input.text().strip()\n self.api_worker = _ApiTestWorker(\n self.settings_manager,\n profile=self._cloud_profile_from_form(),\n api_key_override=api_key or None,\n )\n self.api_worker.finished_with_result.connect(lambda ok, msg: self.api_result.setText((\"通过:\" if ok else \"失败:\") + msg))\n self.api_worker.finished.connect(self.api_worker.deleteLater)\n self.api_worker.start()\n\n def _cloud_profile_from_form(self) -> dict[str, Any]:\n provider = self._current_provider_key()\n base_url = self.base_url_input.text().strip()\n model = self.model_input.text().strip()\n meta = ProviderMeta.get(provider)\n auth_header = self.settings_manager.resolve_auth_header(provider, base_url, self.auth_header_input.currentText())\n return {\n \"id\": ProviderMeta.make_profile_id(provider),\n \"name\": f\"{ProviderMeta.get_display_name(provider)} ({model or meta.get('default_model', '')})\",\n \"type\": \"text\",\n \"provider\": provider,\n \"api_style\": ProviderMeta.get_api_style(provider),\n \"base_url\": base_url or str(meta.get(\"base_url\", \"\")),\n \"model\": model or str(meta.get(\"default_model\", \"\")),\n \"api_key_env\": ProviderMeta.get_api_key_env(provider),\n \"auth_header\": auth_header,\n \"enabled\": True,\n \"capabilities\": [\"text\"],\n \"source\": \"cloud\",\n \"timeout\": ProviderMeta.get_timeout(provider),\n \"max_tokens\": ProviderMeta.get_max_tokens(provider),\n }\n\n def _save_pet_settings(self) -> None:\n config = self.settings_manager.load_app_config()\n config.setdefault(\"pet\", {})[\"pet_height\"] = self.pet_size.value()\n config.setdefault(\"pet\", {})[\"target_height\"] = self.pet_size.value()\n config.setdefault(\"window\", {})[\"show_input\"] = self.show_input.isChecked()\n config.setdefault(\"window\", {})[\"always_on_top\"] = self.always_on_top.isChecked()\n config.setdefault(\"window\", {})[\"opacity_percent\"] = self.opacity.value()\n config.setdefault(\"pet\", {})[\"edge_peek_enabled\"] = self.edge_peek.isChecked()\n config[\"idle_chat_enabled\"] = self.idle_chat.isChecked()\n config[\"idle_chat_minutes\"] = self.idle_minutes.value()\n config[\"idle_behavior_enabled\"] = self.idle_behavior.isChecked()\n config[\"idle_behavior_seconds\"] = self.idle_behavior_seconds.value()\n config[\"hourly_chime_enabled\"] = self.hourly_chime.isChecked()\n config[\"day_night_enabled\"] = self.day_night.isChecked()\n new_renderer = self.renderer_combo.currentData()\n config[\"renderer_type\"] = new_renderer\n self.settings_manager.save_app_config(config)\n self.controller.app_config.update(config)\n self.controller.save_pet_height(self.pet_size.value())\n self.controller.window.set_input_visible(self.show_input.isChecked(), expand=self.show_input.isChecked())\n self.controller.window.set_always_on_top(self.always_on_top.isChecked())\n self.controller.window.setWindowOpacity(self.opacity.value() / 100)\n self.controller.window.set_renderer_type(new_renderer)\n self.controller.window.behavior_engine.reload_config(self.controller.app_config)\n self.controller.window.set_context_menu(self.controller.menu_manager.create_menu())\n self.pet_result.setText(\"已保存。大小、输入框、置顶、透明度、趴墙已即时生效;部分定时器配置重启后完全生效。\")\n self.controller.window.speak(\"……保存好啦。别又忘了哦。\")\n self.controller.window.animation_manager.trigger_happy()\n\n def _refresh_action_status(self) -> None:\n asset_manager = self.controller.asset_manager\n try:\n manifest = asset_manager.manifest()\n source = \"private\" if \"assets\\\\private\" in str(asset_manager.active_asset_dir()) or \"assets/private\" in str(asset_manager.active_asset_dir()) else \"placeholder\"\n raw_lines = [f\"资源来源: {source}\", f\"资源目录: {asset_manager.active_asset_dir()}\", \"manifest: OK\"]\n available = []\n missing = []\n for action in [\"idle\", \"talk\", \"clicked\", \"drag\", \"sleep\", \"happy\", \"remind\", \"soft_idle\", \"close_idle\", \"bubble\", \"look_away\"]:\n frames = asset_manager.frames_for_state(action)\n status = \"available\" if frames and any(frame.exists() for frame in frames) else \"missing\"\n (available if status == \"available\" else missing).append(action)\n raw_lines.append(f\"- {action}: {status}; frames={[str(frame.name) for frame in frames[:3]]}\")\n src_label = '私有素材' if source == 'private' else '占位素材'\n if missing:\n lines = [f\"{src_label} · 可用 {len(available)} 个 · 缺失:{'、'.join(missing)}\"]\n else:\n lines = [f\"{src_label} · {len(available)} 个动作全部就绪\"]\n except Exception as exc:\n lines = [f\"动作资源读取失败:{exc.__class__.__name__}\"]\n raw_lines = [f\"manifest: FAILED {exc.__class__.__name__}\"]\n self.action_status.setPlainText(\"\\n\".join(lines))\n if hasattr(self, \"action_raw_details\"):\n self.action_raw_details.setPlainText(\"\\n\".join(raw_lines))\n\n def _toggle_action_details(self) -> None:\n visible = not self.action_raw_details.isVisible()\n self.action_raw_details.setVisible(visible)\n self.action_details_btn.setText(\"隐藏原始详情\" if visible else \"显示原始详情\")\n\n def _reload_actions(self) -> None:\n self.controller.asset_manager._manifest = None\n self.controller.asset_manager._asset_dir = None\n self.controller.window.animation_manager.refresh()\n self._refresh_action_status()\n\n def _test_action(self) -> None:\n action = self.action_combo.currentText()\n self.controller.window.set_pet_state(action)\n self._refresh_action_status()\n\n def _refresh_character_status(self) -> None:\n status = self.character_editor.status()\n files = status.get(\"files\", {})\n missing = [name for name, file_status in files.items() if not file_status.get(\"exists\")]\n yaml_errors = [name for name, file_status in files.items() if file_status.get(\"yaml_ok\") is False]\n if status['loaded'] and status['validation_ok'] and not missing and not yaml_errors:\n lines = [\"角色包正常\"]\n else:\n lines = []\n if not status['loaded']:\n lines.append(\"角色包加载失败\")\n if not status['validation_ok']:\n lines.append(\"结构校验异常\")\n if missing:\n lines.append(\"缺失文件:\" + \"、\".join(missing))\n if yaml_errors:\n lines.append(\"格式异常:\" + \"、\".join(yaml_errors))\n if status.get(\"validation_errors\"):\n lines.append(str(status[\"validation_errors\"]))\n self.character_status.setText(\"\\n\".join(lines))\n\n def _load_pack_file(self, name: str) -> None:\n try:\n text = self.character_editor.read_file(name)\n editable = name in EDITABLE_FILES\n self.pack_editor_text.setPlainText(text)\n self.pack_editor_text.setReadOnly(not editable)\n self.pack_summary_text.setPlainText(_format_pack_file_summary(name, text, editable))\n except Exception as exc:\n self.pack_editor_text.setPlainText(f\"读取失败:{exc.__class__.__name__}\")\n self.pack_editor_text.setReadOnly(True)\n self.pack_summary_text.setPlainText(f\"读取失败:{exc.__class__.__name__}\")\n\n def _toggle_pack_raw_file(self) -> None:\n visible = not self.pack_editor_text.isVisible()\n self.pack_editor_text.setVisible(visible)\n self.pack_raw_toggle_btn.setText(\"隐藏原始文件\" if visible else \"显示原始文件\")\n\n def _save_pack_file(self) -> None:\n name = self.pack_file_combo.currentText()\n ok, message, backup = self.character_editor.save_yaml_safely(name, self.pack_editor_text.toPlainText())\n QMessageBox.information(self, \"角色包保存\", message + (f\"\\n备份: {backup}\" if backup else \"\"))\n self._refresh_character_status()\n\n def _open_pack_file(self) -> None:\n path = self.character_editor.pack_path / self.pack_file_combo.currentText()\n if path.exists():\n QDesktopServices.openUrl(QUrl.fromLocalFile(str(path)))\n\n def _refresh_relationship(self) -> None:\n status = self.relationship_viewer.status()\n state = status[\"relationship_state\"]\n self.relationship_text.setPlainText(_format_relationship_summary(state))\n if hasattr(self, \"relationship_raw_text\"):\n raw_lines = [f\"{key}: {value}\" for key, value in state.items()]\n self.relationship_raw_text.setPlainText(\"\\n\".join(raw_lines) or \"relationship_state.json 不可读或为空。\")\n\n def _refresh_relationship_bundle(self) -> None:\n self._refresh_relationship()\n self._refresh_memory()\n self._refresh_events()\n\n def _refresh_events(self) -> None:\n status = self.relationship_viewer.status()\n events = status[\"event_log\"][-20:]\n raw_event_lines = [\n f\"- {e.get('timestamp','')} event={e.get('event_id')} source={e.get('source')} effect={e.get('relationship_effect')} lore={e.get('lore_fragments_used')} {e.get('stage_before')}->{e.get('stage_after')}\"\n for e in events\n if isinstance(e, dict)\n ]\n if hasattr(self, \"events_text\"):\n self.events_text.setPlainText(_format_event_log_summary(events))\n if hasattr(self, \"events_raw_text\"):\n self.events_raw_text.setPlainText(\"\\n\".join(raw_event_lines) or \"暂无事件记录。\")\n\n def _refresh_memory(self) -> None:\n status = self.relationship_viewer.status()\n memory = status.get(\"user_memory\")\n if not isinstance(memory, dict):\n memory = {}\n profile = self.controller.profile_manager.load()\n notes = _read_recent_text_lines(self.controller.notes_manager.path, limit=12)\n if hasattr(self, \"memory_text\"):\n self.memory_text.setPlainText(_format_user_memory_summary(profile, memory, notes))\n\n def _save_memory_diary_settings(self) -> None:\n config = self.settings_manager.load_app_config()\n memory_config = config.setdefault(\"memory_features\", {})\n if not isinstance(memory_config, dict):\n memory_config = {}\n config[\"memory_features\"] = memory_config\n memory_config[\"long_term_enabled\"] = self.long_term_memory_enabled.isChecked()\n memory_config[\"long_term_top_k\"] = self.long_term_memory_top_k.value()\n memory_config[\"long_term_max_entries\"] = (\n self.long_term_memory_max_entries.value()\n )\n memory_config[\"diary_enabled\"] = self.observation_diary_enabled.isChecked()\n memory_config[\"diary_days\"] = self.observation_diary_days.value()\n self.settings_manager.save_app_config(config)\n self.controller.app_config.update(config)\n self.observation_diary_status.setText(\n \"设置已保存。长期记忆和观察日记仍只保存在本机运行态目录。\"\n )\n if hasattr(self, \"privacy_status\"):\n self._refresh_privacy_status()\n\n def _refresh_memory_diary(self) -> None:\n long_term_records = self.controller.long_term_memory_store.records()\n diary_records = self.controller.observation_diary.records()\n if hasattr(self, \"long_term_memory_text\"):\n self.long_term_memory_text.setPlainText(\n _format_long_term_memory_summary(long_term_records)\n )\n if hasattr(self, \"observation_diary_text\"):\n self.observation_diary_text.setPlainText(\n _format_diary_summary(diary_records)\n )\n\n def _clear_long_term_memory(self) -> None:\n result = QMessageBox.question(\n self,\n \"清空长期记忆\",\n \"会永久清空本机保存的对话级长期记忆,但不删除聊天历史。继续吗?\",\n )\n if result != QMessageBox.StandardButton.Yes:\n return\n self.controller.long_term_memory_store.clear()\n self._refresh_memory_diary()\n self.observation_diary_status.setText(\"对话级长期记忆已清空。\")\n\n def _generate_observation_diary(self) -> None:\n self._save_memory_diary_settings()\n if not self.observation_diary_enabled.isChecked():\n QMessageBox.information(\n self,\n \"观察日记\",\n \"请先启用「允许手动把近期互动发送给当前文本模型」。\",\n )\n return\n if self.diary_worker is not None and self.diary_worker.isRunning():\n return\n days = self.observation_diary_days.value()\n result = QMessageBox.question(\n self,\n \"发送近期互动\",\n f\"将把最近 {days} 天的事件日志摘要发送给当前文本模型,\"\n \"用于生成第一人称观察日记。继续吗?\",\n )\n if result != QMessageBox.StandardButton.Yes:\n return\n self.observation_diary_status.setText(\"正在生成观察日记……\")\n self.diary_worker = _DiaryWorker(self.controller, days)\n self.diary_worker.finished_with_result.connect(\n self._on_observation_diary_finished\n )\n self.diary_worker.finished.connect(self.diary_worker.deleteLater)\n self.diary_worker.start()\n\n def _on_observation_diary_finished(self, result: object) -> None:\n payload = result if isinstance(result, dict) else {}\n message = str(payload.get(\"message\", \"观察日记生成失败。\"))\n source = str(payload.get(\"source\", \"unknown\"))\n self.observation_diary_status.setText(f\"{message} 来源:{source}。\")\n self.diary_worker = None\n self._refresh_memory_diary()\n\n def _clear_observation_diary(self) -> None:\n result = QMessageBox.question(\n self,\n \"清空观察日记\",\n \"会永久清空本机保存的观察日记。继续吗?\",\n )\n if result != QMessageBox.StandardButton.Yes:\n return\n self.controller.observation_diary.clear()\n self._refresh_memory_diary()\n self.observation_diary_status.setText(\"观察日记已清空。\")\n\n def _save_profile_settings(self) -> None:\n self.controller.save_profile(\n {\n \"user_name\": self.profile_user_name.text(),\n \"birthday\": self.profile_birthday.text(),\n \"relationship\": self.profile_relationship.text(),\n \"style\": self.profile_style.text(),\n }\n )\n profile = self.controller.profile_manager.load()\n self.profile_user_name.setText(profile[\"user_name\"])\n self.profile_birthday.setText(profile.get(\"birthday\", \"\"))\n self.profile_relationship.setText(profile[\"relationship\"])\n self.profile_style.setText(profile[\"style\"])\n self.profile_status.setText(\"用户档案已保存;生日仅保留月和日。\")\n self._refresh_memory()\n\n def _add_memory_note(self) -> None:\n text = self.memory_note_input.text().strip()\n if not text:\n QMessageBox.information(self, \"记忆备忘录\", \"先写一条要记住的内容。\")\n return\n self.controller.add_note(text)\n self.memory_note_input.clear()\n self._refresh_memory()\n\n def _clear_memory(self) -> None:\n result = QMessageBox.question(\n self,\n \"清空记忆\",\n \"会清空自动记忆、手动备忘和对话级长期记忆,但不会重置关系状态。继续吗?\",\n )\n if result != QMessageBox.StandardButton.Yes:\n return\n from core.memory_engine import clear_user_memory\n\n clear_user_memory()\n self.controller.notes_manager.clear()\n self.controller.long_term_memory_store.clear()\n if hasattr(self.controller, \"chat_client\"):\n self.controller.chat_client.reload()\n self._refresh_memory()\n if hasattr(self, \"long_term_memory_text\"):\n self._refresh_memory_diary()\n QMessageBox.information(\n self,\n \"记忆备忘录\",\n \"已清空自动记忆、手动备忘和对话级长期记忆。\",\n )\n\n def _toggle_relationship_raw(self) -> None:\n visible = not self.relationship_raw_text.isVisible()\n self.relationship_raw_text.setVisible(visible)\n self.relationship_raw_toggle_btn.setText(\"隐藏原始数据\" if visible else \"显示原始数据\")\n\n def _toggle_events_raw(self) -> None:\n visible = not self.events_raw_text.isVisible()\n self.events_raw_text.setVisible(visible)\n self.events_raw_toggle_btn.setText(\"隐藏原始日志\" if visible else \"显示原始日志\")\n\n def _export_relationship(self) -> None:\n path = self.relationship_viewer.export_state()\n QMessageBox.information(self, \"导出完成\", f\"已导出到:{path}\")\n\n def _reset_relationship(self) -> None:\n result = QMessageBox.question(self, \"重置关系状态\", \"会先自动备份,再重置 relationship_state。继续吗?\")\n if result != QMessageBox.StandardButton.Yes:\n return\n ok, message, backup = self.relationship_viewer.reset_state_with_backup()\n if ok:\n QMessageBox.information(self, \"关系状态\", message + (f\"\\n备份: {backup}\" if backup else \"\"))\n self._refresh_relationship()\n else:\n QMessageBox.warning(self, \"关系状态\", message)\n\n def _refresh_data(self) -> None:\n status = self.relationship_viewer.status()\n paths = self.relationship_viewer.paths()\n self.data_text.setPlainText(_format_data_status_summary(status, paths))\n lines = [f\"data_dir: {status['data_dir']} exists={status['exists']}\"]\n for key, path in paths.items():\n lines.append(f\"- {key}: exists={path.exists()} readable={status.get(key + '_readable')} error={status.get(key + '_error')}\")\n if hasattr(self, \"data_raw_text\"):\n self.data_raw_text.setPlainText(\"\\n\".join(lines))\n\n def _toggle_data_raw(self) -> None:\n visible = not self.data_raw_text.isVisible()\n self.data_raw_text.setVisible(visible)\n self.data_raw_toggle_btn.setText(\"隐藏原始详情\" if visible else \"显示原始详情\")\n\n def _backup_data(self) -> None:\n path = self.relationship_viewer.backup_data_dir()\n QMessageBox.information(self, \"数据备份\", f\"已备份到:{path}\")\n self._refresh_data()\n\n def _toggle_help(self) -> None:\n if self.help_content.isVisible():\n self.help_content.setVisible(False)\n self.help_toggle_btn.setText(\"展开使用帮助 ▼\")\n else:\n self.help_content.setVisible(True)\n self.help_toggle_btn.setText(\"收起使用帮助 ▲\")\n\n def _run_diagnostics(self) -> None:\n if self.diagnostics_worker is not None and self.diagnostics_worker.isRunning():\n return\n self.diagnostics_text.setPlainText(\"正在后台诊断...\")\n self.diagnostics_worker = _DiagnosticsWorker(self.settings_manager, self.controller)\n self.diagnostics_worker.finished_with_text.connect(self.diagnostics_text.setPlainText)\n self.diagnostics_worker.finished.connect(self.diagnostics_worker.deleteLater)\n self.diagnostics_worker.start()\n\n def _open_first_run_wizard(self) -> None:\n from .first_run_wizard import FirstRunWizard\n from .setup_state_manager import SetupStateManager\n\n wizard = FirstRunWizard(SetupStateManager(root=self.settings_manager.root))\n wizard.exec()\n self.api = self.settings_manager.load_api_config()\n active = self.api.get(\"active_provider\", \"deepseek\")\n active_display = next((display for display, key in self._provider_display_map.items() if key == active), None)\n if active_display:\n self.provider_input.setCurrentText(active_display)\n self.local_mode_input.setChecked(bool(self.api.get(\"local_mode\", False)))\n self.api_key_input.clear()\n self._on_provider_changed(self.provider_input.currentText())\n self._refresh_active_status()\n\n def _show_api_help(self) -> None:\n \"\"\"弹出云端 API 配置帮助窗口。分三区:预设 Provider / CC Switch / 自部署代理。\"\"\"\n dialog = QDialog(self)\n dialog.setWindowTitle(\"云端配置帮助 — 去哪获取密钥和地址\")\n dialog.setMinimumSize(680, 560)\n layout = QVBoxLayout(dialog)\n\n tabs = QTabWidget()\n\n # ── Tab 1: 云端 API 厂商 ──\n cloud_tab = QWidget()\n cloud_layout = QVBoxLayout(cloud_tab)\n cloud_intro = QLabel(\n \"<b>预设服务商</b> — 直接在下方选择,或点「填入配置」一键填入<br>\"\n \"<b>第三方/国产服务</b> — 选「自定义云端」后填入对应地址\"\n )\n cloud_intro.setWordWrap(True)\n cloud_layout.addWidget(cloud_intro)\n\n cloud_scroll = QScrollArea()\n cloud_scroll.setWidgetResizable(True)\n cloud_content = QWidget()\n cloud_content_layout = QVBoxLayout(cloud_content)\n cloud_content_layout.setSpacing(6)\n\n providers_help = [\n (\"DeepSeek\", \"deepseek\", \"https://api.deepseek.com\", \"deepseek-chat\",\n \"https://platform.deepseek.com/api_keys\", \"国内性价比最高,中文能力极强,2026年新款 V3\"),\n (\"OpenAI\", \"openai\", \"https://api.openai.com/v1\", \"gpt-4.1-mini\",\n \"https://platform.openai.com/api-keys\", \"GPT-4.1系列,性价比极高的默认模型\"),\n (\"Claude (Anthropic)\", \"claude\", \"https://api.anthropic.com/v1\", \"claude-sonnet-4-6\",\n \"https://console.anthropic.com/keys\", \"2026年最新 Sonnet 4.6,多文件编码王者\"),\n (\"Google Gemini\", \"gemini\", \"https://generativelanguage.googleapis.com/v1beta/openai\", \"gemini-2.5-flash\",\n \"https://aistudio.google.com/apikey\", \"2026年 Gemini 2.5 Flash,多模态 + 超长上下文\"),\n (\"Mistral AI\", \"mistral\", \"https://api.mistral.ai/v1\", \"mistral-large-latest\",\n \"https://console.mistral.ai/api-keys\", \"欧洲领先 AI,开源友好\"),\n (\"Groq\", \"groq\", \"https://api.groq.com/openai/v1\", \"llama-4-maverick-17b-128e-instruct\",\n \"https://console.groq.com/keys\", \"Llama 4 Maverick,开源旗舰级推理速度\"),\n (\"Z.AI (GLM)\", \"zai\", \"https://api.z.ai/api/paas/v4\", \"glm-5.1\",\n \"https://docs.z.ai/\", \"OpenAI-compatible GLM API,需要 ZAI_API_KEY\"),\n (\"硅基流动 (SiliconFlow)\", \"custom_cloud\", \"https://api.siliconflow.cn/v1\", \"Qwen/Qwen3-235B-A22B\",\n \"https://siliconflow.cn/\", \"Qwen3-235B,国产开源最强旗舰\"),\n (\"月之暗面 Kimi\", \"custom_cloud\", \"https://api.moonshot.cn/v1\", \"kimi-k2.5\",\n \"https://platform.moonshot.cn/\", \"Kimi K2.5,超长上下文+深度推理\"),\n (\"零一万物\", \"custom_cloud\", \"https://api.lingyiwanwu.com/v1\", \"yi-lightning\",\n \"https://platform.lingyiwanwu.com/\", \"Yi Lightning,速度极快,开源可商用\"),\n (\"字节豆包\", \"custom_cloud\", \"https://ark.cn-beijing.volces.com/api/v3\", \"需在控制台创建 endpoint\",\n \"https://console.volcengine.com/ark\", \"火山引擎 Ark,超低延迟企业级\"),\n (\"腾讯混元\", \"custom_cloud\", \"https://api.hunyuan.cloud.tencent.com/v1\", \"hunyuan-lite\",\n \"https://cloud.tencent.com/product/hunyuan\", \"腾讯出品,企业级稳定\"),\n (\"OpenRouter\", \"custom_cloud\", \"https://openrouter.ai/api/v1\", \"openai/gpt-4o\",\n \"https://openrouter.ai/keys\", \"聚合 200+ 模型,统一 API 访问\"),\n (\"Together AI\", \"custom_cloud\", \"https://api.together.xyz/v1\", \"mistralai/Mixtral-8x7B\",\n \"https://api.together.xyz/\", \"开源模型推理平台\"),\n ]\n\n for name, provider_key, url, model, reg_url, desc in providers_help:\n card = self._build_help_card(name, provider_key, url, model, reg_url, desc, dialog)\n cloud_content_layout.addWidget(card)\n cloud_content_layout.addStretch(1)\n cloud_scroll.setWidget(cloud_content)\n cloud_layout.addWidget(cloud_scroll)\n tabs.addTab(cloud_tab, get_icon(\"cloud\"), \"云端服务商\")\n\n # ── Tab 2: CC Switch 本地代理 ──\n cc_tab = QWidget()\n cc_layout = QVBoxLayout(cc_tab)\n cc_scroll = QScrollArea()\n cc_scroll.setWidgetResizable(True)\n cc_content = QWidget()\n cc_content_layout = QVBoxLayout(cc_content)\n cc_content_layout.setSpacing(10)\n\n cc_title = QLabel(\n \"<b>CC Switch — 本地 AI API 代理</b><br>\"\n \"免费开源跨平台桌面应用,启动本地 HTTP 代理统一管理和转发 AI API 请求。\"\n )\n cc_title.setWordWrap(True)\n cc_content_layout.addWidget(cc_title)\n\n cc_what = QLabel(\n \"<b>是什么</b><br>\"\n \"CC Switch 在你的电脑上启动一个本地代理服务器(默认 127.0.0.1:15721),\"\n \"所有 AI 工具的 API 请求先发到代理,代理再转发到你选择的供应商。\"\n \"支持 50+ 预设供应商,一键切换,无需改任何代码。\"\n )\n cc_what.setWordWrap(True)\n cc_what.setStyleSheet(\"background:#f0f7ff; border:1px solid #b6d4fe; border-radius:6px; padding:10px;\")\n cc_content_layout.addWidget(cc_what)\n\n cc_why = QLabel(\n \"<b>为什么用</b>\"\n \"<table cellspacing='4'>\"\n \"<tr><td>✓</td><td>图形界面一键切换模型,告别手动改 JSON</td></tr>\"\n \"<tr><td>✓</td><td>统一代理入口,一个地址覆盖 Claude Code / Codex / Gemini CLI / 达妮娅</td></tr>\"\n \"<tr><td>✓</td><td>自动故障转移 + 熔断,主供应商挂了自动切备用</td></tr>\"\n \"<tr><td>✓</td><td>Anthropic ↔ OpenAI ↔ Gemini 协议自动转换</td></tr>\"\n \"<tr><td>✓</td><td>实时请求日志 + Token 用量统计</td></tr>\"\n \"</table>\"\n )\n cc_why.setWordWrap(True)\n cc_content_layout.addWidget(cc_why)\n\n cc_how = QLabel(\n \"<b>工作原理</b><br>\"\n \"<code>达妮娅 → 127.0.0.1:15721 (CC Switch) → GLM / DeepSeek / Kimi / Claude / ...</code>\"\n )\n cc_how.setWordWrap(True)\n cc_how.setStyleSheet(\"background:#f8f9fa; border:1px solid #e1e4e8; border-radius:6px; padding:10px;\")\n cc_content_layout.addWidget(cc_how)\n\n cc_setup = QLabel(\n \"<b>在达妮娅中接入 CC Switch</b><br>\"\n \"1. 下载安装 CC Switch(GitHub: \"\n \"<a href='https://github.com/farion1231/cc-switch'>farion1231/cc-switch</a>,官网: cswitch.io)<br>\"\n \"2. 启动 CC Switch,在界面中选择要用的模型供应商<br>\"\n \"3. 在达妮娅设置中心填入:<br>\"\n \"&nbsp;&nbsp;&nbsp;服务商: <b>自定义云端</b><br>\"\n \"&nbsp;&nbsp;&nbsp;地址: <code>http://127.0.0.1:15721/v1</code><br>\"\n \"&nbsp;&nbsp;&nbsp;模型: 与 CC Switch 中显示的模型名一致<br>\"\n \"&nbsp;&nbsp;&nbsp;密钥: 任意非空字符串(如 cc-switch)<br>\"\n \"4. 点「测试连接」→「设为当前模型」<br><br>\"\n \"<b>之后在 CC Switch 界面切换模型,达妮娅无需任何改动,立即生效。</b>\"\n )\n cc_setup.setWordWrap(True)\n cc_setup.setStyleSheet(\"background:#fff3cd; border:1px solid #ffeeba; border-radius:6px; padding:10px;\")\n cc_content_layout.addWidget(cc_setup)\n\n cc_protocol = QLabel(\n \"<b>协议转换能力</b><br>\"\n \"CC Switch 自动完成 Anthropic Messages ↔ OpenAI Chat/Responses ↔ Gemini Native 格式互转。\"\n \"达妮娅发出的是 OpenAI 格式请求,CC Switch 可将其转发到任何供应商。\"\n )\n cc_protocol.setWordWrap(True)\n cc_content_layout.addWidget(cc_protocol)\n\n cc_links = QHBoxLayout()\n cc_gh_btn = QPushButton(\" GitHub 仓库\"); cc_gh_btn.setIcon(get_icon(\"internet\"))\n cc_gh_btn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(\"https://github.com/farion1231/cc-switch\")))\n cc_dl_btn = QPushButton(\" 下载 CC Switch\"); cc_dl_btn.setIcon(get_icon(\"download\"))\n cc_dl_btn.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(\"https://github.com/farion1231/cc-switch/releases\")))\n cc_fill_btn = QPushButton(\" 填入达妮娅配置\")\n cc_fill_btn.setIcon(get_icon(\"chip\"))\n def _fill_cc():\n display = next((d for d, v in self._provider_display_map.items() if v == \"custom_cloud\"), None)\n if display:\n self.provider_input.setCurrentText(display)\n self.base_url_input.setText(\"http://127.0.0.1:15721/v1\")\n dialog.accept()\n cc_fill_btn.clicked.connect(_fill_cc)\n cc_links.addWidget(cc_gh_btn); cc_links.addWidget(cc_dl_btn); cc_links.addWidget(cc_fill_btn); cc_links.addStretch(1)\n cc_content_layout.addLayout(cc_links)\n\n cc_content_layout.addStretch(1)\n cc_scroll.setWidget(cc_content)\n cc_layout.addWidget(cc_scroll)\n tabs.addTab(cc_tab, get_icon(\"download\"), \"CC Switch 代理\")\n\n # ── Tab 3: 自部署代理 ──\n custom_tab = QWidget()\n custom_layout = QVBoxLayout(custom_tab)\n custom_scroll = QScrollArea()\n custom_scroll.setWidgetResizable(True)\n custom_content = QWidget()\n custom_content_layout = QVBoxLayout(custom_content)\n custom_content_layout.setSpacing(10)\n\n custom_title = QLabel(\n \"<b>自部署 API 代理 / 中转</b><br>\"\n \"如果你不想用 CC Switch,也可以自己搭建代理服务。以下方案均可提供 OpenAI 兼容端点,\"\n \"填入达妮娅的「自定义云端 (Custom)」即可使用。\"\n )\n custom_title.setWordWrap(True)\n custom_content_layout.addWidget(custom_title)\n\n # one-api\n oa_card = QWidget()\n oa_card.setStyleSheet(\"QWidget#proxyCard { background:#f8f9fa; border:1px solid #e1e4e8; border-radius:6px; }\")\n oa_card.setObjectName(\"proxyCard\")\n oa_inner = QVBoxLayout(oa_card)\n oa_inner.addWidget(QLabel(\"<b>one-api</b> — 最成熟的中文社区 OpenAI 代理管理面板\"))\n oa_inner.addWidget(QLabel(\n \"• GitHub: <a href='https://github.com/songquanpeng/one-api'>songquanpeng/one-api</a><br>\"\n \"• 部署: <code>docker run -d -p 3000:3000 justsong/one-api</code><br>\"\n \"• 功能: 多供应商管理、Key 池、额度控制、用量统计、Web 管理面板<br>\"\n \"• 支持 30+ 供应商: OpenAI / Claude / Gemini / DeepSeek / 智谱 / 讯飞 / 百度 / 阿里 / 腾讯 / 字节<br>\"\n \"• 接入达妮娅: 自定义云端 → <code>http://你的服务器:3000/v1</code> → 在面板中选的模型名\"\n ))\n oa_btn_row = QHBoxLayout()\n oa_gh = QPushButton(\" GitHub\"); oa_gh.setIcon(get_icon(\"internet\"))\n oa_gh.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(\"https://github.com/songquanpeng/one-api\")))\n oa_btn_row.addWidget(oa_gh); oa_btn_row.addStretch(1)\n oa_inner.addLayout(oa_btn_row)\n custom_content_layout.addWidget(oa_card)\n\n # new-api\n na_card = QWidget()\n na_card.setStyleSheet(\"QWidget#proxyCard { background:#f8f9fa; border:1px solid #e1e4e8; border-radius:6px; }\")\n na_card.setObjectName(\"proxyCard\")\n na_inner = QVBoxLayout(na_card)\n na_inner.addWidget(QLabel(\"<b>new-api</b> — one-api 增强分支,界面更现代\"))\n na_inner.addWidget(QLabel(\n \"• GitHub: <a href='https://github.com/Calcium-Ion/new-api'>Calcium-Ion/new-api</a><br>\"\n \"• 部署: <code>docker run -d -p 3000:3000 calciumion/new-api</code><br>\"\n \"• 额外功能: 马甲包、RPM/TPM 精细化控制、数据看板<br>\"\n \"• 接入达妮娅: 同 one-api,自定义云端 → <code>http://你的服务器:3000/v1</code>\"\n ))\n na_btn_row = QHBoxLayout()\n na_gh = QPushButton(\" GitHub\"); na_gh.setIcon(get_icon(\"internet\"))\n na_gh.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(\"https://github.com/Calcium-Ion/new-api\")))\n na_btn_row.addWidget(na_gh); na_btn_row.addStretch(1)\n na_inner.addLayout(na_btn_row)\n custom_content_layout.addWidget(na_card)\n\n # AI Worker Proxy\n aw_card = QWidget()\n aw_card.setStyleSheet(\"QWidget#proxyCard { background:#f8f9fa; border:1px solid #e1e4e8; border-radius:6px; }\")\n aw_card.setObjectName(\"proxyCard\")\n aw_inner = QVBoxLayout(aw_card)\n aw_inner.addWidget(QLabel(\"<b>AI Worker Proxy</b> — 免费代理方案\"))\n aw_inner.addWidget(QLabel(\n \"• GitHub: <a href='https://github.com/zxcloli666/AI-Worker-Proxy'>zxcloli666/AI-Worker-Proxy</a><br>\"\n \"• 部署: 复制代码到 Cloudflare Workers → 1 分钟上线<br>\"\n \"• 功能: 自动故障转移、Token 轮换、免费托管<br>\"\n \"• 注意: Cloudflare 在国内部分网络可能较慢<br>\"\n \"• 接入达妮娅: 自定义云端 → <code>https://你的worker名.workers.dev/v1</code>\"\n ))\n aw_btn_row = QHBoxLayout()\n aw_gh = QPushButton(\" GitHub\"); aw_gh.setIcon(get_icon(\"internet\"))\n aw_gh.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(\"https://github.com/zxcloli666/AI-Worker-Proxy\")))\n aw_btn_row.addWidget(aw_gh); aw_btn_row.addStretch(1)\n aw_inner.addLayout(aw_btn_row)\n custom_content_layout.addWidget(aw_card)\n\n # Proxify\n px_card = QWidget()\n px_card.setStyleSheet(\"QWidget#proxyCard { background:#f8f9fa; border:1px solid #e1e4e8; border-radius:6px; }\")\n px_card.setObjectName(\"proxyCard\")\n px_inner = QVBoxLayout(px_card)\n px_inner.addWidget(QLabel(\"<b>Proxify</b> — 轻量级 Go 实现,适合低配 VPS\"))\n px_inner.addWidget(QLabel(\n \"• GitHub: <a href='https://github.com/poixeai/proxify'>poixeai/proxify</a><br>\"\n \"• 部署: 单二进制文件,10MB 以内,一行命令启动<br>\"\n \"• 接入达妮娅: 自定义云端 → <code>http://你的VPS:端口/v1</code>\"\n ))\n px_btn_row = QHBoxLayout()\n px_gh = QPushButton(\" GitHub\"); px_gh.setIcon(get_icon(\"internet\"))\n px_gh.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(\"https://github.com/poixeai/proxify\")))\n px_btn_row.addWidget(px_gh); px_btn_row.addStretch(1)\n px_inner.addLayout(px_btn_row)\n custom_content_layout.addWidget(px_card)\n\n # 通用接入公式\n formula = QLabel(\n \"<b>接入达妮娅的通用公式</b><br>\"\n \"无论用哪种代理方案,在达妮娅中只需填 3 个字段:<br><br>\"\n \"&nbsp;&nbsp;地址 = <code>&lt;你的代理地址&gt;/v1</code><br>\"\n \"&nbsp;&nbsp;模型 = 代理转发的模型名<br>\"\n \"&nbsp;&nbsp;密钥 = 代理要求的密钥(没有就任意填)<br><br>\"\n \"达妮娅只做 <code>POST {Base URL}/chat/completions</code> + Bearer Auth,<b>不区分厂商、不校验来源、不做白名单</b>。\"\n )\n formula.setWordWrap(True)\n formula.setStyleSheet(\"background:#f0f7ff; border:1px solid #b6d4fe; border-radius:6px; padding:10px;\")\n custom_content_layout.addWidget(formula)\n\n custom_content_layout.addStretch(1)\n custom_scroll.setWidget(custom_content)\n custom_layout.addWidget(custom_scroll)\n tabs.addTab(custom_tab, get_icon(\"host\"), \"自部署代理\")\n\n layout.addWidget(tabs)\n\n close_btn = QPushButton(\"关闭\"); close_btn.setIcon(get_icon(\"settings\"))\n close_btn.clicked.connect(dialog.accept)\n layout.addWidget(close_btn, alignment=Qt.AlignmentFlag.AlignRight)\n dialog.exec()\n\n def _build_help_card(self, name: str, provider_key: str, url: str, model: str,\n reg_url: str, desc: str, dialog: QDialog) -> QWidget:\n card = QWidget()\n card.setStyleSheet(\n \"QWidget#apiHelpCard { background: #f8f9fa; border: 1px solid #e1e4e8; \"\n \"border-radius: 6px; } QWidget#apiHelpCard:hover { border-color: #0366d6; }\"\n )\n card.setObjectName(\"apiHelpCard\")\n cl = QVBoxLayout(card)\n cl.setContentsMargins(10, 6, 10, 6)\n\n tr = QHBoxLayout()\n tr.addWidget(QLabel(f\"<b>{name}</b>\")); tr.addStretch(1)\n if provider_key in Provider.all_cloud():\n tag = QLabel(\"预设\"); tag.setStyleSheet(\"color:#22863a;font-size:10px;border:1px solid #22863a;border-radius:3px;padding:0 4px;\")\n else:\n tag = QLabel(\"自定义\"); tag.setStyleSheet(\"color:#6f42c1;font-size:10px;border:1px solid #6f42c1;border-radius:3px;padding:0 4px;\")\n tr.addWidget(tag); cl.addLayout(tr)\n\n info = QLabel(desc); info.setWordWrap(True); info.setStyleSheet(\"color:#586069;font-size:11px;\"); cl.addWidget(info)\n cl.addWidget(QLabel(f\"<span style='color:#586069;font-size:11px;'>地址</span> <code style='font-size:11px;'>{url}</code>\"))\n cl.addWidget(QLabel(f\"<span style='color:#586069;font-size:11px;'>模型</span> <code style='font-size:11px;'>{model}</code>\"))\n\n br = QHBoxLayout()\n reg_btn = QPushButton(\" 注册获取密钥\"); reg_btn.setIcon(get_icon(\"internet\"))\n reg_btn.setFixedHeight(26); reg_btn.setStyleSheet(\"font-size:11px;\")\n reg_btn.clicked.connect(lambda _, u=reg_url: QDesktopServices.openUrl(QUrl(u)))\n br.addWidget(reg_btn)\n\n copy_btn = QPushButton(\" 复制地址\"); copy_btn.setFixedHeight(26); copy_btn.setStyleSheet(\"font-size:11px;\")\n def _mkcopy(_u, _b):\n def _h(): QApplication.clipboard().setText(_u); _b.setText(\" 已复制!\")\n return _h\n copy_btn.clicked.connect(_mkcopy(url, copy_btn))\n br.addWidget(copy_btn)\n\n def _mkfill(_k, _u, _m):\n def _h():\n d = next((x for x, v in self._provider_display_map.items() if v == _k), None)\n if d: self.provider_input.setCurrentText(d)\n self.base_url_input.setText(_u); self.model_input.setText(_m); dialog.accept()\n return _h\n fill_btn = QPushButton(\" 填入配置\"); fill_btn.setFixedHeight(26); fill_btn.setStyleSheet(\"font-size:11px;\")\n fill_btn.clicked.connect(_mkfill(provider_key, url, model))\n br.addWidget(fill_btn); br.addStretch(1); cl.addLayout(br)\n return card\n\n def _clear_api_key(self) -> None:\n \"\"\"清除当前选中服务商的密钥。\"\"\"\n provider = self._current_provider_key()\n env_key = ProviderMeta.get_api_key_env(provider)\n if not env_key:\n QMessageBox.information(self, \"提示\", f\"{provider} 不需要密钥(通过本地服务连接)。\")\n return\n reply = QMessageBox.question(\n self, \"确认清除\",\n f\"确定要从 .env 中删除 {env_key} 吗?\\n此操作不可恢复,之后需要重新填写。\",\n QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,\n )\n if reply == QMessageBox.StandardButton.Yes:\n self.settings_manager.clear_api_key(env_key)\n self.api_key_input.clear()\n self.api = self.settings_manager.load_api_config()\n self._update_saved_api_key_label(provider, self.api.get(\"providers\", {}).get(provider, {}))\n self.api_result.setText(f\"已清除 {env_key}。\")\n\n def _reset_current_provider(self) -> None:\n \"\"\"将当前服务商恢复到默认地址和模型。\"\"\"\n provider = self._current_provider_key()\n meta = ProviderMeta.get(provider)\n reply = QMessageBox.question(\n self, \"确认重置\",\n f\"将 {provider} 的地址和模型恢复为默认值:\\n\\n\"\n f\"地址: {meta['base_url']}\\n模型: {meta['default_model']}\\n\\n继续?\",\n QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,\n )\n if reply == QMessageBox.StandardButton.Yes:\n self.base_url_input.setText(meta[\"base_url\"])\n self.model_input.setText(meta[\"default_model\"])\n self.auth_header_input.setCurrentText(str(meta.get(\"auth_header\", \"bearer\")))\n self.api_result.setText(f\"{provider} 已重置为默认值(需点击保存和设为当前模型生效)。\")\n\n def _clear_local_config(self) -> None:\n \"\"\"清空本地模型配置。\"\"\"\n reply = QMessageBox.question(\n self, \"确认清空\",\n \"确定要清空本地模型配置吗?\\n这将清空服务类型、地址和模型名称。\",\n QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,\n )\n if reply == QMessageBox.StandardButton.Yes:\n self.local_service_combo.setCurrentIndex(0)\n self.local_base_url.clear()\n self.local_model_list.clear()\n self.local_status.setText(\"状态:已清空本地配置\")\n self.local_status.setStyleSheet(\"color: #6c757d;\")\n\n def _reset_multimodal(self, section: str) -> None:\n \"\"\"重置多模态配置为 none。\"\"\"\n config = self.settings_manager.load_multimodal_config()\n if section == \"tts\":\n config[\"tts\"] = \"none\"\n config[\"tts_lang\"] = \"zh-CN\"\n self.tts_combo.setCurrentText(\"none\")\n self.tts_lang.setCurrentText(\"zh-CN\")\n elif section == \"image\":\n config[\"image\"] = \"none\"\n config[\"image_to_image\"] = \"none\"\n self.t2i_combo.setCurrentText(\"none\")\n self.i2i_combo.setCurrentText(\"none\")\n elif section == \"video\":\n config[\"video\"] = \"none\"\n self.video_combo.setCurrentText(\"none\")\n self.settings_manager.save_multimodal_config(config)\n QMessageBox.information(self, \"已重置\", f\"多模态 {section} 已重置为 none。\")\n\n def _save_multimodal(self, section: str) -> None:\n \"\"\"保存多模态配置。\"\"\"\n config = self.settings_manager.load_multimodal_config()\n if section == \"tts\":\n config[\"tts\"] = self.tts_combo.currentText()\n config[\"tts_lang\"] = self.tts_lang.currentText()\n elif section == \"image\":\n config[\"image\"] = self.t2i_combo.currentText()\n config[\"image_to_image\"] = self.i2i_combo.currentText()\n elif section == \"video\":\n config[\"video\"] = self.video_combo.currentText()\n self.settings_manager.save_multimodal_config(config)\n QMessageBox.information(self, \"已保存\", f\"多模态 {section} 配置已保存。\")\n self.controller.window.speak(f\"……多模态{section}配置保存好啦。别又忘了哦。\")\n self.controller.window.animation_manager.trigger_happy()\n\n def _import_custom_model(self) -> None:\n dialog = QDialog(self)\n dialog.setWindowTitle(\"导入自定义模型\")\n dialog.setMinimumWidth(500)\n form = QFormLayout(dialog)\n\n name_input = QLineEdit(); name_input.setPlaceholderText(\"my-custom-model\")\n provider_combo = QComboBox()\n provider_combo.addItems([Provider.LOCAL_OPENAI_COMPATIBLE, Provider.LLAMA_CPP, Provider.LM_STUDIO, Provider.CUSTOM])\n url_input = QLineEdit(); url_input.setPlaceholderText(\"http://localhost:8080/v1\")\n model_input = QLineEdit(); model_input.setPlaceholderText(\" custom-model-name\")\n desc_input = QLineEdit(); desc_input.setPlaceholderText(\"我的魔改模型 · 0.5B · ~500MB\")\n\n form.addRow(\"模型名称\", name_input)\n form.addRow(\"服务商类型\", provider_combo)\n form.addRow(\"地址\", url_input)\n form.addRow(\"模型 ID\", model_input)\n form.addRow(\"描述\", desc_input)\n\n btn_row = QHBoxLayout()\n ok = QPushButton(\"导入\"); ok.setIcon(get_icon(\"save\"))\n cancel = QPushButton(\"取消\"); cancel.setIcon(get_icon(\"settings\"))\n btn_row.addStretch(1); btn_row.addWidget(ok); btn_row.addWidget(cancel)\n form.addRow(btn_row)\n\n def do_import():\n name = name_input.text().strip()\n url = url_input.text().strip()\n model = model_input.text().strip()\n if not name or not url or not model:\n QMessageBox.warning(dialog, \"错误\", \"请填写模型名称、地址和模型标识。\")\n return\n provider = provider_combo.currentText()\n self.settings_manager.save_local_model_profile(\n provider=provider, base_url=url, model=model, service_label=name,\n )\n self.local_model_list.addItem(model)\n self.local_model_list.setCurrentText(model)\n self.local_service_combo.setCurrentText(\n next((s for s in [self.local_service_combo.itemText(i) for i in range(self.local_service_combo.count())]\n if provider in s.lower().replace(\" \", \"_\") or s.lower().startswith(provider[:4])),\n self.local_service_combo.currentText()))\n self.local_base_url.setText(url)\n self.local_status.setText(f\"状态:已导入 {name} ({model})\")\n self._refresh_profile_switcher()\n dialog.accept()\n\n ok.clicked.connect(do_import)\n cancel.clicked.connect(dialog.reject)\n dialog.exec()\n\n\ndef _profile_has_text(profile: dict[str, Any]) -> bool:\n capabilities = profile.get(\"capabilities\", [])\n return str(profile.get(\"type\", \"\")) == \"text\" or (isinstance(capabilities, list) and \"text\" in capabilities)\n"}}, "config/model_profiles.json": {"5c229bd8577b0663a65d350451508996a63502d7": {"t": 1785745284.1709285, "n": 1826, "h": "a153f014d5d0cd5b", "c": "{\n \"active_text_profile_id\": \"deepseek_default\",\n \"active_vision_profile_id\": \"\",\n \"active_tts_profile_id\": \"\",\n \"active_image_profile_id\": \"\",\n \"profiles\": [\n {\n \"id\": \"deepseek_default\",\n \"name\": \"DeepSeek 默认\",\n \"type\": \"text\",\n \"provider\": \"deepseek\",\n \"api_style\": \"openai_compatible\",\n \"base_url\": \"https://api.deepseek.com\",\n \"model\": \"deepseek-chat\",\n \"api_key_env\": \"DEEPSEEK_API_KEY\",\n \"enabled\": true,\n \"capabilities\": [\n \"text\"\n ],\n \"source\": \"cloud\"\n },\n {\n \"id\": \"openai_default\",\n \"name\": \"OpenAI GPT-4o\",\n \"type\": \"text\",\n \"provider\": \"openai\",\n \"api_style\": \"openai_compatible\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"model\": \"gpt-4o\",\n \"api_key_env\": \"OPENAI_API_KEY\",\n \"enabled\": true,\n \"capabilities\": [\n \"text\"\n ],\n \"source\": \"cloud\"\n },\n {\n \"id\": \"zai_default\",\n \"name\": \"Z.AI (GLM) 默认\",\n \"type\": \"text\",\n \"provider\": \"zai\",\n \"api_style\": \"openai_compatible\",\n \"base_url\": \"https://api.z.ai/api/paas/v4\",\n \"model\": \"glm-5.1\",\n \"api_key_env\": \"ZAI_API_KEY\",\n \"auth_header\": \"bearer\",\n \"enabled\": true,\n \"capabilities\": [\n \"text\"\n ],\n \"source\": \"cloud\"\n },\n {\n \"id\": \"ollama_qwen25_05b\",\n \"name\": \"Ollama qwen2.5:0.5b\",\n \"type\": \"text\",\n \"provider\": \"ollama\",\n \"api_style\": \"ollama\",\n \"base_url\": \"http://localhost:11434\",\n \"model\": \"qwen2.5:0.5b\",\n \"enabled\": true,\n \"capabilities\": [\n \"text\"\n ],\n \"source\": \"local\",\n \"license_required\": true,\n \"api_key_env\": \"\"\n }\n ],\n \"profile_history\": {\n \"text\": [\n \"deepseek_default\"\n ],\n \"vision\": [],\n \"tts\": [],\n \"image\": []\n }\n}"}, "f3356fa1c7a37cccc1c3bb253f43a96dc8d92505": {"t": 1785749695.2570698, "n": 1826, "h": "a153f014d5d0cd5b", "c": "{\n \"active_text_profile_id\": \"deepseek_default\",\n \"active_vision_profile_id\": \"\",\n \"active_tts_profile_id\": \"\",\n \"active_image_profile_id\": \"\",\n \"profiles\": [\n {\n \"id\": \"deepseek_default\",\n \"name\": \"DeepSeek 默认\",\n \"type\": \"text\",\n \"provider\": \"deepseek\",\n \"api_style\": \"openai_compatible\",\n \"base_url\": \"https://api.deepseek.com\",\n \"model\": \"deepseek-chat\",\n \"api_key_env\": \"DEEPSEEK_API_KEY\",\n \"enabled\": true,\n \"capabilities\": [\n \"text\"\n ],\n \"source\": \"cloud\"\n },\n {\n \"id\": \"openai_default\",\n \"name\": \"OpenAI GPT-4o\",\n \"type\": \"text\",\n \"provider\": \"openai\",\n \"api_style\": \"openai_compatible\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"model\": \"gpt-4o\",\n \"api_key_env\": \"OPENAI_API_KEY\",\n \"enabled\": true,\n \"capabilities\": [\n \"text\"\n ],\n \"source\": \"cloud\"\n },\n {\n \"id\": \"zai_default\",\n \"name\": \"Z.AI (GLM) 默认\",\n \"type\": \"text\",\n \"provider\": \"zai\",\n \"api_style\": \"openai_compatible\",\n \"base_url\": \"https://api.z.ai/api/paas/v4\",\n \"model\": \"glm-5.1\",\n \"api_key_env\": \"ZAI_API_KEY\",\n \"auth_header\": \"bearer\",\n \"enabled\": true,\n \"capabilities\": [\n \"text\"\n ],\n \"source\": \"cloud\"\n },\n {\n \"id\": \"ollama_qwen25_05b\",\n \"name\": \"Ollama qwen2.5:0.5b\",\n \"type\": \"text\",\n \"provider\": \"ollama\",\n \"api_style\": \"ollama\",\n \"base_url\": \"http://localhost:11434\",\n \"model\": \"qwen2.5:0.5b\",\n \"enabled\": true,\n \"capabilities\": [\n \"text\"\n ],\n \"source\": \"local\",\n \"license_required\": true,\n \"api_key_env\": \"\"\n }\n ],\n \"profile_history\": {\n \"text\": [\n \"deepseek_default\"\n ],\n \"vision\": [],\n \"tts\": [],\n \"image\": []\n }\n}"}, "b8c00435b9498d31774b09dc20b77b414f3ba4ba": {"t": 1785749696.4222145, "n": 1827, "h": "a7c3906b8522bbc8", "c": "{\n \"active_text_profile_id\": \"deepseek_default\",\n \"active_vision_profile_id\": \"\",\n \"active_tts_profile_id\": \"\",\n \"active_image_profile_id\": \"\",\n \"profiles\": [\n {\n \"id\": \"deepseek_default\",\n \"name\": \"DeepSeek 默认\",\n \"type\": \"text\",\n \"provider\": \"deepseek\",\n \"api_style\": \"openai_compatible\",\n \"base_url\": \"https://api.deepseek.com\",\n \"model\": \"deepseek-chat\",\n \"api_key_env\": \"DEEPSEEK_API_KEY\",\n \"enabled\": true,\n \"capabilities\": [\n \"text\"\n ],\n \"source\": \"cloud\"\n },\n {\n \"id\": \"openai_default\",\n \"name\": \"OpenAI GPT-4o\",\n \"type\": \"text\",\n \"provider\": \"openai\",\n \"api_style\": \"openai_compatible\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"model\": \"gpt-4o\",\n \"api_key_env\": \"OPENAI_API_KEY\",\n \"enabled\": true,\n \"capabilities\": [\n \"text\"\n ],\n \"source\": \"cloud\"\n },\n {\n \"id\": \"zai_default\",\n \"name\": \"Z.AI (GLM) 默认\",\n \"type\": \"text\",\n \"provider\": \"zai\",\n \"api_style\": \"openai_compatible\",\n \"base_url\": \"https://api.z.ai/api/paas/v4\",\n \"model\": \"glm-5.1\",\n \"api_key_env\": \"ZAI_API_KEY\",\n \"auth_header\": \"bearer\",\n \"enabled\": true,\n \"capabilities\": [\n \"text\"\n ],\n \"source\": \"cloud\"\n },\n {\n \"id\": \"ollama_qwen25_05b\",\n \"name\": \"Ollama qwen2.5:0.5b\",\n \"type\": \"text\",\n \"provider\": \"ollama\",\n \"api_style\": \"ollama\",\n \"base_url\": \"http://localhost:11434\",\n \"model\": \"qwen2.5:0.5b\",\n \"enabled\": true,\n \"capabilities\": [\n \"text\"\n ],\n \"source\": \"local\",\n \"license_required\": true,\n \"api_key_env\": \"\"\n }\n ],\n \"profile_history\": {\n \"text\": [\n \"deepseek_default\"\n ],\n \"vision\": [],\n \"tts\": [],\n \"image\": []\n }\n}\n"}, "5399ab600f64fc3d3659abe2d48d8728dc365203": {"t": 1785749697.5644112, "n": 1431, "h": "463899e45008fca4", "c": "{\n \"active_text_profile_id\": \"deepseek_default\",\n \"active_vision_profile_id\": \"\",\n \"active_tts_profile_id\": \"\",\n \"active_image_profile_id\": \"\",\n \"profiles\": [\n {\n \"id\": \"deepseek_default\",\n \"name\": \"DeepSeek 默认\",\n \"type\": \"text\",\n \"provider\": \"deepseek\",\n \"api_style\": \"openai_compatible\",\n \"base_url\": \"https://api.deepseek.com\",\n \"model\": \"deepseek-chat\",\n \"api_key_env\": \"DEEPSEEK_API_KEY\",\n \"enabled\": true,\n \"capabilities\": [\n \"text\"\n ],\n \"source\": \"cloud\"\n },\n {\n \"id\": \"openai_default\",\n \"name\": \"OpenAI GPT-4o\",\n \"type\": \"text\",\n \"provider\": \"openai\",\n \"api_style\": \"openai_compatible\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"model\": \"gpt-4o\",\n \"api_key_env\": \"OPENAI_API_KEY\",\n \"enabled\": true,\n \"capabilities\": [\n \"text\"\n ],\n \"source\": \"cloud\"\n },\n {\n \"id\": \"ollama_qwen25_05b\",\n \"name\": \"Ollama qwen2.5:0.5b\",\n \"type\": \"text\",\n \"provider\": \"ollama\",\n \"api_style\": \"ollama\",\n \"base_url\": \"http://localhost:11434\",\n \"model\": \"qwen2.5:0.5b\",\n \"enabled\": true,\n \"capabilities\": [\n \"text\"\n ],\n \"source\": \"local\",\n \"license_required\": true,\n \"api_key_env\": \"\"\n }\n ],\n \"profile_history\": {\n \"text\": [\n \"deepseek_default\"\n ],\n \"vision\": [],\n \"tts\": [],\n \"image\": []\n }\n}\n"}, "7c3e9c4c8bacffabea3adc200ab3e2504b574a76": {"t": 1785749699.6832793, "n": 1307, "h": "2f742f40cdafac27", "c": "{\n \"active_text_profile_id\": \"deepseek_default\",\n \"active_vision_profile_id\": \"\",\n \"active_tts_profile_id\": \"\",\n \"active_image_profile_id\": \"\",\n \"profiles\": [\n {\n \"id\": \"deepseek_default\",\n \"name\": \"DeepSeek 默认\",\n \"type\": \"text\",\n \"provider\": \"deepseek\",\n \"api_style\": \"openai_compatible\",\n \"base_url\": \"https://api.deepseek.com\",\n \"model\": \"deepseek-chat\",\n \"api_key_env\": \"DEEPSEEK_API_KEY\",\n \"enabled\": true,\n \"capabilities\": [\n \"text\"\n ],\n \"source\": \"cloud\"\n },\n {\n \"id\": \"openai_default\",\n \"name\": \"OpenAI GPT-4o\",\n \"type\": \"text\",\n \"provider\": \"openai\",\n \"api_style\": \"openai_compatible\",\n \"base_url\": \"https://api.openai.com/v1\",\n \"model\": \"gpt-4o\",\n \"api_key_env\": \"OPENAI_API_KEY\",\n \"enabled\": true,\n \"capabilities\": [\n \"text\"\n ],\n \"source\": \"cloud\"\n },\n {\n \"id\": \"ollama_qwen25_05b\",\n \"name\": \"Ollama qwen2.5:0.5b\",\n \"type\": \"text\",\n \"provider\": \"ollama\",\n \"api_style\": \"ollama\",\n \"base_url\": \"http://localhost:11434\",\n \"model\": \"qwen2.5:0.5b\",\n \"enabled\": true,\n \"capabilities\": [\n \"text\"\n ],\n \"source\": \"local\",\n \"license_required\": true,\n \"api_key_env\": \"\"\n }\n ]\n}"}}}}