vox-code 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. vox_code-2.0.0.dist-info/METADATA +258 -0
  2. vox_code-2.0.0.dist-info/RECORD +88 -0
  3. vox_code-2.0.0.dist-info/WHEEL +4 -0
  4. vox_code-2.0.0.dist-info/entry_points.txt +3 -0
  5. voxcli/__init__.py +3 -0
  6. voxcli/__main__.py +5 -0
  7. voxcli/agent/__init__.py +12 -0
  8. voxcli/agent/agent.py +449 -0
  9. voxcli/agent/agent_budget.py +133 -0
  10. voxcli/agent/agent_orchestrator.py +414 -0
  11. voxcli/agent/plan_execute_agent.py +514 -0
  12. voxcli/agent/roles.py +80 -0
  13. voxcli/agent/sub_agent.py +351 -0
  14. voxcli/catalog.py +477 -0
  15. voxcli/chat.py +91 -0
  16. voxcli/cli/__init__.py +4 -0
  17. voxcli/cli/main.py +452 -0
  18. voxcli/cli/parser.py +71 -0
  19. voxcli/config.py +518 -0
  20. voxcli/gui/__main__.py +3 -0
  21. voxcli/gui/main.py +22 -0
  22. voxcli/gui/pet/__init__.py +5 -0
  23. voxcli/gui/pet/base.py +62 -0
  24. voxcli/gui/pet/coordinator.py +888 -0
  25. voxcli/gui/pet/data.py +430 -0
  26. voxcli/gui/pet/widgets.py +683 -0
  27. voxcli/gui/pet/windows.py +2298 -0
  28. voxcli/gui/pet/workers.py +54 -0
  29. voxcli/gui/pet_app.py +7 -0
  30. voxcli/hitl/__init__.py +11 -0
  31. voxcli/hitl/handler.py +11 -0
  32. voxcli/hitl/policy.py +32 -0
  33. voxcli/hitl/request.py +13 -0
  34. voxcli/hitl/result.py +11 -0
  35. voxcli/hitl/terminal_handler.py +64 -0
  36. voxcli/hitl/tool_registry.py +64 -0
  37. voxcli/llm/base.py +93 -0
  38. voxcli/llm/factory.py +178 -0
  39. voxcli/llm/ollama_client.py +137 -0
  40. voxcli/llm/openai_compatible.py +249 -0
  41. voxcli/memory/base.py +16 -0
  42. voxcli/memory/budget.py +53 -0
  43. voxcli/memory/compressor.py +198 -0
  44. voxcli/memory/entry.py +36 -0
  45. voxcli/memory/long_term.py +126 -0
  46. voxcli/memory/manager.py +101 -0
  47. voxcli/memory/retriever.py +72 -0
  48. voxcli/memory/short_term.py +84 -0
  49. voxcli/memory/tokenizer.py +21 -0
  50. voxcli/plan/__init__.py +5 -0
  51. voxcli/plan/execution_plan.py +225 -0
  52. voxcli/plan/planner.py +198 -0
  53. voxcli/plan/task.py +123 -0
  54. voxcli/policy/audit_log.py +111 -0
  55. voxcli/policy/command_guard.py +34 -0
  56. voxcli/policy/exception.py +5 -0
  57. voxcli/policy/path_guard.py +32 -0
  58. voxcli/prompting/__init__.py +7 -0
  59. voxcli/prompting/presenter.py +154 -0
  60. voxcli/rag/__init__.py +16 -0
  61. voxcli/rag/analyzer.py +89 -0
  62. voxcli/rag/chunk.py +17 -0
  63. voxcli/rag/chunker.py +137 -0
  64. voxcli/rag/embedding.py +75 -0
  65. voxcli/rag/formatter.py +40 -0
  66. voxcli/rag/index.py +96 -0
  67. voxcli/rag/relation.py +14 -0
  68. voxcli/rag/retriever.py +58 -0
  69. voxcli/rag/store.py +155 -0
  70. voxcli/rag/tokenizer.py +26 -0
  71. voxcli/runtime/__init__.py +6 -0
  72. voxcli/runtime/session_controller.py +386 -0
  73. voxcli/tool/__init__.py +3 -0
  74. voxcli/tool/tool_registry.py +433 -0
  75. voxcli/util/animation.py +219 -0
  76. voxcli/util/ansi.py +82 -0
  77. voxcli/util/markdown.py +98 -0
  78. voxcli/web/__init__.py +17 -0
  79. voxcli/web/base.py +20 -0
  80. voxcli/web/extractor.py +77 -0
  81. voxcli/web/factory.py +38 -0
  82. voxcli/web/fetch_result.py +27 -0
  83. voxcli/web/fetcher.py +42 -0
  84. voxcli/web/network_policy.py +49 -0
  85. voxcli/web/result.py +23 -0
  86. voxcli/web/searxng.py +55 -0
  87. voxcli/web/serpapi.py +53 -0
  88. voxcli/web/zhipu.py +55 -0
