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,683 @@
1
+ """Core widgets for the desktop pet UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PySide6.QtCore import QPoint, QRect, Qt, QTimer, Signal
6
+ from PySide6.QtGui import QColor, QMouseEvent, QPainter, QPainterPath, QPen, QPixmap, QPolygon
7
+ from PySide6.QtWidgets import QFrame, QLabel, QVBoxLayout, QWidget
8
+
9
+ from ...runtime import SessionController, SessionReply
10
+ from .base import make_shadow
11
+ from .data import BUILTIN_PETS, PetPackage, SKIN_PALETTES, SkinPalette
12
+
13
+
14
+ class SpriteSheetRenderer:
15
+ def __init__(self):
16
+ self._cache: dict[str, tuple[QPixmap, int, int, int]] = {}
17
+
18
+ def frame(self, package: PetPackage | None, action: int, tick: int) -> QPixmap | None:
19
+ if package is None or not package.has_spritesheet:
20
+ return None
21
+ sprite_path = package.spritesheet_path
22
+ cached = self._cache.get(sprite_path)
23
+ if cached is None:
24
+ pixmap = QPixmap(sprite_path)
25
+ if pixmap.isNull():
26
+ return None
27
+ row_count = 9
28
+ frame_height = pixmap.height() // row_count if row_count else 0
29
+ if frame_height <= 0:
30
+ return None
31
+ frame_width = frame_height
32
+ frame_count = max(1, pixmap.width() // frame_width)
33
+ cached = (pixmap, frame_width, frame_height, frame_count)
34
+ self._cache[sprite_path] = cached
35
+
36
+ pixmap, frame_width, frame_height, frame_count = cached
37
+ safe_action = max(0, min(action, 8))
38
+ frame_index = tick % frame_count
39
+ return pixmap.copy(frame_index * frame_width, safe_action * frame_height, frame_width, frame_height)
40
+
41
+
42
+ SPRITE_RENDERER = SpriteSheetRenderer()
43
+
44
+
45
+ class SpeechBubble(QFrame):
46
+ def __init__(self):
47
+ super().__init__()
48
+ self.setObjectName("speechBubble")
49
+ self._skin = "glass"
50
+ make_shadow(self, blur=26, y=10, alpha=44)
51
+ layout = QVBoxLayout(self)
52
+ layout.setContentsMargins(14, 12, 14, 12)
53
+ layout.setSpacing(8)
54
+ self.badge = QLabel("VOX")
55
+ self.badge.setObjectName("speechBubbleBadge")
56
+ self.badge.setAlignment(Qt.AlignmentFlag.AlignCenter)
57
+ self.badge.setFixedSize(42, 20)
58
+ layout.addWidget(self.badge, 0, Qt.AlignmentFlag.AlignLeft)
59
+ self.label = QLabel("点我打开面板,和 Vox 聊天")
60
+ self.label.setObjectName("speechBubbleText")
61
+ self.label.setWordWrap(True)
62
+ layout.addWidget(self.label)
63
+ self.apply_skin("glass")
64
+ self.hide()
65
+
66
+ def show_message(self, text: str):
67
+ import textwrap
68
+
69
+ preview = textwrap.shorten(" ".join(text.split()), width=56, placeholder="...")
70
+ self.label.setText(preview or "Vox 在这里。")
71
+ self.adjustSize()
72
+ self.show()
73
+
74
+ def apply_skin(self, skin: str):
75
+ self._skin = skin if skin in SKIN_PALETTES else "glass"
76
+ self.setStyleSheet(
77
+ "#speechBubble {"
78
+ "background: qlineargradient(x1:0, y1:0, x2:1, y2:1,"
79
+ "stop:0 rgba(24, 26, 31, 0.98), stop:1 rgba(37, 40, 47, 0.96));"
80
+ "border: 1px solid rgba(86, 90, 98, 0.88);"
81
+ "border-radius: 20px;"
82
+ "}"
83
+ "#speechBubbleBadge {"
84
+ "background: rgba(10, 132, 255, 0.96);"
85
+ "border: 1px solid rgba(104, 177, 255, 0.98);"
86
+ "border-radius: 10px;"
87
+ "color: #ffffff;"
88
+ "font-size: 10px;"
89
+ "font-weight: 800;"
90
+ "letter-spacing: 0.5px;"
91
+ "padding: 0 6px;"
92
+ "}"
93
+ "#speechBubbleText {"
94
+ "background: transparent;"
95
+ "color: #f1f1f1;"
96
+ "font-size: 13px;"
97
+ "font-weight: 600;"
98
+ "line-height: 1.35;"
99
+ "}"
100
+ )
101
+
102
+
103
+ class PillLabel(QLabel):
104
+ def __init__(self, text: str):
105
+ super().__init__(text)
106
+ self.setAlignment(Qt.AlignmentFlag.AlignCenter)
107
+ self.setStyleSheet(
108
+ "QLabel {"
109
+ "padding: 6px 12px;"
110
+ "background: rgba(255, 248, 239, 205);"
111
+ "border: 1px solid rgba(220, 187, 138, 190);"
112
+ "border-radius: 14px;"
113
+ "color: #6b5636;"
114
+ "font-size: 12px;"
115
+ "font-weight: 700;"
116
+ "}"
117
+ )
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # 共享宠物绘制函数(供 PetWidget + PetPreviewWidget 复用)
122
+ # ---------------------------------------------------------------------------
123
+
124
+ def draw_pet_body(painter: QPainter, rect: QRect, package: PetPackage,
125
+ palette: SkinPalette, status_action: int = 0,
126
+ thinking: bool = False, blink: bool = False,
127
+ float_phase: int = 0):
128
+ """在给定的 painter/rect 区域内绘制宠物身体。
129
+
130
+ 参数与 PetWidget 内部状态对应,由调用方决定传入值。
131
+ """
132
+ lift = 2 if (float_phase % 4) in {1, 2} else 0
133
+ if package.id == "pixel-cat":
134
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, False)
135
+
136
+ sprite = SPRITE_RENDERER.frame(package, status_action, float_phase)
137
+ if sprite is not None:
138
+ painter.setPen(Qt.PenStyle.NoPen)
139
+ painter.setBrush(palette.pet_shadow)
140
+ painter.drawEllipse(QRect(62, 178, 128, 22))
141
+ target = QRect(54, 44 - lift, 144, 144)
142
+ painter.drawPixmap(target, sprite)
143
+ return
144
+
145
+ painter.setPen(Qt.PenStyle.NoPen)
146
+ painter.setBrush(palette.pet_shadow)
147
+ painter.drawEllipse(QRect(62, 178, 128, 22))
148
+ painter.setBrush(palette.pet_glow)
149
+ painter.drawEllipse(QRect(42, 40 - lift, 170, 128))
150
+
151
+ body_rect = QRect(62, 76 - lift, 128, 104)
152
+ face_rect = QRect(50, 36 - lift, 152, 120)
153
+ body_radius = 46
154
+ if package.id == "mochi":
155
+ body_rect = QRect(66, 84 - lift, 120, 92)
156
+ face_rect = QRect(46, 40 - lift, 160, 122)
157
+ body_radius = 52
158
+ elif package.id == "pixel-cat":
159
+ body_rect = QRect(66, 82 - lift, 122, 96)
160
+ face_rect = QRect(54, 42 - lift, 146, 112)
161
+ body_radius = 12
162
+
163
+ offset_x = 0
164
+ if status_action == 1:
165
+ offset_x = -4 if float_phase in {0, 2} else 4
166
+ elif status_action == 2:
167
+ offset_x = -2 if float_phase in {0, 1} else 2
168
+ elif status_action == 8:
169
+ lift += 4
170
+ body_rect.translate(offset_x, 0)
171
+ face_rect.translate(offset_x, 0)
172
+
173
+ left_ear = QPainterPath()
174
+ left_ear.moveTo(82 + offset_x, 54 - lift)
175
+ left_ear.lineTo(102 + offset_x, 18 - lift)
176
+ left_ear.lineTo(122 + offset_x, 58 - lift)
177
+ left_ear.closeSubpath()
178
+
179
+ right_ear = QPainterPath()
180
+ right_ear.moveTo(130 + offset_x, 58 - lift)
181
+ right_ear.lineTo(150 + offset_x, 18 - lift)
182
+ right_ear.lineTo(170 + offset_x, 54 - lift)
183
+ right_ear.closeSubpath()
184
+
185
+ base_color = palette.pet_base
186
+ outline_color = palette.pet_outline
187
+ blush_color = palette.pet_blush
188
+ charm_color = palette.pet_charm
189
+ highlight_color = palette.pet_highlight
190
+ inner_ear_color = QColor(255, 226, 213)
191
+ collar_color = QColor(101, 190, 146)
192
+ if thinking:
193
+ collar_color = QColor(255, 193, 94)
194
+
195
+ if package.id == "terminal-cat":
196
+ collar_color = QColor(93, 214, 171)
197
+ elif package.id == "pixel-cat":
198
+ base_color = QColor(248, 220, 146)
199
+ outline_color = QColor(110, 72, 24)
200
+ blush_color = QColor(255, 168, 122, 72)
201
+ charm_color = QColor(255, 237, 166)
202
+ highlight_color = QColor(255, 248, 214, 32)
203
+ inner_ear_color = QColor(224, 143, 109)
204
+ collar_color = QColor(255, 133, 61)
205
+ elif package.id == "wizard-claude":
206
+ base_color = QColor(238, 234, 246)
207
+ outline_color = QColor(112, 102, 145)
208
+ blush_color = QColor(203, 185, 246, 64)
209
+ charm_color = QColor(255, 235, 161)
210
+ highlight_color = QColor(255, 255, 255, 74)
211
+ inner_ear_color = QColor(209, 171, 216)
212
+ collar_color = QColor(114, 98, 216)
213
+ elif package.id == "mochi":
214
+ base_color = QColor(253, 250, 244)
215
+ outline_color = QColor(226, 214, 202)
216
+ blush_color = QColor(255, 197, 189, 92)
217
+ charm_color = QColor(247, 214, 154)
218
+ highlight_color = QColor(255, 255, 255, 96)
219
+ inner_ear_color = QColor(244, 204, 197)
220
+ collar_color = QColor(240, 164, 112)
221
+
222
+ if status_action == 5:
223
+ collar_color = QColor(217, 88, 72)
224
+ elif status_action == 4:
225
+ collar_color = QColor(255, 187, 66)
226
+ elif status_action == 8:
227
+ collar_color = QColor(255, 215, 84)
228
+
229
+ painter.setBrush(base_color)
230
+ painter.setPen(QPen(outline_color, 2))
231
+ painter.drawPath(left_ear)
232
+ painter.drawPath(right_ear)
233
+
234
+ painter.setPen(Qt.PenStyle.NoPen)
235
+ painter.setBrush(inner_ear_color)
236
+ painter.drawPolygon(QPolygon([QPoint(90 + offset_x, 50 - lift), QPoint(103 + offset_x, 27 - lift), QPoint(114 + offset_x, 52 - lift)]))
237
+ painter.drawPolygon(QPolygon([QPoint(138 + offset_x, 52 - lift), QPoint(149 + offset_x, 27 - lift), QPoint(161 + offset_x, 50 - lift)]))
238
+
239
+ painter.setBrush(base_color)
240
+ painter.setPen(QPen(outline_color, 2))
241
+ painter.drawRoundedRect(body_rect, body_radius, body_radius)
242
+ painter.drawEllipse(face_rect)
243
+
244
+ painter.setPen(Qt.PenStyle.NoPen)
245
+ painter.setBrush(blush_color)
246
+ painter.drawEllipse(QRect(78 + offset_x, 105 - lift, 22, 12))
247
+ painter.drawEllipse(QRect(152 + offset_x, 105 - lift, 22, 12))
248
+
249
+ eye_y = 92 - lift
250
+ if status_action == 6:
251
+ painter.setPen(QPen(palette.pet_eye, 3))
252
+ painter.drawLine(95 + offset_x, eye_y, 108 + offset_x, eye_y)
253
+ painter.drawLine(143 + offset_x, eye_y, 156 + offset_x, eye_y)
254
+ painter.setPen(QPen(palette.pet_mouth, 2))
255
+ painter.drawText(QRect(166 + offset_x, 58 - lift, 30, 20), "Z")
256
+ elif status_action == 5:
257
+ painter.setPen(QPen(palette.pet_eye, 3))
258
+ painter.drawLine(96 + offset_x, eye_y - 3, 108 + offset_x, eye_y + 7)
259
+ painter.drawLine(108 + offset_x, eye_y - 3, 96 + offset_x, eye_y + 7)
260
+ painter.drawLine(142 + offset_x, eye_y - 3, 154 + offset_x, eye_y + 7)
261
+ painter.drawLine(154 + offset_x, eye_y - 3, 142 + offset_x, eye_y + 7)
262
+ elif status_action == 3:
263
+ painter.setPen(QPen(palette.pet_eye, 3))
264
+ painter.drawArc(QRect(95 + offset_x, eye_y - 2, 14, 10), 0, 180 * 16)
265
+ painter.drawArc(QRect(141 + offset_x, eye_y - 2, 14, 10), 0, 180 * 16)
266
+ elif thinking:
267
+ painter.setPen(QPen(palette.pet_eye, 3))
268
+ painter.drawLine(97 + offset_x, eye_y, 109 + offset_x, eye_y + 3)
269
+ painter.drawLine(141 + offset_x, eye_y + 3, 153 + offset_x, eye_y)
270
+ elif blink:
271
+ painter.setPen(QPen(palette.pet_eye, 3))
272
+ painter.drawLine(97 + offset_x, eye_y, 108 + offset_x, eye_y)
273
+ painter.drawLine(141 + offset_x, eye_y, 152 + offset_x, eye_y)
274
+ else:
275
+ painter.setPen(Qt.PenStyle.NoPen)
276
+ painter.setBrush(palette.pet_eye)
277
+ left_eye = QRect(96 + offset_x, eye_y - 5, 11, 15)
278
+ right_eye = QRect(142 + offset_x, eye_y - 5, 11, 15)
279
+ if status_action == 4:
280
+ left_eye = QRect(95 + offset_x, eye_y - 6, 13, 18)
281
+ right_eye = QRect(141 + offset_x, eye_y - 6, 13, 18)
282
+ painter.drawEllipse(left_eye)
283
+ painter.drawEllipse(right_eye)
284
+
285
+ painter.setBrush(palette.pet_nose)
286
+ painter.drawEllipse(QRect(116 + offset_x, 104 - lift, 19, 12))
287
+ painter.setPen(QPen(palette.pet_mouth, 3))
288
+ mouth_rect = QRect(104 + offset_x, 108 - lift, 44, 28)
289
+ mouth_start = 210 * 16
290
+ mouth_span = 120 * 16
291
+ if status_action == 3:
292
+ mouth_rect = QRect(104 + offset_x, 106 - lift, 46, 30)
293
+ mouth_start = 200 * 16
294
+ mouth_span = 140 * 16
295
+ elif status_action == 5:
296
+ mouth_rect = QRect(108 + offset_x, 118 - lift, 36, 18)
297
+ mouth_start = 30 * 16
298
+ mouth_span = 120 * 16
299
+ painter.drawArc(mouth_rect, mouth_start, mouth_span)
300
+
301
+ painter.setPen(Qt.PenStyle.NoPen)
302
+ painter.setBrush(collar_color)
303
+ collar_rect = QRect(92 + offset_x, 134 - lift, 68, 20)
304
+ if package.id == "mochi":
305
+ collar_rect = QRect(96 + offset_x, 132 - lift, 62, 18)
306
+ painter.drawRoundedRect(collar_rect, 10, 10)
307
+ painter.setBrush(charm_color)
308
+ if package.id == "wizard-claude":
309
+ star = QPolygon(
310
+ [
311
+ QPoint(125 + offset_x, 138 - lift),
312
+ QPoint(128 + offset_x, 144 - lift),
313
+ QPoint(135 + offset_x, 145 - lift),
314
+ QPoint(130 + offset_x, 149 - lift),
315
+ QPoint(132 + offset_x, 156 - lift),
316
+ QPoint(125 + offset_x, 152 - lift),
317
+ QPoint(118 + offset_x, 156 - lift),
318
+ QPoint(120 + offset_x, 149 - lift),
319
+ QPoint(115 + offset_x, 145 - lift),
320
+ QPoint(122 + offset_x, 144 - lift),
321
+ ]
322
+ )
323
+ painter.drawPolygon(star)
324
+ else:
325
+ painter.drawEllipse(QRect(121 + offset_x, 138 - lift, 9, 9))
326
+
327
+ painter.setBrush(highlight_color)
328
+ painter.drawEllipse(QRect(76 + offset_x, 54 - lift, 18, 12))
329
+ if package.id == "wizard-claude":
330
+ hat = QPainterPath()
331
+ hat.moveTo(118 + offset_x, 12 - lift)
332
+ hat.lineTo(96 + offset_x, 60 - lift)
333
+ hat.lineTo(154 + offset_x, 60 - lift)
334
+ hat.closeSubpath()
335
+ painter.setPen(QPen(QColor(76, 61, 138), 2))
336
+ painter.setBrush(QColor(110, 89, 201))
337
+ painter.drawPath(hat)
338
+ painter.setBrush(QColor(255, 221, 112))
339
+ painter.drawEllipse(QRect(118 + offset_x, 30 - lift, 8, 8))
340
+ elif package.id == "pixel-cat":
341
+ painter.setPen(QPen(QColor(110, 72, 24), 3))
342
+ painter.drawRect(QRect(82 + offset_x, 52 - lift, 12, 10))
343
+ painter.drawRect(QRect(156 + offset_x, 52 - lift, 12, 10))
344
+
345
+ if status_action == 4:
346
+ painter.setPen(QPen(QColor(255, 189, 59), 3))
347
+ painter.drawText(QRect(166 + offset_x, 56 - lift, 20, 26), "!")
348
+ elif status_action == 8:
349
+ painter.setPen(QPen(QColor(255, 212, 82), 3))
350
+ painter.drawText(QRect(168 + offset_x, 54 - lift, 24, 24), "*")
351
+
352
+
353
+ # ---------------------------------------------------------------------------
354
+ # 宠物预览组件(只读、静态,适合放置在设置窗口)
355
+ # ---------------------------------------------------------------------------
356
+
357
+ class PetPreviewWidget(QWidget):
358
+ """静态宠物预览 — 用于 Pet Manager 等管理界面。
359
+
360
+ 固定尺寸 120x120,显示宠物的 idle 常态,无动画、无交互。
361
+ """
362
+
363
+ def __init__(self, package: PetPackage | None = None, skin: str = "glass"):
364
+ super().__init__()
365
+ self._package = package or BUILTIN_PETS[0]
366
+ self._skin = skin if skin in SKIN_PALETTES else "glass"
367
+
368
+ def set_package(self, package: PetPackage):
369
+ self._package = package
370
+ self.update()
371
+
372
+ def set_skin(self, skin: str):
373
+ self._skin = skin if skin in SKIN_PALETTES else "glass"
374
+ self.update()
375
+
376
+ def paintEvent(self, _event):
377
+ painter = QPainter(self)
378
+ painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source)
379
+ painter.fillRect(self.rect(), Qt.GlobalColor.transparent)
380
+ painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
381
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
382
+ palette = SKIN_PALETTES[self._skin]
383
+ painter.save()
384
+ # Scale the 252x220 drawing to fit our preview rect
385
+ scale_x = self.width() / 252.0
386
+ scale_y = self.height() / 220.0
387
+ scale = min(scale_x, scale_y)
388
+ painter.translate(
389
+ (self.width() - 252 * scale) / 2,
390
+ (self.height() - 220 * scale) / 2,
391
+ )
392
+ painter.scale(scale, scale)
393
+ draw_pet_body(painter, self.rect(), self._package, palette,
394
+ status_action=0, thinking=False, blink=False, float_phase=0)
395
+ painter.restore()
396
+
397
+
398
+ # ---------------------------------------------------------------------------
399
+ # 宠物卡片组件(用于宠物列表网格)
400
+ # ---------------------------------------------------------------------------
401
+
402
+ class PetCardWidget(QWidget):
403
+ """宠物选择卡片 — 小预览 + 名称。
404
+
405
+ Signals:
406
+ clicked(): 卡片被点击
407
+ delete_requested(): 删除按钮被点击(仅 imported pets)
408
+ """
409
+
410
+ clicked = Signal()
411
+ delete_requested = Signal()
412
+
413
+ def __init__(self, package: PetPackage, skin: str = "glass",
414
+ selected: bool = False, show_delete: bool = False):
415
+ super().__init__()
416
+ self._package = package
417
+ self._skin = skin
418
+ self._selected = selected
419
+ self._show_delete = show_delete
420
+ self.setFixedSize(100, 120)
421
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
422
+
423
+ @property
424
+ def package_id(self) -> str:
425
+ return self._package.id
426
+
427
+ def set_selected(self, selected: bool):
428
+ self._selected = selected
429
+ self.update()
430
+
431
+ def set_skin(self, skin: str):
432
+ self._skin = skin
433
+ self.update()
434
+
435
+ def mousePressEvent(self, event):
436
+ if event.button() == Qt.MouseButton.LeftButton:
437
+ self.clicked.emit()
438
+ event.accept()
439
+ return
440
+ super().mousePressEvent(event)
441
+
442
+ def paintEvent(self, _event):
443
+ painter = QPainter(self)
444
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
445
+ palette = SKIN_PALETTES[self._skin]
446
+
447
+ # Background
448
+ border_color = QColor(255, 193, 94) if self._selected else QColor(72, 76, 84, 200)
449
+ painter.setPen(QPen(border_color, 2 if self._selected else 1))
450
+ painter.setBrush(QColor(16, 18, 22, 230))
451
+ painter.drawRoundedRect(QRect(1, 1, self.width() - 2, self.height() - 2), 12, 12)
452
+
453
+ # Pet preview (64x64 area)
454
+ painter.save()
455
+ preview_rect = QRect(18, 8, 64, 64)
456
+ scale_x = 64 / 252.0
457
+ scale_y = 64 / 220.0
458
+ scale = min(scale_x, scale_y)
459
+ painter.translate(
460
+ preview_rect.x() + (preview_rect.width() - 252 * scale) / 2,
461
+ preview_rect.y() + (preview_rect.height() - 220 * scale) / 2,
462
+ )
463
+ painter.scale(scale, scale)
464
+ draw_pet_body(painter, QRect(0, 0, 252, 220), self._package, palette,
465
+ status_action=0, thinking=False, blink=False, float_phase=0)
466
+ painter.restore()
467
+
468
+ # Name label
469
+ painter.setPen(QColor(220, 220, 220))
470
+ font = painter.font()
471
+ font.setPixelSize(11)
472
+ font.setBold(True)
473
+ painter.setFont(font)
474
+ name = self._package.display_name
475
+ painter.drawText(QRect(4, 76, self.width() - 8, 20),
476
+ Qt.AlignmentFlag.AlignCenter, name)
477
+
478
+ # Delete button (small X in top-right corner)
479
+ if self._show_delete:
480
+ painter.setPen(QPen(QColor(200, 80, 70), 2))
481
+ painter.drawText(QRect(self.width() - 22, 4, 18, 18),
482
+ Qt.AlignmentFlag.AlignCenter, "×")
483
+
484
+
485
+ # ---------------------------------------------------------------------------
486
+ # 主宠物窗口部件
487
+ # ---------------------------------------------------------------------------
488
+
489
+ class PetWidget(QWidget):
490
+ toggled = Signal()
491
+ request_quit = Signal()
492
+ hover_changed = Signal(bool)
493
+ position_changed = Signal()
494
+ cycle_pet_requested = Signal()
495
+ context_menu_requested = Signal(object)
496
+
497
+ def __init__(self, controller: SessionController):
498
+ super().__init__()
499
+ self._controller = controller
500
+ self._package = BUILTIN_PETS[0]
501
+ self._drag_offset: QPoint | None = None
502
+ self._press_global_pos: QPoint | None = None
503
+ self._thinking = False
504
+ self._blink = False
505
+ self._float_phase = 0
506
+ self._status_action = 0
507
+ self._skin = "glass"
508
+ self._bubble_timer = QTimer(self)
509
+ self._bubble_timer.setInterval(9000)
510
+ self._bubble_timer.timeout.connect(self._hide_bubble)
511
+
512
+ self._idle_timer = QTimer(self)
513
+ self._idle_timer.setSingleShot(True)
514
+ self._idle_timer.setInterval(30000)
515
+ self._idle_timer.timeout.connect(self._go_sleep)
516
+
517
+ self._saved_action = 0
518
+
519
+ self.setWindowFlags(
520
+ Qt.WindowType.FramelessWindowHint
521
+ | Qt.WindowType.Tool
522
+ | Qt.WindowType.NoDropShadowWindowHint
523
+ | Qt.WindowType.WindowStaysOnTopHint
524
+ | Qt.WindowType.WindowDoesNotAcceptFocus
525
+ )
526
+ self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
527
+ self.setFixedSize(252, 220)
528
+
529
+ self.bubble = SpeechBubble()
530
+ self.bubble.setParent(self)
531
+ self.bubble.setGeometry(QRect(14, 4, 212, 92))
532
+
533
+ self._anim_timer = QTimer(self)
534
+ self._anim_timer.setInterval(450)
535
+ self._anim_timer.timeout.connect(self._tick)
536
+ self._anim_timer.start()
537
+
538
+ self._mode_label = QLabel(self._controller.mode.upper(), self)
539
+ self._mode_label.setGeometry(QRect(86, 170, 82, 26))
540
+ self._mode_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
541
+ self._mode_label.hide()
542
+ self.set_skin("glass")
543
+ self._idle_timer.start()
544
+
545
+ def update_session_state(self, reply: SessionReply):
546
+ return
547
+
548
+ def set_thinking(self, thinking: bool):
549
+ self._thinking = thinking
550
+ self._status_action = 1 if thinking else 0 # 1=run for spritesheet
551
+ if thinking:
552
+ self._idle_timer.stop()
553
+ else:
554
+ self._idle_timer.start()
555
+ self.update()
556
+
557
+ def speak(self, text: str):
558
+ self.bubble.show_message(text)
559
+ self._bubble_timer.start()
560
+
561
+ def _hide_bubble(self):
562
+ self.bubble.hide()
563
+
564
+ def happy(self):
565
+ self._status_action = 3
566
+ QTimer.singleShot(1000, self._reset_action)
567
+ self.update()
568
+
569
+ def _go_sleep(self):
570
+ """Idle timeout → sleeping pose."""
571
+ if self._status_action == 0 and not self._thinking:
572
+ self._status_action = 6
573
+ self.update()
574
+
575
+ def _reset_idle_timer(self):
576
+ """Reset idle countdown and wake from sleep."""
577
+ self._idle_timer.stop()
578
+ self._idle_timer.start()
579
+ if self._status_action == 6:
580
+ self._status_action = 0
581
+ self.update()
582
+
583
+ def _tick(self):
584
+ self._blink = not self._blink
585
+ self._float_phase += 1
586
+ self.update()
587
+
588
+ def mousePressEvent(self, event: QMouseEvent):
589
+ if event.button() == Qt.MouseButton.LeftButton:
590
+ self._press_global_pos = event.globalPosition().toPoint()
591
+ self._drag_offset = event.globalPosition().toPoint() - self.frameGeometry().topLeft()
592
+ self._saved_action = self._status_action
593
+ self._reset_idle_timer()
594
+ event.accept()
595
+ return
596
+ if event.button() == Qt.MouseButton.RightButton:
597
+ self.context_menu_requested.emit(event.globalPosition().toPoint())
598
+ event.accept()
599
+ return
600
+ super().mousePressEvent(event)
601
+
602
+ def mouseMoveEvent(self, event: QMouseEvent):
603
+ if self._drag_offset is not None and event.buttons() & Qt.MouseButton.LeftButton:
604
+ self.move(event.globalPosition().toPoint() - self._drag_offset)
605
+ self.position_changed.emit()
606
+ # Show "move" (2) once dragged past threshold
607
+ start = self._press_global_pos
608
+ if start and (event.globalPosition().toPoint() - start).manhattanLength() > 10:
609
+ self._status_action = 2
610
+ self.update()
611
+ self._reset_idle_timer()
612
+ event.accept()
613
+ return
614
+ super().mouseMoveEvent(event)
615
+
616
+ def mouseReleaseEvent(self, event: QMouseEvent):
617
+ if event.button() == Qt.MouseButton.LeftButton:
618
+ moved = 0
619
+ if self._press_global_pos is not None:
620
+ moved = (event.globalPosition().toPoint() - self._press_global_pos).manhattanLength()
621
+ if self._drag_offset is not None and moved < 10:
622
+ # Click → happy (3) + open chat
623
+ self.happy()
624
+ QTimer.singleShot(60, self.toggled.emit)
625
+ else:
626
+ # Drag ended → restore saved state
627
+ saved = getattr(self, '_saved_action', 0)
628
+ self._status_action = saved if not self._thinking else 1
629
+ self.update()
630
+ self._drag_offset = None
631
+ self._press_global_pos = None
632
+ self._reset_idle_timer()
633
+ event.accept()
634
+ return
635
+ super().mouseReleaseEvent(event)
636
+
637
+ def paintEvent(self, _event):
638
+ painter = QPainter(self)
639
+ painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source)
640
+ painter.fillRect(self.rect(), Qt.GlobalColor.transparent)
641
+ painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
642
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
643
+ palette = SKIN_PALETTES[self._skin]
644
+ draw_pet_body(painter, self.rect(), self._package, palette,
645
+ status_action=self._status_action, thinking=self._thinking,
646
+ blink=self._blink, float_phase=self._float_phase)
647
+
648
+ def enterEvent(self, event):
649
+ self._reset_idle_timer()
650
+ self.hover_changed.emit(True)
651
+ super().enterEvent(event)
652
+
653
+ def leaveEvent(self, event):
654
+ self.hover_changed.emit(False)
655
+ super().leaveEvent(event)
656
+
657
+ def set_skin(self, skin: str):
658
+ self._skin = skin if skin in SKIN_PALETTES else "glass"
659
+ self.bubble.apply_skin(self._skin)
660
+ self.update()
661
+
662
+ def set_pet_package(self, package: PetPackage):
663
+ self._package = package
664
+ self.update()
665
+
666
+ def celebrate(self):
667
+ self._status_action = 8
668
+ QTimer.singleShot(1400, self._reset_action)
669
+ self.update()
670
+
671
+ def alert(self):
672
+ self._status_action = 4
673
+ QTimer.singleShot(1200, self._reset_action)
674
+ self.update()
675
+
676
+ def error(self):
677
+ self._status_action = 5
678
+ QTimer.singleShot(1600, self._reset_action)
679
+ self.update()
680
+
681
+ def _reset_action(self):
682
+ self._status_action = 1 if self._thinking else 0 # 1=run
683
+ self.update()