@@ -0,0 +1,888 @@
1
+ """Top-level coordinator for the desktop pet UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from PySide6.QtCore import QPoint, QRect, Qt, QTimer
8
+ from PySide6.QtGui import QAction, QColor, QIcon, QPainter, QPen, QPixmap, QPolygon
9
+ from PySide6.QtWidgets import QApplication, QFileDialog, QMenu, QSystemTrayIcon, QWidget
10
+
11
+ from ...chat import GuiChatSubmission
12
+ from ...config import GuiModelConfig, GuiModelConfigStore, pai_config
13
+ from ...llm.factory import create_from_config, create_from_provider_config
14
+ from ...runtime import SessionController, SessionReply
15
+ from .data import (
16
+ BUILTIN_PETS,
17
+ SKIN_PALETTES,
18
+ ChatMessage,
19
+ GuiStateStore,
20
+ PetPackage,
21
+ PetPackageStore,
22
+ gui_model_profile_label,
23
+ )
24
+ from .widgets import PetWidget
25
+ from .windows import ChatWindow, CommandWindow, FloatingActionBar, GuiModelSettingsWindow, PetManagerWindow, PersonalityWindow, StatusCardWidget
26
+ from .workers import SessionWorker
27
+
28
+
29
+ class PetCoordinator(QWidget):
30
+ def __init__(self):
31
+ super().__init__()
32
+ self._gui_model_store = GuiModelConfigStore()
33
+ self._gui_model_config = GuiModelConfig()
34
+ self._gui_model_source = "跟随全局"
35
+ self._gui_model_warning = ""
36
+ self.controller = SessionController(
37
+ llm_client=self._build_initial_gui_llm_client(),
38
+ allow_model_switch_commands=False,
39
+ )
40
+ self._state_store = GuiStateStore()
41
+ self._pet_store = PetPackageStore()
42
+ self._settings_window: GuiModelSettingsWindow | None = None
43
+ self._pet_manager: PetManagerWindow | None = None
44
+ self._personality_window: PersonalityWindow | None = None
45
+ self.current_skin = self._state_store.load_skin()
46
+ self.current_pet_id = self._state_store.load_selected_pet()
47
+ self.current_language = pai_config.active_language
48
+ self._pets: list[PetPackage] = []
49
+ self.worker: SessionWorker | None = None
50
+ self._toolbar_hide_timer = QTimer(self)
51
+ self._toolbar_hide_timer.setSingleShot(True)
52
+ self._toolbar_hide_timer.setInterval(220)
53
+ self._toolbar_hide_timer.timeout.connect(self._hide_toolbar_if_idle)
54
+
55
+ self.pet = PetWidget(self.controller)
56
+ self.chat = ChatWindow(self.controller)
57
+ self.commands = CommandWindow()
58
+ self.toolbar = FloatingActionBar()
59
+ self.status_card = StatusCardWidget()
60
+ self._pet_state = "idle"
61
+ self.pet.toggled.connect(self.toggle_chat)
62
+ self.pet.request_quit.connect(QApplication.instance().quit)
63
+ self.pet.hover_changed.connect(self._on_pet_hover_changed)
64
+ self.pet.position_changed.connect(self._sync_toolbar_position)
65
+ self.pet.position_changed.connect(self._sync_status_card_position)
66
+ self.pet.cycle_pet_requested.connect(self._cycle_pet_action)
67
+ self.pet.context_menu_requested.connect(self._show_pet_context_menu)
68
+ self.chat.submitted.connect(self.submit_line)
69
+ self.chat.attachment_error.connect(self._handle_attachment_error)
70
+ self.commands.submitted.connect(self.submit_line)
71
+ self.toolbar.chat_requested.connect(self.toggle_chat)
72
+ self.toolbar.commands_requested.connect(self.toggle_commands)
73
+ self.toolbar.hover_changed.connect(self._on_toolbar_hover_changed)
74
+
75
+ self.status_card.set_status(
76
+ mode=self.controller.mode,
77
+ model_provider=self.controller.provider_name,
78
+ model_name=self.controller.model_name,
79
+ state=self._pet_state,
80
+ )
81
+ self._reload_pets()
82
+ self._apply_pet(self.current_pet_id, announce=False)
83
+
84
+ self.tray = QSystemTrayIcon(self._build_tray_icon(), self)
85
+ self.tray.setToolTip("Vox Pet")
86
+ self.tray.activated.connect(self._on_tray_activated)
87
+ self._tray_menu = self._build_tray_menu()
88
+ self.tray.setContextMenu(self._tray_menu)
89
+ self.tray.show()
90
+
91
+ self._place_windows()
92
+ self._apply_skin(self.current_skin)
93
+ self._apply_language(self.current_language, announce=False)
94
+ self.pet.show()
95
+ self._sync_toolbar_position()
96
+ self.commands.set_quick_commands(pai_config.quick_commands())
97
+ self._sync_reply_state(SessionReply(text="", mode=self.controller.mode, presentation_mode=self.controller.presentation_mode))
98
+ self.pet.speak(self._text("ready_bubble", "主人,我已经准备好了。"))
99
+ QTimer.singleShot(160, self._initial_reveal)
100
+
101
+ def toggle_chat(self):
102
+ if self.chat.isVisible():
103
+ self.chat.hide()
104
+ self.toolbar.set_active_panel("")
105
+ return
106
+ self.commands.hide()
107
+ self._position_floating_panel(self.chat, x_shift=120, y_gap=34)
108
+ self.chat.show()
109
+ self.chat.raise_()
110
+ self.chat.focus_input()
111
+ self.toolbar.set_active_panel("chat")
112
+ self._show_toolbar()
113
+
114
+ def toggle_commands(self):
115
+ if self.commands.isVisible():
116
+ self.commands.hide()
117
+ self.toolbar.set_active_panel("")
118
+ return
119
+ self.chat.hide()
120
+ self._position_floating_panel(self.commands, x_shift=92, y_gap=34)
121
+ self.commands.show()
122
+ self.commands.raise_()
123
+ self.toolbar.set_active_panel("commands")
124
+ self._show_toolbar()
125
+
126
+ def reveal_pet(self, reset_position: bool = False):
127
+ if reset_position:
128
+ self._place_windows()
129
+ self._sync_toolbar_position()
130
+ self._sync_status_card_position()
131
+ self.pet.show()
132
+ self.pet.raise_()
133
+ self.pet.speak(self._text("reveal_bubble", "主人,我在这里。"))
134
+
135
+ def submit_line(self, line: str | GuiChatSubmission):
136
+ if self.worker is not None and self.worker.isRunning():
137
+ return
138
+ submission = line if isinstance(line, GuiChatSubmission) else GuiChatSubmission(text=line)
139
+ if not self.chat.isVisible():
140
+ self._position_floating_panel(self.chat, x_shift=120, y_gap=34)
141
+ self.chat.show()
142
+ self.chat.raise_()
143
+ self.chat.focus_input()
144
+ self.toolbar.set_active_panel("chat")
145
+ self.chat.append_message(
146
+ ChatMessage("user", submission.text, attachments=submission.attachments)
147
+ )
148
+ self.chat.set_busy(True)
149
+ self._pet_state = "thinking"
150
+ self.pet.set_thinking(True)
151
+ self._update_status_card(state="thinking")
152
+ self.worker = SessionWorker(self.controller, submission)
153
+ self.worker.completed.connect(self._handle_reply)
154
+ self.worker.failed.connect(self._handle_failure)
155
+ self.worker.start()
156
+
157
+ def _handle_reply(self, reply: SessionReply):
158
+ submitted_line = ""
159
+ if self.worker is not None:
160
+ submitted = self.worker._line
161
+ submitted_line = submitted.text.strip() if isinstance(submitted, GuiChatSubmission) else submitted.strip()
162
+ self.chat.set_busy(False)
163
+ self.pet.set_thinking(False)
164
+ self._sync_reply_state(reply)
165
+
166
+ role = "error" if reply.kind == "error" else "assistant"
167
+ if submitted_line == "/clear" and reply.kind != "error":
168
+ self.chat.clear_messages()
169
+ self._pet_state = "celebrate"
170
+ self.pet.speak(reply.text)
171
+ self.pet.celebrate()
172
+ self._update_status_card(state="celebrate", bubble_text=reply.text)
173
+ self.worker = None
174
+ return
175
+
176
+ if reply.text:
177
+ if role == "assistant":
178
+ self.chat.start_streaming_message(ChatMessage(role, reply.text))
179
+ else:
180
+ self.chat.append_message(ChatMessage(role, reply.text))
181
+ self.pet.speak(reply.text)
182
+ if reply.kind == "assistant":
183
+ self._pet_state = "celebrate"
184
+ self.pet.celebrate()
185
+ elif reply.kind == "error":
186
+ self._pet_state = "error"
187
+ self.pet.error()
188
+ else:
189
+ self._pet_state = "alert"
190
+ self.pet.alert()
191
+ self._update_status_card(state=self._pet_state, bubble_text=reply.text)
192
+ self._show_toolbar()
193
+
194
+ # Reset to idle after short delay
195
+ QTimer.singleShot(2000, self._reset_pet_state)
196
+ if reply.should_quit:
197
+ QApplication.instance().quit()
198
+ self.worker = None
199
+
200
+ def _reset_pet_state(self):
201
+ self._pet_state = "idle"
202
+ self._update_status_card(state="idle")
203
+
204
+ def _handle_failure(self, message: str):
205
+ self.chat.set_busy(False)
206
+ self.pet.set_thinking(False)
207
+ self.chat.append_message(ChatMessage("error", message))
208
+ self._pet_state = "error"
209
+ self._update_status_card(state="error", bubble_text=message)
210
+ self.pet.speak(message)
211
+ self.pet.error()
212
+ QTimer.singleShot(2000, self._reset_pet_state)
213
+ self.worker = None
214
+
215
+ def _handle_attachment_error(self, message: str):
216
+ self.chat.append_message(ChatMessage("error", message))
217
+ self._pet_state = "error"
218
+ self._update_status_card(state="error", bubble_text=message)
219
+ self.pet.speak(message)
220
+ self.pet.error()
221
+ QTimer.singleShot(2000, self._reset_pet_state)
222
+
223
+ def _apply_controller_reply(self, reply: SessionReply):
224
+ self._sync_reply_state(reply)
225
+ self._refresh_tray_menu()
226
+ self.pet.speak(reply.text)
227
+ self.pet.alert()
228
+
229
+ def _sync_reply_state(self, reply: SessionReply):
230
+ self.pet.update_session_state(reply)
231
+ self.chat.sync_state(reply, self.current_skin)
232
+ self.toolbar.sync_state(
233
+ reply.mode,
234
+ reply.presentation_mode,
235
+ self.current_skin,
236
+ self._current_pet().display_name,
237
+ )
238
+
239
+ def _on_mode_changed(self, mode: str):
240
+ self._apply_controller_reply(self.controller.set_mode(mode))
241
+
242
+ def _on_style_changed(self, style: str):
243
+ self._apply_controller_reply(self.controller.set_presentation_mode(style))
244
+
245
+ def _on_tray_activated(self, reason: QSystemTrayIcon.ActivationReason):
246
+ if reason in {
247
+ QSystemTrayIcon.ActivationReason.Trigger,
248
+ QSystemTrayIcon.ActivationReason.DoubleClick,
249
+ }:
250
+ self.toggle_chat()
251
+
252
+ def _build_tray_menu(self) -> QMenu:
253
+ menu = QMenu()
254
+ show_pet = QAction("显示桌宠", menu)
255
+ show_pet.triggered.connect(self.reveal_pet)
256
+ menu.addAction(show_pet)
257
+
258
+ reset_pet = QAction("重置桌宠位置", menu)
259
+ reset_pet.triggered.connect(lambda: self.reveal_pet(reset_position=True))
260
+ menu.addAction(reset_pet)
261
+
262
+ show_chat = QAction("打开面板", menu)
263
+ show_chat.triggered.connect(self.toggle_chat)
264
+ menu.addAction(show_chat)
265
+
266
+ show_commands = QAction("快捷命令", menu)
267
+ show_commands.triggered.connect(self.toggle_commands)
268
+ menu.addAction(show_commands)
269
+
270
+ menu.addSeparator()
271
+ model_source = QAction(f"模型来源: {self._gui_model_source}", menu)
272
+ model_source.setEnabled(False)
273
+ menu.addAction(model_source)
274
+
275
+ pet_manager = QAction("宠物管理", menu)
276
+ pet_manager.triggered.connect(self.open_pet_manager)
277
+ menu.addAction(pet_manager)
278
+
279
+ model_settings = QAction("模型设置", menu)
280
+ model_settings.triggered.connect(self.open_model_settings)
281
+ menu.addAction(model_settings)
282
+
283
+ personality = QAction("性格设置", menu)
284
+ personality.triggered.connect(self.open_personality_settings)
285
+ menu.addAction(personality)
286
+
287
+ menu.addSeparator()
288
+ self._populate_configuration_menu(menu)
289
+
290
+ menu.addSeparator()
291
+ quit_action = QAction("退出", menu)
292
+ quit_action.triggered.connect(QApplication.instance().quit)
293
+ menu.addAction(quit_action)
294
+ return menu
295
+
296
+ def _build_tray_icon(self) -> QIcon:
297
+ pixmap = QPixmap(64, 64)
298
+ pixmap.fill(Qt.GlobalColor.transparent)
299
+ painter = QPainter(pixmap)
300
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
301
+ painter.setPen(QPen(QColor(205, 192, 176), 2))
302
+ painter.setBrush(QColor(248, 244, 236))
303
+ painter.drawEllipse(QRect(10, 14, 44, 38))
304
+ painter.drawPolygon(QPolygon([QPoint(18, 20), QPoint(24, 6), QPoint(31, 22)]))
305
+ painter.drawPolygon(QPolygon([QPoint(33, 22), QPoint(40, 6), QPoint(46, 20)]))
306
+ painter.setPen(Qt.PenStyle.NoPen)
307
+ painter.setBrush(QColor(59, 64, 72))
308
+ painter.drawEllipse(QRect(23, 28, 5, 8))
309
+ painter.drawEllipse(QRect(36, 28, 5, 8))
310
+ painter.setBrush(QColor(239, 164, 145))
311
+ painter.drawEllipse(QRect(29, 34, 6, 4))
312
+ painter.end()
313
+ return QIcon(pixmap)
314
+
315
+ def _place_windows(self):
316
+ screen = QApplication.primaryScreen()
317
+ if screen is None:
318
+ self.pet.move(80, 80)
319
+ self.chat.move(140, 120)
320
+ self.commands.move(120, 80)
321
+ self._sync_toolbar_position()
322
+ return
323
+ area = screen.availableGeometry()
324
+ pet_x = max(area.left() + 16, area.right() - self.pet.width() - 24)
325
+ pet_y = max(area.top() + 16, area.bottom() - self.pet.height() - 24)
326
+ self.pet.move(pet_x, pet_y)
327
+ self._position_floating_panel(self.chat, x_shift=120, y_gap=34)
328
+ self._position_floating_panel(self.commands, x_shift=92, y_gap=34)
329
+ self._sync_toolbar_position()
330
+
331
+ def _position_floating_panel(self, window: QWidget, x_shift: int, y_gap: int):
332
+ screen = QApplication.primaryScreen()
333
+ if screen is None:
334
+ return
335
+ area = screen.availableGeometry()
336
+ pet_pos = self.pet.pos()
337
+ x = pet_pos.x() - window.width() + x_shift
338
+ y = pet_pos.y() - window.height() - y_gap
339
+ x = max(area.left() + 24, min(x, area.right() - window.width() - 24))
340
+ y = max(area.top() + 24, min(y, area.bottom() - window.height() - 24))
341
+ window.move(x, y)
342
+
343
+ def _sync_toolbar_position(self):
344
+ toolbar_size = self.toolbar.sizeHint()
345
+ pet_pos = self.pet.pos()
346
+ x = pet_pos.x() - max(0, (toolbar_size.width() - self.pet.width()) // 2)
347
+ y = pet_pos.y() - toolbar_size.height() - 12
348
+ self.toolbar.move(x, y)
349
+
350
+ def _sync_status_card_position(self):
351
+ card_size = self.status_card.sizeHint()
352
+ toolbar_size = self.toolbar.sizeHint()
353
+ pet_pos = self.pet.pos()
354
+ card_y = pet_pos.y() - card_size.height() - toolbar_size.height() - 18
355
+ card_x = pet_pos.x() - max(0, (card_size.width() - self.pet.width()) // 2)
356
+ screen = QApplication.primaryScreen()
357
+ if screen is not None:
358
+ area = screen.availableGeometry()
359
+ card_x = max(area.left() + 8, min(card_x, area.right() - card_size.width() - 8))
360
+ card_y = max(area.top() + 8, card_y)
361
+ self.status_card.move(card_x, card_y)
362
+
363
+ def _show_toolbar(self):
364
+ self._toolbar_hide_timer.stop()
365
+ self._sync_toolbar_position()
366
+ self.toolbar.show()
367
+ self.toolbar.raise_()
368
+ self._sync_status_card_position()
369
+ self.status_card.show()
370
+ self.status_card.raise_()
371
+
372
+ def _hide_toolbar_if_idle(self):
373
+ if self.chat.isVisible() or self.commands.isVisible():
374
+ return
375
+ if self.pet.underMouse() or self.toolbar.underMouse() or self.status_card.underMouse():
376
+ return
377
+ self.toolbar.hide()
378
+ self.status_card.hide()
379
+
380
+ def _update_status_card(self, state: str = "", bubble_text: str = ""):
381
+ self.status_card.set_status(
382
+ mode=self.controller.mode,
383
+ model_provider=self.controller.provider_name,
384
+ model_name=self.controller.model_name,
385
+ state=state or self._pet_state,
386
+ bubble_text=bubble_text,
387
+ )
388
+
389
+ def _on_pet_hover_changed(self, hovering: bool):
390
+ if hovering:
391
+ self._show_toolbar()
392
+ return
393
+ self._toolbar_hide_timer.start()
394
+
395
+ def _on_toolbar_hover_changed(self, hovering: bool):
396
+ if hovering:
397
+ self._toolbar_hide_timer.stop()
398
+ return
399
+ self._toolbar_hide_timer.start()
400
+
401
+ def _show_pet_context_menu(self, global_pos: QPoint):
402
+ menu = QMenu()
403
+ open_chat = QAction("打开聊天", menu)
404
+ open_chat.triggered.connect(self.toggle_chat)
405
+ menu.addAction(open_chat)
406
+
407
+ open_commands = QAction("快捷命令", menu)
408
+ open_commands.triggered.connect(self.toggle_commands)
409
+ menu.addAction(open_commands)
410
+
411
+ menu.addSeparator()
412
+ pet_manager = QAction("宠物管理", menu)
413
+ pet_manager.triggered.connect(self.open_pet_manager)
414
+ menu.addAction(pet_manager)
415
+
416
+ model_settings = QAction("模型设置", menu)
417
+ model_settings.triggered.connect(self.open_model_settings)
418
+ menu.addAction(model_settings)
419
+
420
+ personality = QAction("性格设置", menu)
421
+ personality.triggered.connect(self.open_personality_settings)
422
+ menu.addAction(personality)
423
+
424
+ menu.addSeparator()
425
+ pets_menu = menu.addMenu("宠物")
426
+ for package in self._pets:
427
+ action = QAction(package.display_name, pets_menu)
428
+ action.setCheckable(True)
429
+ action.setChecked(package.id == self.current_pet_id)
430
+ action.triggered.connect(lambda _checked=False, value=package.id: self._apply_pet(value))
431
+ pets_menu.addAction(action)
432
+
433
+ import_pet = QAction("导入宠物", menu)
434
+ import_pet.triggered.connect(self._import_pet_action)
435
+ menu.addAction(import_pet)
436
+
437
+ menu.addSeparator()
438
+ reset_pet = QAction("重置桌宠位置", menu)
439
+ reset_pet.triggered.connect(lambda: self.reveal_pet(reset_position=True))
440
+ menu.addAction(reset_pet)
441
+
442
+ hide_pet = QAction("隐藏桌宠", menu)
443
+ hide_pet.triggered.connect(self._hide_pet_action)
444
+ menu.addAction(hide_pet)
445
+
446
+ menu.addSeparator()
447
+ quit_action = QAction("退出", menu)
448
+ quit_action.triggered.connect(QApplication.instance().quit)
449
+ menu.addAction(quit_action)
450
+ menu.exec(global_pos)
451
+
452
+ def _cycle_mode_action(self):
453
+ self._apply_controller_reply(self.controller.cycle_mode())
454
+
455
+ def _toggle_style_action(self):
456
+ next_style = "pet" if self.controller.presentation_mode == "work" else "work"
457
+ self._apply_controller_reply(self.controller.set_presentation_mode(next_style))
458
+
459
+ def _hide_pet_action(self):
460
+ self.toolbar.hide()
461
+ self.status_card.hide()
462
+ self.chat.hide()
463
+ self.commands.hide()
464
+ self.toolbar.set_active_panel("")
465
+ self.pet.hide()
466
+
467
+ def _initial_reveal(self):
468
+ self.reveal_pet(reset_position=True)
469
+
470
+ def _cycle_skin_action(self):
471
+ skins = list(SKIN_PALETTES.keys())
472
+ idx = skins.index(self.current_skin) if self.current_skin in skins else 0
473
+ next_skin = skins[(idx + 1) % len(skins)]
474
+ self._apply_skin(next_skin)
475
+ self.pet.speak(f"换成 {next_skin} 皮肤啦。")
476
+ self.pet.celebrate()
477
+
478
+ def _apply_skin(self, skin: str):
479
+ self.current_skin = skin if skin in SKIN_PALETTES else "glass"
480
+ self._state_store.save_skin(self.current_skin)
481
+ self.pet.set_skin(self.current_skin)
482
+ self.chat.apply_skin(self.current_skin)
483
+ self.commands.apply_skin(self.current_skin)
484
+ self.toolbar.apply_skin(self.current_skin)
485
+ self._sync_reply_state(SessionReply(text="", mode=self.controller.mode, presentation_mode=self.controller.presentation_mode))
486
+ self._refresh_tray_menu()
487
+
488
+ def _apply_language(self, language_id: str, announce: bool = True):
489
+ language = pai_config.get_language(language_id)
490
+ if language is None:
491
+ return
492
+ self.current_language = str(language.get("id", "zh-CN"))
493
+ pai_config.set_active_language(self.current_language)
494
+ texts = dict(language.get("texts", {}))
495
+ self.chat.set_language(texts)
496
+ self.commands.set_language(texts)
497
+ self.commands.set_quick_commands(pai_config.quick_commands())
498
+ self.chat.set_quick_commands(pai_config.quick_commands())
499
+ self.toolbar.set_language(texts)
500
+ self._refresh_tray_menu()
501
+ if announce:
502
+ label = str(language.get("label", self.current_language))
503
+ message = self._format_text("language_switched", label, f"界面语言已切换为 {label}")
504
+ self.pet.speak(message)
505
+ self.pet.alert()
506
+
507
+ def _reload_pets(self):
508
+ ordered: list[PetPackage] = []
509
+ seen: dict[str, int] = {}
510
+ for package in BUILTIN_PETS + self._pet_store.list_imported():
511
+ if package.id in seen:
512
+ ordered[seen[package.id]] = package
513
+ continue
514
+ seen[package.id] = len(ordered)
515
+ ordered.append(package)
516
+ self._pets = ordered or BUILTIN_PETS[:]
517
+ if self._pet_manager is not None and self._pet_manager.isVisible():
518
+ self._pet_manager.load_pets(self._pets, self.current_pet_id, self.current_skin)
519
+
520
+ def _current_pet(self) -> PetPackage:
521
+ for package in self._pets:
522
+ if package.id == self.current_pet_id:
523
+ return package
524
+ return self._pets[0]
525
+
526
+ def _apply_pet(self, pet_id: str, announce: bool = True):
527
+ selected = next((package for package in self._pets if package.id == pet_id), None) or self._pets[0]
528
+ self.current_pet_id = selected.id
529
+ self._state_store.save_selected_pet(self.current_pet_id)
530
+ self.pet.set_pet_package(selected)
531
+ if hasattr(self, "toolbar"):
532
+ self.toolbar.sync_state(
533
+ self.controller.mode,
534
+ self.controller.presentation_mode,
535
+ self.current_skin,
536
+ selected.display_name,
537
+ )
538
+ if hasattr(self, "tray"):
539
+ self._refresh_tray_menu()
540
+ if self._pet_manager is not None and self._pet_manager.isVisible():
541
+ self._pet_manager.load_pets(self._pets, self.current_pet_id, self.current_skin)
542
+ if announce:
543
+ self.pet.speak(self._format_text("pet_changed", selected.display_name, f"{selected.display_name} 来了。"))
544
+ self.pet.celebrate()
545
+
546
+ def _cycle_pet_action(self):
547
+ if not self._pets:
548
+ return
549
+ pet_ids = [package.id for package in self._pets]
550
+ try:
551
+ current_index = pet_ids.index(self.current_pet_id)
552
+ except ValueError:
553
+ current_index = -1
554
+ self._apply_pet(pet_ids[(current_index + 1) % len(pet_ids)])
555
+
556
+ def _import_pet_action(self):
557
+ folder = QFileDialog.getExistingDirectory(
558
+ self.chat if self.chat.isVisible() else None,
559
+ "选择 Petdex / Codex 宠物文件夹",
560
+ str(Path.home()),
561
+ )
562
+ if not folder:
563
+ return
564
+ try:
565
+ imported = self._pet_store.import_folder(folder)
566
+ except Exception as exc:
567
+ message = f"导入失败: {exc}"
568
+ self.pet.speak(message)
569
+ self.pet.error()
570
+ return
571
+ self._reload_pets()
572
+ self._apply_pet(imported.id, announce=False)
573
+ self.pet.speak(f"{imported.display_name} 已导入。")
574
+ self.pet.celebrate()
575
+
576
+ def _populate_configuration_menu(self, menu: QMenu):
577
+ mode_menu = menu.addMenu("运行模式")
578
+ for mode in ("single", "plan", "team"):
579
+ action = QAction(mode, mode_menu)
580
+ action.setCheckable(True)
581
+ action.setChecked(mode == self.controller.mode)
582
+ action.triggered.connect(lambda _checked=False, value=mode: self._set_mode(value))
583
+ mode_menu.addAction(action)
584
+
585
+ style_menu = menu.addMenu("展示模式")
586
+ for style in ("work", "pet"):
587
+ action = QAction(style, style_menu)
588
+ action.setCheckable(True)
589
+ action.setChecked(style == self.controller.presentation_mode)
590
+ action.triggered.connect(lambda _checked=False, value=style: self._set_style(value))
591
+ style_menu.addAction(action)
592
+
593
+ persona_menu = menu.addMenu("人格 Prompt")
594
+ for persona in pai_config.personas():
595
+ persona_id = str(persona.get("id", "")).strip()
596
+ label = str(persona.get("label", persona_id)).strip() or persona_id
597
+ action = QAction(label, persona_menu)
598
+ action.setCheckable(True)
599
+ action.setChecked(persona_id == pai_config.active_persona)
600
+ action.triggered.connect(lambda _checked=False, value=persona_id: self._apply_persona(value))
601
+ persona_menu.addAction(action)
602
+
603
+ language_menu = menu.addMenu("语言")
604
+ for language in pai_config.languages():
605
+ language_id = str(language.get("id", "")).strip()
606
+ label = str(language.get("label", language_id)).strip() or language_id
607
+ action = QAction(label, language_menu)
608
+ action.setCheckable(True)
609
+ action.setChecked(language_id == self.current_language)
610
+ action.triggered.connect(lambda _checked=False, value=language_id: self._apply_language(value))
611
+ language_menu.addAction(action)
612
+
613
+ skin_menu = menu.addMenu("皮肤")
614
+ for skin_id in SKIN_PALETTES:
615
+ action = QAction(skin_id, skin_menu)
616
+ action.setCheckable(True)
617
+ action.setChecked(skin_id == self.current_skin)
618
+ action.triggered.connect(lambda _checked=False, value=skin_id: self._apply_skin_action(value))
619
+ skin_menu.addAction(action)
620
+
621
+ pets_menu = menu.addMenu("宠物")
622
+ for package in self._pets:
623
+ action = QAction(package.display_name, pets_menu)
624
+ action.setCheckable(True)
625
+ action.setChecked(package.id == self.current_pet_id)
626
+ action.triggered.connect(lambda _checked=False, value=package.id: self._apply_pet(value))
627
+ pets_menu.addAction(action)
628
+
629
+ import_pet = QAction("导入 Petdex 宠物", menu)
630
+ import_pet.triggered.connect(self._import_pet_action)
631
+ menu.addAction(import_pet)
632
+
633
+ reset_pet = QAction("重置桌宠位置", menu)
634
+ reset_pet.triggered.connect(lambda: self.reveal_pet(reset_position=True))
635
+ menu.addAction(reset_pet)
636
+
637
+ hide_pet = QAction("隐藏桌宠", menu)
638
+ hide_pet.triggered.connect(self._hide_pet_action)
639
+ menu.addAction(hide_pet)
640
+
641
+ def _set_mode(self, mode: str):
642
+ self._apply_controller_reply(self.controller.set_mode(mode))
643
+ self._update_status_card()
644
+
645
+ def _set_style(self, style: str):
646
+ self._apply_controller_reply(self.controller.set_presentation_mode(style))
647
+ self._update_status_card()
648
+
649
+ def _apply_skin_action(self, skin_id: str):
650
+ self._apply_skin(skin_id)
651
+ self.pet.speak(self._format_text("skin_switched", skin_id, f"皮肤已切换为 {skin_id}"))
652
+ self.pet.celebrate()
653
+
654
+ def _apply_persona(self, persona_id: str):
655
+ persona = pai_config.get_persona(persona_id)
656
+ if persona is None:
657
+ return
658
+ pai_config.set_active_persona(persona_id)
659
+ self._refresh_tray_menu()
660
+ if self._personality_window is not None and self._personality_window.isVisible():
661
+ self._personality_window.load_personas(
662
+ pai_config.personas(), pai_config.active_persona
663
+ )
664
+ label = str(persona.get("label", persona_id)).strip() or persona_id
665
+ message = self._format_text("persona_switched", label, f"人格已切换为 {label}")
666
+ self.pet.speak(message)
667
+ self.pet.alert()
668
+
669
+ def _build_initial_gui_llm_client(self):
670
+ self._gui_model_config = self._load_gui_model_config()
671
+ if self._gui_model_config.enabled:
672
+ try:
673
+ client = self._create_gui_model_client(self._gui_model_config)
674
+ self._gui_model_source = self._format_model_source("独立配置", client.provider_name, client.model_name)
675
+ return client
676
+ except Exception as exc:
677
+ self._gui_model_warning = f"独立 GUI 模型加载失败: {exc}。当前已回退到全局模型。"
678
+
679
+ client = create_from_config()
680
+ if client is None:
681
+ raise RuntimeError(
682
+ "无法创建 LLM 客户端。请至少配置一组模型环境变量,例如 "
683
+ "GLM_API_KEY/GLM_MODEL、DEEPSEEK_API_KEY/DEEPSEEK_MODEL、"
684
+ "QWEN_API_KEY/QWEN_MODEL 或 "
685
+ "OLLAMA_MODEL/OLLAMA_BASE_URL。"
686
+ )
687
+ self._gui_model_source = self._format_model_source("跟随全局", client.provider_name, client.model_name)
688
+ return client
689
+
690
+ def _load_gui_model_config(self) -> GuiModelConfig:
691
+ try:
692
+ return self._gui_model_store.load()
693
+ except Exception as exc:
694
+ self._gui_model_warning = str(exc)
695
+ return GuiModelConfig()
696
+
697
+ def _create_gui_model_client(self, config: GuiModelConfig):
698
+ missing = config.validate()
699
+ if missing:
700
+ raise ValueError(f"缺少字段: {', '.join(missing)}")
701
+ client = create_from_provider_config(config.provider, config.provider_config())
702
+ if client is None:
703
+ raise RuntimeError(f"无法创建 provider={config.provider} 的客户端")
704
+ return client
705
+
706
+ def _format_model_source(self, prefix: str, provider: str, model: str) -> str:
707
+ return f"{prefix} ({gui_model_profile_label(provider)} / {model})"
708
+
709
+ def _current_global_model_summary(self) -> str:
710
+ global_client = create_from_config()
711
+ if global_client is not None:
712
+ return f"{gui_model_profile_label(global_client.provider_name)} / {global_client.model_name}"
713
+ preset = pai_config.get_model_preset(pai_config.active_model_preset)
714
+ if preset is not None:
715
+ return f"{gui_model_profile_label(preset.provider)} / {preset.model}"
716
+ return "未配置"
717
+
718
+ def _seed_gui_model_config(self) -> GuiModelConfig:
719
+ config = self._gui_model_config
720
+ if config.enabled or config.model or config.base_url or config.api_key:
721
+ return config
722
+ return GuiModelConfig(enabled=False, provider="codex", model="", base_url="", api_key="")
723
+
724
+ def open_model_settings(self):
725
+ if self._settings_window is None:
726
+ self._settings_window = GuiModelSettingsWindow()
727
+ self._settings_window.save_requested.connect(self._save_gui_model_settings)
728
+ self._settings_window.load_state(
729
+ self._seed_gui_model_config(),
730
+ self._gui_model_source,
731
+ self._current_global_model_summary(),
732
+ self._gui_model_warning,
733
+ )
734
+ self._settings_window.show()
735
+ self._settings_window.raise_()
736
+ self._settings_window.activateWindow()
737
+
738
+ def _save_gui_model_settings(self, config: GuiModelConfig):
739
+ if not config.enabled:
740
+ self._follow_global_model(config)
741
+ return
742
+ try:
743
+ new_client = self._create_gui_model_client(config)
744
+ self._gui_model_store.save(config)
745
+ self._gui_model_config = config
746
+ self._gui_model_warning = ""
747
+ self.controller.set_llm_client(new_client)
748
+ self._gui_model_source = self._format_model_source(
749
+ "独立配置",
750
+ new_client.provider_name,
751
+ new_client.model_name,
752
+ )
753
+ self._refresh_tray_menu()
754
+ if self._settings_window is not None:
755
+ self._settings_window.update_runtime_labels(
756
+ self._gui_model_source,
757
+ self._current_global_model_summary(),
758
+ self._gui_model_warning,
759
+ )
760
+ self._settings_window.set_status("独立 GUI 模型已保存并应用。")
761
+ self.pet.speak(
762
+ f"桌宠已切换到独立模型 {gui_model_profile_label(new_client.provider_name)} / {new_client.model_name}。"
763
+ )
764
+ self.pet.celebrate()
765
+ self._update_status_card()
766
+ except Exception as exc:
767
+ if self._settings_window is not None:
768
+ self._settings_window.set_status(f"保存失败: {exc}", error=True)
769
+ self.pet.speak(f"模型配置保存失败: {exc}")
770
+ self.pet.error()
771
+
772
+ def open_pet_manager(self):
773
+ if self._pet_manager is None:
774
+ from .windows import PetManagerWindow
775
+ self._pet_manager = PetManagerWindow()
776
+ self._pet_manager.pet_selected.connect(self._apply_pet)
777
+ self._pet_manager.import_requested.connect(self._manager_import_pet)
778
+ self._pet_manager.delete_requested.connect(self._manager_delete_pet)
779
+ self._pet_manager.load_pets(self._pets, self.current_pet_id, self.current_skin)
780
+ self._pet_manager.show()
781
+ self._pet_manager.raise_()
782
+ self._pet_manager.activateWindow()
783
+
784
+ def open_personality_settings(self):
785
+ if self._personality_window is None:
786
+ self._personality_window = PersonalityWindow()
787
+ self._personality_window.load_personas(
788
+ pai_config.personas(), pai_config.active_persona
789
+ )
790
+ self._personality_window.show()
791
+ self._personality_window.raise_()
792
+ self._personality_window.activateWindow()
793
+
794
+ def _manager_import_pet(self):
795
+ """从 Pet Manager 导入宠物"""
796
+ folder = QFileDialog.getExistingDirectory(
797
+ self._pet_manager if self._pet_manager and self._pet_manager.isVisible() else None,
798
+ "选择宠物文件夹",
799
+ str(Path.home()),
800
+ )
801
+ if not folder:
802
+ return
803
+ try:
804
+ imported = self._pet_store.import_folder(folder)
805
+ except Exception as exc:
806
+ self.pet.speak(f"导入失败: {exc}")
807
+ self.pet.error()
808
+ return
809
+ self._reload_pets()
810
+ self._apply_pet(imported.id, announce=False)
811
+ self.pet.speak(f"{imported.display_name} 已导入。")
812
+ self.pet.celebrate()
813
+ if self._pet_manager is not None:
814
+ self._pet_manager.load_pets(self._pets, self.current_pet_id, self.current_skin)
815
+
816
+ def _manager_delete_pet(self, pet_id: str):
817
+ """从 Pet Manager 删除已导入宠物"""
818
+ import shutil
819
+ pet_store = self._pet_store
820
+ target_dir = pet_store.root / pet_id
821
+ if target_dir.exists():
822
+ shutil.rmtree(target_dir)
823
+ self._reload_pets()
824
+ if self.current_pet_id == pet_id:
825
+ self._apply_pet(self._pets[0].id if self._pets else "terminal-cat", announce=False)
826
+ if self._pet_manager is not None:
827
+ self._pet_manager.load_pets(self._pets, self.current_pet_id, self.current_skin)
828
+ self.pet.speak(f"{pet_id} 已删除。")
829
+ self.pet.alert()
830
+
831
+ def _follow_global_model(self, config: GuiModelConfig | None = None):
832
+ target_config = config or self._seed_gui_model_config()
833
+ target_config.enabled = False
834
+ reply = self.controller.reload_default_model()
835
+ if reply.kind == "error":
836
+ if self._settings_window is not None:
837
+ self._settings_window.set_status(reply.text, error=True)
838
+ self.pet.speak(reply.text)
839
+ self.pet.error()
840
+ return
841
+ try:
842
+ self._gui_model_store.save(target_config)
843
+ self._gui_model_config = target_config
844
+ self._gui_model_warning = ""
845
+ self._gui_model_source = self._format_model_source(
846
+ "跟随全局",
847
+ self.controller.provider_name,
848
+ self.controller.model_name,
849
+ )
850
+ self._refresh_tray_menu()
851
+ if self._settings_window is not None:
852
+ self._settings_window.update_runtime_labels(
853
+ self._gui_model_source,
854
+ self._current_global_model_summary(),
855
+ self._gui_model_warning,
856
+ )
857
+ self._settings_window.set_status("桌宠已恢复跟随全局模型。")
858
+ self.pet.speak("桌宠已经恢复跟随全局模型。")
859
+ self.pet.alert()
860
+ self._update_status_card()
861
+ except Exception as exc:
862
+ if self._settings_window is not None:
863
+ self._settings_window.set_status(f"保存失败: {exc}", error=True)
864
+ self.pet.speak(f"模型配置保存失败: {exc}")
865
+ self.pet.error()
866
+
867
+ def _text(self, key: str, default: str) -> str:
868
+ return pai_config.active_language_text(key, default)
869
+
870
+ def _format_text(self, key: str, value: str, default: str) -> str:
871
+ template = self._text(key, default)
872
+ try:
873
+ return template.format(value=value)
874
+ except Exception:
875
+ return default
876
+
877
+ def _refresh_tray_menu(self):
878
+ if hasattr(self, "tray"):
879
+ self._tray_menu = self._build_tray_menu()
880
+ self.tray.setContextMenu(self._tray_menu)
881
+
882
+
883
+ def run_pet_app(argv: list[str]) -> int:
884
+ app = QApplication(argv)
885
+ app.setQuitOnLastWindowClosed(False)
886
+ coordinator = PetCoordinator()
887
+ coordinator.hide()
888
+ return app.exec()