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,2298 @@
1
+ """
2
+ Floating windows used by the desktop pet UI."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from pathlib import Path
8
+
9
+ from PySide6.QtCore import QPoint, QRect, QSize, Qt, QTimer, Signal
10
+ from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPixmap
11
+ from PySide6.QtWidgets import (
12
+ QAbstractItemView,
13
+ QFrame,
14
+ QFileDialog,
15
+ QGridLayout,
16
+ QHBoxLayout,
17
+ QLabel,
18
+ QLineEdit,
19
+ QListView,
20
+ QListWidget,
21
+ QListWidgetItem,
22
+ QPlainTextEdit,
23
+ QPushButton,
24
+ QScrollArea,
25
+ QSizePolicy,
26
+ QVBoxLayout,
27
+ QWidget,
28
+ )
29
+
30
+ from ...chat import ChatAttachment, GuiChatSubmission
31
+ from ...config import GuiModelConfig, pai_config
32
+ from ...llm.factory import default_model_for
33
+ from ...runtime import SessionController, SessionReply
34
+ from .base import FramelessToolWindow, make_shadow, termi_panel_stylesheet
35
+ from .data import (
36
+ ChatMessage,
37
+ PendingAttachmentStore,
38
+ PetPackage,
39
+ gui_model_profile_label,
40
+ gui_model_profile_meta,
41
+ load_gui_model_config_from_json,
42
+ normalize_gui_model_base_url,
43
+ )
44
+ from .widgets import PetCardWidget
45
+ from .workers import ModelConnectionTestWorker
46
+
47
+
48
+ class CommandCardButton(QPushButton):
49
+ def __init__(self, title: str, summary: str, command_text: str):
50
+ super().__init__()
51
+ self._title = title
52
+ self._summary = summary
53
+ self.command_text = command_text
54
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
55
+ self.setFixedHeight(112)
56
+ self.setFlat(True)
57
+ self.setToolTip(summary or command_text)
58
+
59
+ def enterEvent(self, event):
60
+ self.update()
61
+ super().enterEvent(event)
62
+
63
+ def leaveEvent(self, event):
64
+ self.update()
65
+ super().leaveEvent(event)
66
+
67
+ def paintEvent(self, _event):
68
+ painter = QPainter(self)
69
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
70
+ rect = self.rect().adjusted(1, 1, -1, -1)
71
+ if self.isDown():
72
+ bg = QColor(38, 41, 48)
73
+ elif self.underMouse():
74
+ bg = QColor(28, 31, 36)
75
+ else:
76
+ bg = QColor(22, 24, 28)
77
+
78
+ painter.setPen(QPen(QColor(58, 62, 70), 1))
79
+ painter.setBrush(bg)
80
+ painter.drawRoundedRect(rect, 16, 16)
81
+
82
+ title_rect = rect.adjusted(18, 16, -18, -56)
83
+ summary_rect = rect.adjusted(18, 52, -18, -14)
84
+
85
+ title_font = QFont("Menlo")
86
+ if not title_font.exactMatch():
87
+ title_font = QFont()
88
+ title_font.setPointSize(14)
89
+ title_font.setBold(True)
90
+ painter.setFont(title_font)
91
+ painter.setPen(QColor(236, 236, 236))
92
+ painter.drawText(
93
+ title_rect,
94
+ int(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop | Qt.TextFlag.TextWordWrap),
95
+ self._title,
96
+ )
97
+
98
+ summary_font = QFont()
99
+ summary_font.setPointSize(10)
100
+ summary_font.setWeight(QFont.Weight.DemiBold)
101
+ painter.setFont(summary_font)
102
+ painter.setPen(QColor(145, 145, 145))
103
+ painter.drawText(
104
+ summary_rect,
105
+ int(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop | Qt.TextFlag.TextWordWrap),
106
+ self._summary,
107
+ )
108
+
109
+
110
+ class FloatingActionBar(QFrame):
111
+ chat_requested = Signal()
112
+ commands_requested = Signal()
113
+ hover_changed = Signal(bool)
114
+
115
+ def __init__(self):
116
+ super().__init__()
117
+ self._active_panel = ""
118
+ self._texts = {
119
+ "toolbar_chat": "聊天",
120
+ "toolbar_commands": "命令",
121
+ }
122
+ self.setWindowFlags(
123
+ Qt.WindowType.FramelessWindowHint
124
+ | Qt.WindowType.Tool
125
+ | Qt.WindowType.WindowStaysOnTopHint
126
+ | Qt.WindowType.WindowDoesNotAcceptFocus
127
+ )
128
+ self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
129
+ self.setObjectName("toolbarPanel")
130
+ make_shadow(self, blur=24, y=6, alpha=54)
131
+
132
+ layout = QHBoxLayout(self)
133
+ layout.setContentsMargins(12, 12, 12, 12)
134
+ layout.setSpacing(10)
135
+ self.commands_button = self._make_button("令", self.commands_requested.emit)
136
+ self.chat_button = self._make_button("聊", self.chat_requested.emit)
137
+ layout.addWidget(self.commands_button)
138
+ layout.addWidget(self.chat_button)
139
+
140
+ self.apply_skin("dark")
141
+ self.hide()
142
+
143
+ def _make_button(self, text: str, handler):
144
+ button = QPushButton(text)
145
+ button.setCursor(Qt.CursorShape.PointingHandCursor)
146
+ button.setFixedSize(54, 54)
147
+ button.clicked.connect(handler)
148
+ return button
149
+
150
+ def sync_state(self, mode: str, presentation_mode: str, skin: str, pet_name: str):
151
+ return
152
+
153
+ def set_active_panel(self, panel: str):
154
+ self._active_panel = panel
155
+ self._refresh_button_styles()
156
+
157
+ def set_language(self, texts: dict[str, str]):
158
+ self._texts["toolbar_chat"] = texts.get("toolbar_chat", "聊天")
159
+ self._texts["toolbar_commands"] = texts.get("toolbar_commands", "命令")
160
+ self.chat_button.setToolTip(self._texts["toolbar_chat"])
161
+ self.commands_button.setToolTip(self._texts["toolbar_commands"])
162
+
163
+ def _refresh_button_styles(self):
164
+ self.commands_button.setStyleSheet(self._button_stylesheet(self._active_panel == "commands"))
165
+ self.chat_button.setStyleSheet(self._button_stylesheet(self._active_panel == "chat"))
166
+
167
+ def _button_stylesheet(self, active: bool) -> str:
168
+ bg = "rgba(63, 67, 74, 235)" if active else "rgba(29, 32, 36, 232)"
169
+ border = "rgba(132, 136, 144, 230)" if active else "rgba(86, 90, 98, 220)"
170
+ return (
171
+ "QPushButton {"
172
+ f"background: {bg};"
173
+ f"border: 1px solid {border};"
174
+ "border-radius: 27px;"
175
+ "color: #f2f2f2;"
176
+ "font-size: 18px;"
177
+ "font-weight: 700;"
178
+ "}"
179
+ "QPushButton:hover { background: rgba(74, 79, 87, 238); }"
180
+ )
181
+
182
+ def apply_skin(self, skin: str):
183
+ self.setStyleSheet(f"#toolbarPanel {{{termi_panel_stylesheet(radius=28)}}}")
184
+ self._refresh_button_styles()
185
+
186
+ def enterEvent(self, event):
187
+ self.hover_changed.emit(True)
188
+ super().enterEvent(event)
189
+
190
+ def leaveEvent(self, event):
191
+ self.hover_changed.emit(False)
192
+ super().leaveEvent(event)
193
+
194
+
195
+ class StatusCardWidget(QFrame):
196
+ """Floating status card — mode, model, state dot, optional bubble preview.
197
+
198
+ Appears above the toolbar on hover. Managed by PetCoordinator.
199
+ """
200
+
201
+ STATE_COLORS = {
202
+ "idle": QColor(140, 140, 145),
203
+ "thinking": QColor(255, 193, 94),
204
+ "working": QColor(10, 132, 255),
205
+ "error": QColor(255, 89, 89),
206
+ "celebrate": QColor(50, 210, 120),
207
+ "alert": QColor(255, 183, 64),
208
+ }
209
+
210
+ STATE_LABELS = {
211
+ "idle": "空闲",
212
+ "thinking": "思考中",
213
+ "working": "执行中",
214
+ "error": "出错",
215
+ "celebrate": "完成",
216
+ "alert": "提醒",
217
+ }
218
+
219
+ CARD_WIDTH = 260
220
+ ROW1_Y = 10
221
+ ROW1_H = 28
222
+ DIVIDER_Y = 42
223
+ ROW2_Y = 48
224
+ ROW2_H = 26
225
+ PAD_TOP = 8
226
+ PAD_BOTTOM = 10
227
+
228
+ def __init__(self):
229
+ super().__init__()
230
+ self._mode = "single"
231
+ self._model_provider = ""
232
+ self._model_name = ""
233
+ self._state = "idle"
234
+ self._bubble_text = ""
235
+
236
+ self.setObjectName("statusCard")
237
+ self.setWindowFlags(
238
+ Qt.WindowType.FramelessWindowHint
239
+ | Qt.WindowType.Tool
240
+ | Qt.WindowType.WindowStaysOnTopHint
241
+ | Qt.WindowType.WindowDoesNotAcceptFocus
242
+ )
243
+ self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
244
+ make_shadow(self, blur=24, y=6, alpha=54)
245
+ self.setFixedWidth(self.CARD_WIDTH)
246
+ self.adjustSize()
247
+ self.hide()
248
+
249
+ def set_status(
250
+ self,
251
+ mode: str = "",
252
+ model_provider: str = "",
253
+ model_name: str = "",
254
+ state: str = "idle",
255
+ bubble_text: str = "",
256
+ ):
257
+ self._mode = mode or self._mode
258
+ self._model_provider = model_provider or self._model_provider
259
+ self._model_name = model_name or self._model_name
260
+ self._state = state if state in self.STATE_COLORS else "idle"
261
+ self._bubble_text = bubble_text or ""
262
+ self.adjustSize()
263
+ self.update()
264
+
265
+ def sizeHint(self):
266
+ h = self.PAD_TOP + self.ROW1_H + self.PAD_BOTTOM
267
+ if self._bubble_text:
268
+ h += self.ROW2_Y - self.ROW1_H + self.ROW2_H
269
+ return QSize(self.CARD_WIDTH, h)
270
+
271
+ def paintEvent(self, _event):
272
+ painter = QPainter(self)
273
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
274
+ rect = self.rect().adjusted(1, 1, -1, -1)
275
+
276
+ # Background
277
+ painter.setPen(QPen(QColor(58, 62, 70), 1))
278
+ painter.setBrush(QColor(16, 18, 22, 238))
279
+ painter.drawRoundedRect(rect, 18, 18)
280
+
281
+ dot_color = self.STATE_COLORS.get(self._state, self.STATE_COLORS["idle"])
282
+
283
+ # State dot
284
+ painter.setPen(Qt.PenStyle.NoPen)
285
+ painter.setBrush(dot_color)
286
+ painter.drawEllipse(QPoint(24, self.ROW1_Y + self.ROW1_H // 2), 5, 5)
287
+
288
+ # Mode badge
289
+ mode_font = QFont("Menlo")
290
+ if not mode_font.exactMatch():
291
+ mode_font = QFont()
292
+ mode_font.setPointSize(11)
293
+ mode_font.setBold(True)
294
+ painter.setFont(mode_font)
295
+ painter.setPen(QColor(236, 236, 236))
296
+ mode_rect = QRect(36, self.ROW1_Y, 72, self.ROW1_H)
297
+ painter.drawText(
298
+ mode_rect,
299
+ Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
300
+ self._mode.upper(),
301
+ )
302
+
303
+ # Model name
304
+ model_text = self._model_name or self._model_provider
305
+ if model_text:
306
+ model_font = QFont()
307
+ model_font.setPointSize(10)
308
+ painter.setFont(model_font)
309
+ painter.setPen(QColor(165, 165, 170))
310
+ model_rect = QRect(104, self.ROW1_Y, 90, self.ROW1_H)
311
+ painter.drawText(
312
+ model_rect,
313
+ Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
314
+ model_text,
315
+ )
316
+
317
+ # State label (right-aligned)
318
+ state_label = self.STATE_LABELS.get(self._state, self._state)
319
+ painter.setPen(dot_color)
320
+ painter.setFont(mode_font)
321
+ state_rect = QRect(rect.right() - 76, self.ROW1_Y, 62, self.ROW1_H)
322
+ painter.drawText(
323
+ state_rect,
324
+ Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
325
+ state_label,
326
+ )
327
+
328
+ # Bubble text (optional second row)
329
+ if self._bubble_text:
330
+ import textwrap
331
+
332
+ preview = textwrap.shorten(
333
+ " ".join(self._bubble_text.split()), width=48, placeholder="..."
334
+ )
335
+ painter.setPen(QPen(QColor(58, 62, 70), 1))
336
+ painter.drawLine(rect.left() + 14, self.DIVIDER_Y, rect.right() - 14, self.DIVIDER_Y)
337
+
338
+ bubble_font = QFont()
339
+ bubble_font.setPointSize(10)
340
+ painter.setFont(bubble_font)
341
+ painter.setPen(QColor(185, 185, 190))
342
+ text_rect = QRect(20, self.ROW2_Y, rect.width() - 40, self.ROW2_H)
343
+ painter.drawText(
344
+ text_rect,
345
+ Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter | Qt.TextFlag.TextWordWrap,
346
+ preview,
347
+ )
348
+
349
+
350
+ class CommandWindow(FramelessToolWindow):
351
+ submitted = Signal(str)
352
+
353
+ def __init__(self):
354
+ super().__init__()
355
+ self._cards: list[CommandCardButton] = []
356
+ self._language_texts: dict[str, str] = {}
357
+ self.setMinimumSize(QSize(720, 560))
358
+ self.resize(720, 560)
359
+
360
+ root = QWidget()
361
+ self.setCentralWidget(root)
362
+ outer = QVBoxLayout(root)
363
+ outer.setContentsMargins(0, 0, 0, 0)
364
+
365
+ self.panel = QFrame()
366
+ self.panel.setObjectName("commandPanel")
367
+ make_shadow(self.panel, blur=34, y=10, alpha=78)
368
+ outer.addWidget(self.panel)
369
+
370
+ layout = QVBoxLayout(self.panel)
371
+ layout.setContentsMargins(0, 0, 0, 0)
372
+ layout.setSpacing(0)
373
+
374
+ header = QHBoxLayout()
375
+ header.setContentsMargins(18, 18, 18, 18)
376
+ header.setSpacing(12)
377
+
378
+ self.icon_label = QLabel(">_")
379
+ self.icon_label.setObjectName("commandIcon")
380
+ self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
381
+ self.icon_label.setFixedSize(34, 28)
382
+ header.addWidget(self.icon_label)
383
+
384
+ self.title_label = QLabel("快捷指令")
385
+ title_font = QFont()
386
+ title_font.setPointSize(18)
387
+ title_font.setBold(True)
388
+ self.title_label.setFont(title_font)
389
+ header.addWidget(self.title_label)
390
+ header.addStretch(1)
391
+
392
+ self.count_label = QLabel("0")
393
+ self.count_label.setObjectName("commandCount")
394
+ header.addWidget(self.count_label)
395
+ layout.addLayout(header)
396
+
397
+ scroll = QScrollArea()
398
+ scroll.setWidgetResizable(True)
399
+ scroll.setFrameShape(QFrame.Shape.NoFrame)
400
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
401
+ scroll.setStyleSheet("background: transparent;")
402
+ layout.addWidget(scroll, 1)
403
+
404
+ container = QWidget()
405
+ scroll.setWidget(container)
406
+ self.grid = QGridLayout(container)
407
+ self.grid.setContentsMargins(18, 8, 18, 18)
408
+ self.grid.setHorizontalSpacing(14)
409
+ self.grid.setVerticalSpacing(14)
410
+
411
+ self.apply_skin("dark")
412
+
413
+ def set_quick_commands(self, commands: list[dict]):
414
+ while self.grid.count():
415
+ item = self.grid.takeAt(0)
416
+ widget = item.widget()
417
+ if widget is not None:
418
+ widget.deleteLater()
419
+ self._cards.clear()
420
+
421
+ visible = 0
422
+ for command in commands:
423
+ command_text = str(command.get("command", "")).strip()
424
+ if not command_text:
425
+ continue
426
+ title = str(command.get("label", command_text)).strip() or command_text
427
+ summary = str(command.get("description", "")).strip()
428
+ button = CommandCardButton(title, summary, command_text)
429
+ button.clicked.connect(lambda _checked=False, value=command_text: self.submitted.emit(value))
430
+ self.grid.addWidget(button, visible // 2, visible % 2)
431
+ self._cards.append(button)
432
+ visible += 1
433
+ self.count_label.setText(str(visible))
434
+
435
+ def set_language(self, texts: dict[str, str]):
436
+ self._language_texts = dict(texts)
437
+ self.title_label.setText(texts.get("quick_commands_title", "快捷指令"))
438
+
439
+ def apply_skin(self, skin: str):
440
+ self.setStyleSheet(
441
+ "QMainWindow { background: transparent; }"
442
+ f"#commandPanel {{{termi_panel_stylesheet(radius=26)}}}"
443
+ "#commandIcon {"
444
+ "background: rgba(235, 235, 235, 220);"
445
+ "border-radius: 8px;"
446
+ "color: #111111;"
447
+ "font-size: 14px;"
448
+ "font-weight: 700;"
449
+ "}"
450
+ "#commandCount {"
451
+ "color: rgba(164, 164, 164, 220);"
452
+ "font-size: 14px;"
453
+ "font-weight: 700;"
454
+ "}"
455
+ "QLabel { color: #f1f1f1; }"
456
+ "QScrollArea { background: transparent; }"
457
+ )
458
+
459
+
460
+ def _build_attachment_thumbnail(file_path: str, size: int) -> QPixmap:
461
+ pixmap = QPixmap(file_path)
462
+ if not pixmap.isNull():
463
+ return pixmap.scaled(
464
+ size,
465
+ size,
466
+ Qt.AspectRatioMode.KeepAspectRatioByExpanding,
467
+ Qt.TransformationMode.SmoothTransformation,
468
+ )
469
+
470
+ fallback = QPixmap(size, size)
471
+ fallback.fill(QColor(54, 57, 63))
472
+ painter = QPainter(fallback)
473
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
474
+ painter.setPen(QColor(219, 222, 228))
475
+ font = QFont()
476
+ font.setPointSize(9)
477
+ font.setBold(True)
478
+ painter.setFont(font)
479
+ painter.drawText(fallback.rect(), int(Qt.AlignmentFlag.AlignCenter), "IMG")
480
+ painter.end()
481
+ return fallback
482
+
483
+
484
+ class AttachmentChipWidget(QFrame):
485
+ remove_requested = Signal(str)
486
+
487
+ def __init__(self, attachment: ChatAttachment):
488
+ super().__init__()
489
+ self.attachment = attachment
490
+ self.setObjectName("attachmentChip")
491
+ self.setFixedSize(88, 100)
492
+
493
+ layout = QVBoxLayout(self)
494
+ layout.setContentsMargins(6, 6, 6, 6)
495
+ layout.setSpacing(4)
496
+
497
+ top = QHBoxLayout()
498
+ top.setContentsMargins(0, 0, 0, 0)
499
+ top.setSpacing(0)
500
+ top.addStretch(1)
501
+ self.remove_button = QPushButton("x")
502
+ self.remove_button.setObjectName("attachmentRemoveButton")
503
+ self.remove_button.setCursor(Qt.CursorShape.PointingHandCursor)
504
+ self.remove_button.setFixedSize(18, 18)
505
+ self.remove_button.clicked.connect(lambda: self.remove_requested.emit(self.attachment.id))
506
+ top.addWidget(self.remove_button)
507
+ layout.addLayout(top)
508
+
509
+ self.thumbnail = QLabel()
510
+ self.thumbnail.setAlignment(Qt.AlignmentFlag.AlignCenter)
511
+ self.thumbnail.setFixedSize(72, 52)
512
+ self.thumbnail.setPixmap(_build_attachment_thumbnail(attachment.file_path, 72))
513
+ self.thumbnail.setScaledContents(False)
514
+ self.thumbnail.setToolTip(attachment.file_path)
515
+ layout.addWidget(self.thumbnail, 0, Qt.AlignmentFlag.AlignHCenter)
516
+
517
+ self.name_label = QLabel(attachment.display_name)
518
+ self.name_label.setWordWrap(True)
519
+ self.name_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
520
+ self.name_label.setToolTip(attachment.file_path)
521
+ self.name_label.setObjectName("attachmentNameLabel")
522
+ layout.addWidget(self.name_label, 1)
523
+
524
+
525
+ class AttachmentListWidget(QListWidget):
526
+ files_dropped = Signal(list)
527
+ order_changed = Signal(list)
528
+ remove_requested = Signal(str)
529
+
530
+ def __init__(self):
531
+ super().__init__()
532
+ self.setAcceptDrops(True)
533
+ self.setDragEnabled(True)
534
+ self.setDropIndicatorShown(True)
535
+ self.setDefaultDropAction(Qt.DropAction.MoveAction)
536
+ self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
537
+ self.setFlow(QListView.Flow.LeftToRight)
538
+ self.setWrapping(False)
539
+ self.setSpacing(8)
540
+ self.setMovement(QListView.Movement.Snap)
541
+ self.setResizeMode(QListView.ResizeMode.Adjust)
542
+ self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
543
+ self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
544
+ self.setFixedHeight(116)
545
+
546
+ def add_attachment(self, attachment: ChatAttachment):
547
+ item = QListWidgetItem()
548
+ item.setData(Qt.ItemDataRole.UserRole, attachment.id)
549
+ item.setSizeHint(QSize(92, 104))
550
+ self.addItem(item)
551
+ chip = AttachmentChipWidget(attachment)
552
+ chip.remove_requested.connect(self.remove_requested.emit)
553
+ self.setItemWidget(item, chip)
554
+
555
+ def attachment_ids(self) -> list[str]:
556
+ return [
557
+ str(self.item(index).data(Qt.ItemDataRole.UserRole))
558
+ for index in range(self.count())
559
+ ]
560
+
561
+ def remove_attachment(self, attachment_id: str):
562
+ for index in range(self.count()):
563
+ item = self.item(index)
564
+ if str(item.data(Qt.ItemDataRole.UserRole)) != attachment_id:
565
+ continue
566
+ removed = self.takeItem(index)
567
+ del removed
568
+ return
569
+
570
+ def dragEnterEvent(self, event):
571
+ if self._extract_paths(event):
572
+ event.acceptProposedAction()
573
+ return
574
+ super().dragEnterEvent(event)
575
+
576
+ def dragMoveEvent(self, event):
577
+ if self._extract_paths(event):
578
+ event.acceptProposedAction()
579
+ return
580
+ super().dragMoveEvent(event)
581
+
582
+ def dropEvent(self, event):
583
+ paths = self._extract_paths(event)
584
+ if paths:
585
+ self.files_dropped.emit(paths)
586
+ event.acceptProposedAction()
587
+ return
588
+ super().dropEvent(event)
589
+ self.order_changed.emit(self.attachment_ids())
590
+
591
+ @staticmethod
592
+ def _extract_paths(event) -> list[str]:
593
+ mime_data = event.mimeData()
594
+ if mime_data is None or not mime_data.hasUrls():
595
+ return []
596
+ paths: list[str] = []
597
+ for url in mime_data.urls():
598
+ if url.isLocalFile():
599
+ local_path = url.toLocalFile()
600
+ if local_path:
601
+ paths.append(local_path)
602
+ return paths
603
+
604
+
605
+ class ChatBubbleWidget(QFrame):
606
+ def __init__(self, message: ChatMessage, streaming: bool = False):
607
+ super().__init__()
608
+ self._role = message.role
609
+ self._streaming = streaming
610
+ self.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Preferred)
611
+
612
+ layout = QVBoxLayout(self)
613
+ layout.setContentsMargins(11, 8, 11, 8)
614
+ layout.setSpacing(6)
615
+ self.label = QLabel()
616
+ self.label.setWordWrap(True)
617
+ self.label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
618
+ self.label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
619
+ layout.addWidget(self.label)
620
+
621
+ self.attachments_grid = None
622
+ if message.attachments:
623
+ attachments_widget = QWidget()
624
+ self.attachments_grid = QGridLayout(attachments_widget)
625
+ self.attachments_grid.setContentsMargins(0, 0, 0, 0)
626
+ self.attachments_grid.setHorizontalSpacing(6)
627
+ self.attachments_grid.setVerticalSpacing(6)
628
+ for index, attachment in enumerate(message.attachments):
629
+ preview = QLabel()
630
+ preview.setFixedSize(84, 84)
631
+ preview.setPixmap(_build_attachment_thumbnail(attachment.file_path, 84))
632
+ preview.setScaledContents(False)
633
+ preview.setAlignment(Qt.AlignmentFlag.AlignCenter)
634
+ preview.setStyleSheet(
635
+ "background: rgba(255, 255, 255, 0.08);"
636
+ "border: 1px solid rgba(255, 255, 255, 0.18);"
637
+ "border-radius: 10px;"
638
+ )
639
+ preview.setToolTip(attachment.file_path)
640
+ self.attachments_grid.addWidget(preview, index // 2, index % 2)
641
+ layout.addWidget(attachments_widget)
642
+
643
+ self._apply_role_style()
644
+ self.set_text(message.text, streaming=streaming)
645
+
646
+ def set_text(self, text: str, streaming: bool = False):
647
+ self._streaming = streaming
648
+ self.label.setText(f"{text}▍" if streaming and text else text)
649
+ self.label.setVisible(bool(text) or streaming)
650
+ self.adjustSize()
651
+
652
+ def _apply_role_style(self):
653
+ if self._role == "user":
654
+ bubble_bg = "#0a84ff"
655
+ bubble_border = "#4eabff"
656
+ bubble_text = "#ffffff"
657
+ elif self._role == "error":
658
+ bubble_bg = "rgba(79, 33, 33, 0.94)"
659
+ bubble_border = "rgba(153, 74, 74, 0.92)"
660
+ bubble_text = "#ffe4e4"
661
+ else:
662
+ bubble_bg = "rgba(46, 44, 45, 0.96)"
663
+ bubble_border = "rgba(92, 92, 98, 0.84)"
664
+ bubble_text = "#f2f2f2"
665
+
666
+ self.setStyleSheet(
667
+ "QFrame {"
668
+ f"background: {bubble_bg};"
669
+ f"border: 1px solid {bubble_border};"
670
+ "border-radius: 14px;"
671
+ "}"
672
+ "QLabel {"
673
+ f"color: {bubble_text};"
674
+ "font-size: 13px;"
675
+ "font-weight: 500;"
676
+ "background: transparent;"
677
+ "border: none;"
678
+ "}"
679
+ )
680
+
681
+
682
+ class ChatMessageRow(QWidget):
683
+ def __init__(self, message: ChatMessage, streaming: bool = False):
684
+ super().__init__()
685
+ layout = QHBoxLayout(self)
686
+ layout.setContentsMargins(0, 0, 0, 0)
687
+ layout.setSpacing(0)
688
+
689
+ self.bubble = ChatBubbleWidget(message, streaming=streaming)
690
+ self.bubble.setMaximumWidth(226)
691
+ if message.role == "user":
692
+ layout.addStretch(1)
693
+ layout.addWidget(self.bubble, 0, Qt.AlignmentFlag.AlignRight)
694
+ else:
695
+ layout.addWidget(self.bubble, 0, Qt.AlignmentFlag.AlignLeft)
696
+ layout.addStretch(1)
697
+
698
+ def set_text(self, text: str, streaming: bool = False):
699
+ self.bubble.set_text(text, streaming=streaming)
700
+
701
+
702
+ class ChatWindow(FramelessToolWindow):
703
+ submitted = Signal(object)
704
+ attachment_error = Signal(str)
705
+
706
+ def __init__(self, controller: SessionController):
707
+ super().__init__()
708
+ self._controller = controller
709
+ self._busy = False
710
+ self._language_texts: dict[str, str] = {}
711
+ self._messages: list[ChatMessage] = []
712
+ self._attachment_store = PendingAttachmentStore()
713
+ self._streaming_message: ChatMessage | None = None
714
+ self._streaming_row: ChatMessageRow | None = None
715
+ self._streaming_visible_text = ""
716
+ self._streaming_index = 0
717
+ self._compact_size = QSize(296, 74)
718
+ self._expanded_size = QSize(296, 340)
719
+ self._streaming_timer = QTimer(self)
720
+ self._streaming_timer.timeout.connect(self._advance_streaming)
721
+ self.setWindowTitle("Vox Pet")
722
+ self.setAcceptDrops(True)
723
+ self.setMinimumSize(self._compact_size)
724
+ self.resize(self._compact_size)
725
+
726
+ root = QWidget()
727
+ self.setCentralWidget(root)
728
+ outer = QVBoxLayout(root)
729
+ outer.setContentsMargins(0, 0, 0, 0)
730
+
731
+ panel = QFrame()
732
+ panel.setObjectName("chatPanel")
733
+ make_shadow(panel, blur=34, y=10, alpha=78)
734
+ outer.addWidget(panel)
735
+
736
+ layout = QVBoxLayout(panel)
737
+ layout.setContentsMargins(16, 14, 16, 14)
738
+ layout.setSpacing(10)
739
+
740
+ self.messages_scroll = QScrollArea()
741
+ self.messages_scroll.setWidgetResizable(True)
742
+ self.messages_scroll.setFrameShape(QFrame.Shape.NoFrame)
743
+ self.messages_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
744
+ self.messages_scroll.setStyleSheet(
745
+ "QScrollArea { background: transparent; border: none; }"
746
+ "QScrollBar:vertical { background: transparent; width: 8px; margin: 2px 0 2px 0; }"
747
+ "QScrollBar::handle:vertical {"
748
+ "background: rgba(110, 110, 116, 0.42);"
749
+ "border-radius: 4px;"
750
+ "min-height: 24px;"
751
+ "}"
752
+ "QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }"
753
+ "QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; }"
754
+ )
755
+
756
+ self.messages_container = QWidget()
757
+ self.messages_container.setStyleSheet("background: transparent;")
758
+ self.messages_layout = QVBoxLayout(self.messages_container)
759
+ self.messages_layout.setContentsMargins(2, 4, 2, 4)
760
+ self.messages_layout.setSpacing(5)
761
+ self.messages_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
762
+ self.messages_scroll.setWidget(self.messages_container)
763
+ self.messages_scroll.setMinimumHeight(180)
764
+ self.messages_scroll.setMaximumHeight(228)
765
+ layout.addWidget(self.messages_scroll, 1)
766
+
767
+ self.attachments_list = AttachmentListWidget()
768
+ self.attachments_list.files_dropped.connect(self.add_attachment_files)
769
+ self.attachments_list.order_changed.connect(self._reorder_pending_attachments)
770
+ self.attachments_list.remove_requested.connect(self._remove_pending_attachment)
771
+ self.attachments_list.hide()
772
+ layout.addWidget(self.attachments_list)
773
+
774
+ footer = QHBoxLayout()
775
+ footer.setSpacing(6)
776
+ self.attach_button = QPushButton("+")
777
+ self.attach_button.setObjectName("attachButton")
778
+ self.attach_button.setCursor(Qt.CursorShape.PointingHandCursor)
779
+ self.attach_button.setFixedSize(40, 40)
780
+ self.attach_button.clicked.connect(self._open_attachment_picker)
781
+ footer.addWidget(self.attach_button)
782
+
783
+ self.input = QLineEdit()
784
+ self.input.returnPressed.connect(self._emit_submit)
785
+ self.input.textChanged.connect(self._refresh_send_button_state)
786
+ footer.addWidget(self.input, 1)
787
+
788
+ self.send_button = QPushButton("↑")
789
+ self.send_button.setObjectName("sendButton")
790
+ self.send_button.setFixedSize(40, 40)
791
+ self.send_button.clicked.connect(self._emit_submit)
792
+ footer.addWidget(self.send_button)
793
+ layout.addLayout(footer)
794
+
795
+ self.apply_skin("dark")
796
+ self.set_language((pai_config.get_language(pai_config.active_language) or {}).get("texts", {}))
797
+ self._update_window_mode()
798
+ self._refresh_send_button_state()
799
+
800
+ def append_message(self, message: ChatMessage):
801
+ self._flush_streaming_message()
802
+ self._messages.append(message)
803
+ self._add_message_row(message)
804
+
805
+ def start_streaming_message(self, message: ChatMessage):
806
+ self._flush_streaming_message()
807
+ self._streaming_message = message
808
+ self._streaming_row = self._add_message_row(ChatMessage(message.role, ""), streaming=True)
809
+ self._streaming_visible_text = ""
810
+ self._streaming_index = 0
811
+ self._update_window_mode()
812
+ self._scroll_messages_to_bottom()
813
+ self._streaming_timer.start(22)
814
+
815
+ def clear_messages(self):
816
+ self._streaming_timer.stop()
817
+ self._streaming_message = None
818
+ self._streaming_row = None
819
+ self._streaming_visible_text = ""
820
+ self._streaming_index = 0
821
+ self._messages.clear()
822
+ while self.messages_layout.count():
823
+ item = self.messages_layout.takeAt(0)
824
+ widget = item.widget()
825
+ if widget is not None:
826
+ widget.deleteLater()
827
+ self._update_window_mode()
828
+ self._refresh_send_button_state()
829
+
830
+ def sync_state(self, reply: SessionReply, skin: str):
831
+ return
832
+
833
+ def set_busy(self, busy: bool):
834
+ self._busy = busy
835
+ self.input.setEnabled(not busy)
836
+ self.attach_button.setEnabled(not busy)
837
+ self.attachments_list.setEnabled(not busy)
838
+ self.send_button.setEnabled(not busy)
839
+ self._refresh_send_button_state()
840
+
841
+ def set_quick_commands(self, commands: list[dict]):
842
+ return
843
+
844
+ def _flush_streaming_message(self):
845
+ if self._streaming_message is None:
846
+ return
847
+ self._streaming_timer.stop()
848
+ self._messages.append(
849
+ ChatMessage(
850
+ self._streaming_message.role,
851
+ self._streaming_message.text,
852
+ attachments=self._streaming_message.attachments,
853
+ )
854
+ )
855
+ if self._streaming_row is not None:
856
+ self._streaming_row.set_text(self._streaming_message.text, streaming=False)
857
+ self._streaming_message = None
858
+ self._streaming_row = None
859
+ self._streaming_visible_text = ""
860
+ self._streaming_index = 0
861
+ self._update_window_mode()
862
+ self._refresh_send_button_state()
863
+
864
+ def _advance_streaming(self):
865
+ if self._streaming_message is None:
866
+ self._streaming_timer.stop()
867
+ return
868
+ text = self._streaming_message.text
869
+ if self._streaming_index >= len(text):
870
+ self._flush_streaming_message()
871
+ self._scroll_messages_to_bottom()
872
+ return
873
+
874
+ self._streaming_index = min(len(text), self._streaming_index + 1)
875
+ self._streaming_visible_text = text[: self._streaming_index]
876
+ if self._streaming_row is not None:
877
+ self._streaming_row.set_text(self._streaming_visible_text, streaming=True)
878
+ self._scroll_messages_to_bottom()
879
+
880
+ if self._streaming_visible_text:
881
+ last_char = self._streaming_visible_text[-1]
882
+ if last_char in ",。!?;:,.!?;:\n":
883
+ self._streaming_timer.start(96)
884
+ return
885
+ if last_char == " ":
886
+ self._streaming_timer.start(30)
887
+ return
888
+ self._streaming_timer.start(18)
889
+
890
+ def _add_message_row(self, message: ChatMessage, streaming: bool = False) -> ChatMessageRow:
891
+ row = ChatMessageRow(message, streaming=streaming)
892
+ self.messages_layout.addWidget(row)
893
+ self._update_window_mode()
894
+ self._scroll_messages_to_bottom()
895
+ return row
896
+
897
+ def _scroll_messages_to_bottom(self):
898
+ scrollbar = self.messages_scroll.verticalScrollBar()
899
+ scrollbar.setValue(scrollbar.maximum())
900
+
901
+ def _has_visible_messages(self) -> bool:
902
+ return (
903
+ bool(self._messages)
904
+ or self._streaming_message is not None
905
+ or bool(self._attachment_store.attachments())
906
+ )
907
+
908
+ def _update_window_mode(self):
909
+ expanded = self._has_visible_messages()
910
+ self.messages_scroll.setVisible(bool(self._messages) or self._streaming_message is not None)
911
+ self.attachments_list.setVisible(bool(self._attachment_store.attachments()))
912
+ target_size = self._expanded_size if expanded else self._compact_size
913
+ self.setMinimumSize(target_size)
914
+ if expanded:
915
+ self.resize(max(self.width(), target_size.width()), max(self.height(), target_size.height()))
916
+ else:
917
+ self.resize(target_size)
918
+
919
+ def _refresh_send_button_state(self):
920
+ has_text = bool(self.input.text().strip())
921
+ has_attachments = bool(self._attachment_store.attachments())
922
+ if self._busy or has_text or has_attachments:
923
+ bg = "rgba(10, 132, 255, 0.96)"
924
+ border = "rgba(104, 177, 255, 0.98)"
925
+ fg = "#ffffff"
926
+ else:
927
+ bg = "rgba(58, 58, 61, 0.98)"
928
+ border = "rgba(96, 96, 102, 0.88)"
929
+ fg = "#d8d8dc"
930
+
931
+ self.send_button.setText("…" if self._busy else "↑")
932
+ self.send_button.setStyleSheet(
933
+ "QPushButton {"
934
+ f"background: {bg};"
935
+ f"border: 1px solid {border};"
936
+ "border-radius: 20px;"
937
+ f"color: {fg};"
938
+ "font-size: 20px;"
939
+ "font-weight: 700;"
940
+ "padding: 0;"
941
+ "}"
942
+ "QPushButton:hover { background: rgba(82, 82, 88, 0.98); }"
943
+ "QPushButton:disabled { color: rgba(255, 255, 255, 0.88); }"
944
+ )
945
+
946
+ def _emit_submit(self):
947
+ text = self.input.text().strip()
948
+ attachments = self._attachment_store.attachments()
949
+ if not text and not attachments:
950
+ return
951
+ self.input.clear()
952
+ self._clear_pending_attachments()
953
+ self.submitted.emit(GuiChatSubmission(text=text, attachments=attachments))
954
+
955
+ def focus_input(self):
956
+ self.input.setFocus(Qt.FocusReason.ActiveWindowFocusReason)
957
+
958
+ def set_language(self, texts: dict[str, str]):
959
+ self._language_texts = dict(texts)
960
+ self.setWindowTitle(texts.get("app_title", "Vox Pet"))
961
+ self.input.setPlaceholderText(texts.get("chat_placeholder", "说点什么..."))
962
+ self.attach_button.setToolTip(texts.get("upload_image_button", "上传图片"))
963
+ self.send_button.setToolTip(texts.get("send_button", "发送"))
964
+ self.set_busy(self._busy)
965
+
966
+ def apply_skin(self, skin: str):
967
+ self.setStyleSheet(
968
+ "QMainWindow { background: transparent; }"
969
+ f"#chatPanel {{{termi_panel_stylesheet(radius=26)}}}"
970
+ "QListWidget {"
971
+ "background: rgba(22, 24, 28, 0.72);"
972
+ "border: 1px solid rgba(58, 62, 70, 0.82);"
973
+ "border-radius: 16px;"
974
+ "padding: 6px;"
975
+ "}"
976
+ "QFrame#attachmentChip {"
977
+ "background: rgba(35, 36, 40, 0.96);"
978
+ "border: 1px solid rgba(86, 90, 98, 0.82);"
979
+ "border-radius: 14px;"
980
+ "}"
981
+ "QLabel#attachmentNameLabel {"
982
+ "color: rgba(236, 236, 240, 0.94);"
983
+ "font-size: 11px;"
984
+ "font-weight: 600;"
985
+ "background: transparent;"
986
+ "border: none;"
987
+ "}"
988
+ "QPushButton#attachmentRemoveButton {"
989
+ "background: rgba(79, 33, 33, 0.92);"
990
+ "border: 1px solid rgba(153, 74, 74, 0.92);"
991
+ "border-radius: 9px;"
992
+ "color: #ffe4e4;"
993
+ "font-size: 11px;"
994
+ "font-weight: 700;"
995
+ "padding: 0;"
996
+ "}"
997
+ "QPushButton#attachmentRemoveButton:hover { background: rgba(110, 39, 39, 0.96); }"
998
+ "QLineEdit {"
999
+ "background: rgba(44, 42, 43, 0.96);"
1000
+ "border: 1px solid rgba(96, 96, 102, 0.86);"
1001
+ "border-radius: 20px;"
1002
+ "padding: 0 14px;"
1003
+ "color: #f1f1f1;"
1004
+ "font-size: 14px;"
1005
+ "font-weight: 600;"
1006
+ "min-height: 40px;"
1007
+ "}"
1008
+ "QLineEdit:disabled { color: rgba(180, 180, 184, 0.85); }"
1009
+ "QPushButton#attachButton {"
1010
+ "background: rgba(29, 32, 36, 0.96);"
1011
+ "border: 1px solid rgba(86, 90, 98, 0.88);"
1012
+ "border-radius: 20px;"
1013
+ "color: #f1f1f1;"
1014
+ "font-size: 20px;"
1015
+ "font-weight: 700;"
1016
+ "padding: 0;"
1017
+ "}"
1018
+ "QPushButton#attachButton:hover { background: rgba(74, 79, 87, 0.98); }"
1019
+ )
1020
+ self._refresh_send_button_state()
1021
+
1022
+ def add_attachment_files(self, paths: list[str]):
1023
+ for path in paths:
1024
+ try:
1025
+ added = self._attachment_store.add_files([path])
1026
+ except Exception as exc:
1027
+ self.attachment_error.emit(str(exc))
1028
+ continue
1029
+ for attachment in added:
1030
+ self.attachments_list.add_attachment(attachment)
1031
+ self._update_window_mode()
1032
+ self._refresh_send_button_state()
1033
+
1034
+ def dragEnterEvent(self, event):
1035
+ if AttachmentListWidget._extract_paths(event):
1036
+ event.acceptProposedAction()
1037
+ return
1038
+ super().dragEnterEvent(event)
1039
+
1040
+ def dragMoveEvent(self, event):
1041
+ if AttachmentListWidget._extract_paths(event):
1042
+ event.acceptProposedAction()
1043
+ return
1044
+ super().dragMoveEvent(event)
1045
+
1046
+ def dropEvent(self, event):
1047
+ paths = AttachmentListWidget._extract_paths(event)
1048
+ if paths:
1049
+ self.add_attachment_files(paths)
1050
+ event.acceptProposedAction()
1051
+ return
1052
+ super().dropEvent(event)
1053
+
1054
+ def _open_attachment_picker(self):
1055
+ selected, _filter = QFileDialog.getOpenFileNames(
1056
+ self,
1057
+ self._language_texts.get("upload_image_button", "上传图片"),
1058
+ "",
1059
+ "Images (*.png *.jpg *.jpeg *.webp)",
1060
+ )
1061
+ if selected:
1062
+ self.add_attachment_files(selected)
1063
+
1064
+ def _remove_pending_attachment(self, attachment_id: str):
1065
+ self._attachment_store.remove(attachment_id)
1066
+ self.attachments_list.remove_attachment(attachment_id)
1067
+ self._update_window_mode()
1068
+ self._refresh_send_button_state()
1069
+
1070
+ def _reorder_pending_attachments(self, ordered_ids: list[str]):
1071
+ self._attachment_store.reorder(ordered_ids)
1072
+ self._refresh_send_button_state()
1073
+
1074
+ def _clear_pending_attachments(self):
1075
+ self._attachment_store.clear()
1076
+ self.attachments_list.clear()
1077
+ self._update_window_mode()
1078
+ self._refresh_send_button_state()
1079
+
1080
+
1081
+ class GuiModelSettingsWindow(FramelessToolWindow):
1082
+ save_requested = Signal(object)
1083
+
1084
+ def __init__(self):
1085
+ super().__init__()
1086
+ self.setWindowTitle("Vox Pet 模型设置")
1087
+ self._selected_profile = "codex"
1088
+ self._source_mode = "global"
1089
+ self._source_buttons: dict[str, QPushButton] = {}
1090
+ self._test_worker: ModelConnectionTestWorker | None = None
1091
+ self.setMinimumSize(QSize(640, 520))
1092
+ self.resize(760, 560)
1093
+
1094
+ root = QWidget()
1095
+ self.setCentralWidget(root)
1096
+ outer = QVBoxLayout(root)
1097
+ outer.setContentsMargins(0, 0, 0, 0)
1098
+
1099
+ self.panel = QFrame()
1100
+ self.panel.setObjectName("settingsPanel")
1101
+ make_shadow(self.panel, blur=34, y=10, alpha=78)
1102
+ outer.addWidget(self.panel)
1103
+
1104
+ layout = QVBoxLayout(self.panel)
1105
+ layout.setContentsMargins(0, 0, 0, 0)
1106
+ layout.setSpacing(0)
1107
+
1108
+ header = QHBoxLayout()
1109
+ header.setContentsMargins(18, 18, 18, 16)
1110
+ header.setSpacing(12)
1111
+
1112
+ self.back_button = QPushButton("←")
1113
+ self.back_button.setObjectName("settingsBackButton")
1114
+ self.back_button.setFixedSize(40, 40)
1115
+ self.back_button.clicked.connect(self.hide)
1116
+ header.addWidget(self.back_button)
1117
+
1118
+ title_stack = QVBoxLayout()
1119
+ title_stack.setContentsMargins(0, 0, 0, 0)
1120
+ title_stack.setSpacing(3)
1121
+ self.title_label = QLabel("模型设置")
1122
+ title_font = QFont()
1123
+ title_font.setPointSize(18)
1124
+ title_font.setBold(True)
1125
+ self.title_label.setFont(title_font)
1126
+ title_stack.addWidget(self.title_label)
1127
+
1128
+ self.subtitle_label = QLabel("仅影响桌宠 GUI,会话和 CLI 全局模型保持隔离。")
1129
+ self.subtitle_label.setObjectName("settingsSecondary")
1130
+ title_stack.addWidget(self.subtitle_label)
1131
+ header.addLayout(title_stack, 1)
1132
+
1133
+ self.mode_badge = QLabel("跟随全局")
1134
+ self.mode_badge.setObjectName("settingsBadge")
1135
+ header.addWidget(self.mode_badge, 0, Qt.AlignmentFlag.AlignTop)
1136
+ layout.addLayout(header)
1137
+
1138
+ scroll = QScrollArea()
1139
+ scroll.setWidgetResizable(True)
1140
+ scroll.setFrameShape(QFrame.Shape.NoFrame)
1141
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
1142
+ scroll.setStyleSheet(
1143
+ "QScrollArea { background: transparent; border: none; }"
1144
+ "QScrollBar:vertical { background: transparent; width: 8px; margin: 2px 0 2px 0; }"
1145
+ "QScrollBar::handle:vertical {"
1146
+ "background: rgba(110, 110, 116, 0.42);"
1147
+ "border-radius: 4px;"
1148
+ "min-height: 24px;"
1149
+ "}"
1150
+ "QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }"
1151
+ "QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; }"
1152
+ )
1153
+ layout.addWidget(scroll, 1)
1154
+
1155
+ body_container = QWidget()
1156
+ scroll.setWidget(body_container)
1157
+ body = QVBoxLayout(body_container)
1158
+ body.setContentsMargins(18, 6, 18, 18)
1159
+ body.setSpacing(12)
1160
+
1161
+ self.mode_card = QFrame()
1162
+ self.mode_card.setObjectName("settingsModeCard")
1163
+ mode_layout = QVBoxLayout(self.mode_card)
1164
+ mode_layout.setContentsMargins(18, 18, 18, 18)
1165
+ mode_layout.setSpacing(10)
1166
+ mode_title = QLabel("模型来源")
1167
+ mode_title.setObjectName("settingsSection")
1168
+ mode_layout.addWidget(mode_title)
1169
+
1170
+ self.source_label = QLabel("")
1171
+ self.source_label.setObjectName("settingsSource")
1172
+ self.source_label.setWordWrap(True)
1173
+ mode_layout.addWidget(self.source_label)
1174
+
1175
+ self.global_label = QLabel("")
1176
+ self.global_label.setWordWrap(True)
1177
+ self.global_label.setObjectName("settingsSecondary")
1178
+ mode_layout.addWidget(self.global_label)
1179
+
1180
+ self.warning_label = QLabel("")
1181
+ self.warning_label.setWordWrap(True)
1182
+ self.warning_label.hide()
1183
+ self.warning_label.setObjectName("settingsWarning")
1184
+ mode_layout.addWidget(self.warning_label)
1185
+
1186
+ self.source_mode_widget = QWidget()
1187
+ self.source_mode_grid = QGridLayout(self.source_mode_widget)
1188
+ self.source_mode_grid.setContentsMargins(0, 0, 0, 0)
1189
+ self.source_mode_grid.setHorizontalSpacing(10)
1190
+ self.source_mode_grid.setVerticalSpacing(8)
1191
+ self.global_source_button = self._make_segment_button("跟随全局", "global", self._select_source_mode)
1192
+ self.independent_source_button = self._make_segment_button("独立配置", "independent", self._select_source_mode)
1193
+ self._source_buttons["global"] = self.global_source_button
1194
+ self._source_buttons["independent"] = self.independent_source_button
1195
+ mode_layout.addWidget(self.source_mode_widget)
1196
+ body.addWidget(self.mode_card)
1197
+
1198
+ self.online_api_card = QFrame()
1199
+ self.online_api_card.setObjectName("settingsApiCard")
1200
+ api_card_layout = QVBoxLayout(self.online_api_card)
1201
+ api_card_layout.setContentsMargins(18, 18, 18, 18)
1202
+ api_card_layout.setSpacing(14)
1203
+ api_title = QLabel("API 模型")
1204
+ api_title.setObjectName("settingsSection")
1205
+ api_card_layout.addWidget(api_title)
1206
+
1207
+ api_intro = QLabel("适合任何 OpenAI 兼容网关。填写 Base URL、API Key;如果网关要求显式 model,也可以直接填写。")
1208
+ api_intro.setObjectName("settingsSecondary")
1209
+ api_intro.setWordWrap(True)
1210
+ api_card_layout.addWidget(api_intro)
1211
+
1212
+ self.profile_hint_label = QLabel("")
1213
+ self.profile_hint_label.setWordWrap(True)
1214
+ self.profile_hint_label.setObjectName("settingsSecondary")
1215
+ api_card_layout.addWidget(self.profile_hint_label)
1216
+
1217
+ self.form_widget = QWidget()
1218
+ self.form_layout = QGridLayout(self.form_widget)
1219
+ self.form_layout.setContentsMargins(0, 0, 0, 0)
1220
+ self.form_layout.setHorizontalSpacing(12)
1221
+ self.form_layout.setVerticalSpacing(10)
1222
+
1223
+ self.base_url_label = QLabel("Base URL")
1224
+ self.base_url_label.setObjectName("settingsFieldLabel")
1225
+ self.base_url_input = QLineEdit()
1226
+ self.base_url_input.setPlaceholderText("填写根地址、/v1 或完整 chat/completions 地址")
1227
+ self.base_url_input.setClearButtonEnabled(True)
1228
+
1229
+ self.api_key_label = QLabel("API Key")
1230
+ self.api_key_label.setObjectName("settingsFieldLabel")
1231
+ self.api_key_input = QLineEdit()
1232
+ self.api_key_input.setEchoMode(QLineEdit.EchoMode.Password)
1233
+ self.api_key_input.setPlaceholderText("填写对应平台的 API Key")
1234
+ self.api_key_input.setClearButtonEnabled(True)
1235
+
1236
+ self.model_label = QLabel("Model")
1237
+ self.model_label.setObjectName("settingsFieldLabel")
1238
+ self.model_input = QLineEdit()
1239
+ self.model_input.setPlaceholderText("可选,留空则默认使用 gpt-5-codex")
1240
+ self.model_input.setClearButtonEnabled(True)
1241
+ api_card_layout.addWidget(self.form_widget)
1242
+
1243
+ self.api_hint_label = QLabel("根地址或 /v1 会自动补全为 /chat/completions;请求会带上 model 字段。")
1244
+ self.api_hint_label.setWordWrap(True)
1245
+ self.api_hint_label.setObjectName("settingsInlineHint")
1246
+ api_card_layout.addWidget(self.api_hint_label)
1247
+
1248
+ self.test_button = QPushButton("测试连接")
1249
+ self.test_button.setObjectName("secondaryAction")
1250
+ self.test_button.clicked.connect(self._start_connection_test)
1251
+ api_card_layout.addWidget(self.test_button, 0, Qt.AlignmentFlag.AlignLeft)
1252
+ body.addWidget(self.online_api_card)
1253
+
1254
+ self.json_card = QFrame()
1255
+ self.json_card.setObjectName("settingsJsonCard")
1256
+ json_card_layout = QVBoxLayout(self.json_card)
1257
+ json_card_layout.setContentsMargins(18, 18, 18, 18)
1258
+ json_card_layout.setSpacing(12)
1259
+
1260
+ self.json_title_label = QLabel("配置 JSON")
1261
+ self.json_title_label.setObjectName("settingsSection")
1262
+ json_card_layout.addWidget(self.json_title_label)
1263
+
1264
+ self.json_intro_label = QLabel("需要自定义 model 或一次性粘贴配置时,再使用这里。")
1265
+ self.json_intro_label.setObjectName("settingsSecondary")
1266
+ self.json_intro_label.setWordWrap(True)
1267
+ json_card_layout.addWidget(self.json_intro_label)
1268
+
1269
+ self.json_actions_widget = QWidget()
1270
+ self.json_actions_grid = QGridLayout(self.json_actions_widget)
1271
+ self.json_actions_grid.setContentsMargins(0, 0, 0, 0)
1272
+ self.json_actions_grid.setHorizontalSpacing(10)
1273
+ self.json_actions_grid.setVerticalSpacing(8)
1274
+
1275
+ self.json_sync_button = QPushButton("同步表单")
1276
+ self.json_sync_button.setObjectName("secondaryAction")
1277
+ self.json_sync_button.clicked.connect(self._sync_json_editor)
1278
+
1279
+ self.json_apply_button = QPushButton("应用 JSON")
1280
+ self.json_apply_button.setObjectName("secondaryAction")
1281
+ self.json_apply_button.clicked.connect(self._apply_json_from_editor)
1282
+ json_card_layout.addWidget(self.json_actions_widget)
1283
+
1284
+ self.json_editor = QPlainTextEdit()
1285
+ self.json_editor.setObjectName("settingsJsonEditor")
1286
+ self.json_editor.setMinimumHeight(190)
1287
+ self.json_editor.setPlaceholderText(
1288
+ '{\n "baseUrl": "https://...",\n "apiKey": "sk-...",\n "model": "gpt-5-codex"\n}'
1289
+ )
1290
+ json_card_layout.addWidget(self.json_editor)
1291
+
1292
+ self.json_hint_label = QLabel(
1293
+ "支持 baseUrl/base_url、apiKey/api_key、model、enabled;旧的 provider/profile/type 写法也兼容。"
1294
+ )
1295
+ self.json_hint_label.setObjectName("settingsSecondary")
1296
+ self.json_hint_label.setWordWrap(True)
1297
+ json_card_layout.addWidget(self.json_hint_label)
1298
+ body.addWidget(self.json_card)
1299
+
1300
+ hint = QLabel("保存后只影响桌宠 GUI,不会修改 CLI 的 ~/.vox-code/config.json。")
1301
+ hint.setWordWrap(True)
1302
+ hint.setObjectName("settingsSecondary")
1303
+ body.addWidget(hint)
1304
+
1305
+ self.status_label = QLabel("")
1306
+ self.status_label.setWordWrap(True)
1307
+ self.status_label.hide()
1308
+ body.addWidget(self.status_label)
1309
+
1310
+ self.footer_actions_widget = QWidget()
1311
+ self.footer_actions_grid = QGridLayout(self.footer_actions_widget)
1312
+ self.footer_actions_grid.setContentsMargins(0, 0, 0, 0)
1313
+ self.footer_actions_grid.setHorizontalSpacing(10)
1314
+ self.footer_actions_grid.setVerticalSpacing(8)
1315
+
1316
+ self.save_button = QPushButton("保存并应用")
1317
+ self.save_button.setObjectName("primaryAction")
1318
+ self.save_button.clicked.connect(self._emit_save_request)
1319
+
1320
+ self.close_button = QPushButton("关闭")
1321
+ self.close_button.setObjectName("secondaryAction")
1322
+ self.close_button.clicked.connect(self.hide)
1323
+ body.addWidget(self.footer_actions_widget)
1324
+
1325
+ self.setStyleSheet(
1326
+ "QMainWindow { background: transparent; }"
1327
+ f"#settingsPanel {{{termi_panel_stylesheet(radius=26)}}}"
1328
+ "#settingsBackButton {"
1329
+ "background: rgba(29, 32, 36, 0.96);"
1330
+ "border: 1px solid rgba(86, 90, 98, 0.88);"
1331
+ "border-radius: 20px;"
1332
+ "color: #f1f1f1;"
1333
+ "font-size: 18px;"
1334
+ "font-weight: 700;"
1335
+ "}"
1336
+ "#settingsBackButton:hover { background: rgba(74, 79, 87, 0.98); }"
1337
+ "#settingsBadge {"
1338
+ "padding: 6px 12px;"
1339
+ "border-radius: 14px;"
1340
+ "background: rgba(29, 32, 36, 0.96);"
1341
+ "border: 1px solid rgba(86, 90, 98, 0.88);"
1342
+ "color: rgba(214, 214, 214, 0.92);"
1343
+ "font-size: 12px;"
1344
+ "font-weight: 700;"
1345
+ "}"
1346
+ "QLabel { color: #f1f1f1; font-size: 14px; }"
1347
+ "#settingsSource { color: #f1f1f1; font-size: 14px; font-weight: 700; }"
1348
+ "#settingsSecondary { color: rgba(164, 164, 164, 0.95); font-size: 12px; }"
1349
+ "#settingsSection { color: #f1f1f1; font-size: 15px; font-weight: 700; }"
1350
+ "#settingsFieldLabel { color: rgba(234, 234, 238, 0.96); font-size: 13px; font-weight: 700; }"
1351
+ "#settingsModeCard, #settingsApiCard, #settingsJsonCard {"
1352
+ "background: rgba(22, 24, 28, 0.96);"
1353
+ "border: 1px solid rgba(58, 62, 70, 0.92);"
1354
+ "border-radius: 16px;"
1355
+ "}"
1356
+ "#settingsWarning {"
1357
+ "padding: 10px 12px;"
1358
+ "border-radius: 12px;"
1359
+ "background: rgba(79, 33, 33, 0.42);"
1360
+ "border: 1px solid rgba(153, 74, 74, 0.46);"
1361
+ "color: #ffe4e4;"
1362
+ "font-size: 12px;"
1363
+ "font-weight: 600;"
1364
+ "}"
1365
+ "#settingsInlineHint {"
1366
+ "padding: 12px 14px;"
1367
+ "border-radius: 14px;"
1368
+ "background: rgba(35, 36, 40, 0.96);"
1369
+ "border: 1px solid rgba(96, 96, 102, 0.72);"
1370
+ "color: rgba(214, 214, 220, 0.92);"
1371
+ "font-size: 12px;"
1372
+ "font-weight: 600;"
1373
+ "}"
1374
+ "QLineEdit {"
1375
+ "background: rgba(44, 42, 43, 0.96);"
1376
+ "border: 1px solid rgba(96, 96, 102, 0.86);"
1377
+ "border-radius: 20px;"
1378
+ "padding: 0 14px;"
1379
+ "color: #f1f1f1;"
1380
+ "font-size: 14px;"
1381
+ "font-weight: 600;"
1382
+ "min-height: 42px;"
1383
+ "}"
1384
+ "QLineEdit:disabled { color: rgba(150, 150, 150, 0.82); }"
1385
+ "QPlainTextEdit#settingsJsonEditor {"
1386
+ "background: rgba(44, 42, 43, 0.96);"
1387
+ "border: 1px solid rgba(96, 96, 102, 0.86);"
1388
+ "border-radius: 16px;"
1389
+ "padding: 10px 12px;"
1390
+ "color: #f1f1f1;"
1391
+ "font-size: 13px;"
1392
+ "font-family: Menlo, Monaco, monospace;"
1393
+ "}"
1394
+ "QPushButton#primaryAction {"
1395
+ "background: rgba(10, 132, 255, 0.96);"
1396
+ "border: 1px solid rgba(104, 177, 255, 0.98);"
1397
+ "border-radius: 18px;"
1398
+ "padding: 10px 18px;"
1399
+ "color: #ffffff;"
1400
+ "font-size: 13px;"
1401
+ "font-weight: 700;"
1402
+ "min-height: 42px;"
1403
+ "}"
1404
+ "QPushButton#primaryAction:hover { background: rgba(43, 146, 255, 1); }"
1405
+ "QPushButton#secondaryAction {"
1406
+ "background: rgba(29, 32, 36, 0.96);"
1407
+ "border: 1px solid rgba(86, 90, 98, 0.88);"
1408
+ "border-radius: 18px;"
1409
+ "padding: 10px 16px;"
1410
+ "color: #f1f1f1;"
1411
+ "font-size: 13px;"
1412
+ "font-weight: 700;"
1413
+ "min-height: 42px;"
1414
+ "}"
1415
+ "QPushButton#secondaryAction:hover { background: rgba(74, 79, 87, 0.98); }"
1416
+ "QPushButton:disabled { color: rgba(170, 170, 170, 0.82); }"
1417
+ )
1418
+ self._select_source_mode("global")
1419
+ self._relayout_responsive_sections()
1420
+ self.base_url_input.textChanged.connect(lambda _text: self._refresh_json_preview_if_clean())
1421
+ self.api_key_input.textChanged.connect(lambda _text: self._refresh_json_preview_if_clean())
1422
+ self.model_input.textChanged.connect(lambda _text: self._refresh_json_preview_if_clean())
1423
+ self.model_input.textChanged.connect(lambda _text: self._refresh_profile_hint())
1424
+
1425
+ def load_state(
1426
+ self,
1427
+ config: GuiModelConfig,
1428
+ source_text: str,
1429
+ global_text: str,
1430
+ warning_text: str = "",
1431
+ ):
1432
+ profile_meta = gui_model_profile_meta(config.provider)
1433
+ self.update_runtime_labels(source_text, global_text, warning_text)
1434
+ self._select_source_mode("independent" if config.enabled else "global")
1435
+ self._selected_profile = profile_meta["id"]
1436
+ self.base_url_input.setText(config.base_url)
1437
+ self.api_key_input.setText(config.api_key)
1438
+ self.model_input.setText(config.model.strip())
1439
+ self.clear_status()
1440
+ self._refresh_profile_hint()
1441
+ self._sync_json_editor()
1442
+
1443
+ def update_runtime_labels(self, source_text: str, global_text: str, warning_text: str = ""):
1444
+ self.source_label.setText(f"当前来源: {source_text}")
1445
+ self.global_label.setText(f"全局模型: {global_text}")
1446
+ if warning_text:
1447
+ self.warning_label.setText(warning_text)
1448
+ self.warning_label.show()
1449
+ else:
1450
+ self.warning_label.hide()
1451
+
1452
+ def selected_config(self) -> GuiModelConfig:
1453
+ return GuiModelConfig(
1454
+ enabled=self._source_mode == "independent",
1455
+ provider=self._selected_profile,
1456
+ model=self.model_input.text().strip(),
1457
+ base_url=normalize_gui_model_base_url(self.base_url_input.text().strip()),
1458
+ api_key=self.api_key_input.text().strip(),
1459
+ )
1460
+
1461
+ def set_status(self, message: str, error: bool = False):
1462
+ self.status_label.setText(message)
1463
+ self.status_label.setStyleSheet(
1464
+ "padding: 10px 12px;"
1465
+ "border-radius: 12px;"
1466
+ f"background: {'rgba(79, 33, 33, 0.42)' if error else 'rgba(33, 63, 49, 0.42)'};"
1467
+ f"border: 1px solid {'rgba(153, 74, 74, 0.46)' if error else 'rgba(84, 153, 121, 0.46)'};"
1468
+ f"color: {'#ffe4e4' if error else '#dff7e7'};"
1469
+ "font-size: 12px;"
1470
+ "font-weight: 600;"
1471
+ )
1472
+ self.status_label.show()
1473
+
1474
+ def clear_status(self):
1475
+ self.status_label.clear()
1476
+ self.status_label.hide()
1477
+
1478
+ def _emit_save_request(self):
1479
+ self.save_requested.emit(self.selected_config())
1480
+
1481
+ def _update_form_enabled_state(self, enabled: bool):
1482
+ for widget in (self.base_url_input, self.api_key_input, self.model_input):
1483
+ widget.setEnabled(enabled)
1484
+ self.test_button.setEnabled(enabled and self._test_worker is None)
1485
+ self.json_sync_button.setEnabled(enabled)
1486
+ self.json_apply_button.setEnabled(enabled)
1487
+ self.json_editor.setEnabled(enabled)
1488
+ self.online_api_card.setEnabled(enabled)
1489
+ self.json_card.setEnabled(enabled)
1490
+
1491
+ def _select_profile(self, profile_id: str, preserve_override: bool = False):
1492
+ meta = gui_model_profile_meta(profile_id)
1493
+ self._selected_profile = meta["id"]
1494
+ self._refresh_profile_hint()
1495
+
1496
+ def _refresh_profile_hint(self):
1497
+ meta = gui_model_profile_meta(self._selected_profile)
1498
+ model = self.model_input.text().strip()
1499
+ if model:
1500
+ self.profile_hint_label.setText(
1501
+ f"当前网关类型: {meta['label']}。将使用你填写的 model: `{model}`。"
1502
+ " 图片上传仅对支持视觉的 OpenAI 兼容模型生效。"
1503
+ )
1504
+ return
1505
+ self.profile_hint_label.setText(
1506
+ f"当前网关类型: {meta['label']}。留空时会默认使用 `{default_model_for(meta['id'])}`。"
1507
+ " 图片上传仅对支持视觉的 OpenAI 兼容模型生效。"
1508
+ )
1509
+
1510
+ def _select_source_mode(self, mode: str):
1511
+ self._source_mode = mode if mode in {"global", "independent"} else "global"
1512
+ for current_mode, button in self._source_buttons.items():
1513
+ button.setStyleSheet(self._segment_button_stylesheet(current_mode == self._source_mode))
1514
+ self._update_form_enabled_state(self._source_mode == "independent")
1515
+ self.mode_badge.setText("GUI 独立" if self._source_mode == "independent" else "跟随全局")
1516
+
1517
+ def _start_connection_test(self):
1518
+ config = self.selected_config()
1519
+ if not config.enabled:
1520
+ self.set_status("当前是跟随全局模式,无需测试独立配置。", error=True)
1521
+ return
1522
+ self.clear_status()
1523
+ self.test_button.setText("测试中...")
1524
+ self.test_button.setEnabled(False)
1525
+ self._test_worker = ModelConnectionTestWorker(config)
1526
+ self._test_worker.completed.connect(self._handle_test_success)
1527
+ self._test_worker.failed.connect(self._handle_test_failure)
1528
+ self._test_worker.finished.connect(self._cleanup_test_worker)
1529
+ self._test_worker.start()
1530
+
1531
+ def _handle_test_success(self, message: str):
1532
+ self.set_status(message)
1533
+
1534
+ def _handle_test_failure(self, message: str):
1535
+ self.set_status(f"连接失败: {message}", error=True)
1536
+
1537
+ def _cleanup_test_worker(self):
1538
+ self._test_worker = None
1539
+ self.test_button.setText("测试连接")
1540
+ self.test_button.setEnabled(self._source_mode == "independent")
1541
+
1542
+ def _sync_json_editor(self):
1543
+ config = self.selected_config()
1544
+ payload = {
1545
+ "baseUrl": config.base_url,
1546
+ "apiKey": config.api_key,
1547
+ "model": config.model,
1548
+ "enabled": config.enabled,
1549
+ }
1550
+ self.json_editor.setPlainText(json.dumps(payload, ensure_ascii=False, indent=2))
1551
+
1552
+ def _apply_json_from_editor(self):
1553
+ try:
1554
+ loaded = load_gui_model_config_from_json(self.json_editor.toPlainText(), self.selected_config())
1555
+ except Exception as exc:
1556
+ self.set_status(f"JSON 配置无效: {exc}", error=True)
1557
+ return
1558
+ self._select_source_mode("independent" if loaded.enabled else "global")
1559
+ self._select_profile(loaded.provider, preserve_override=True)
1560
+ self.base_url_input.setText(loaded.base_url)
1561
+ self.api_key_input.setText(loaded.api_key)
1562
+ self.model_input.setText(loaded.model)
1563
+ self._refresh_profile_hint()
1564
+ self.set_status("JSON 配置已载入,确认后点击保存并应用。")
1565
+
1566
+ def _refresh_json_preview_if_clean(self):
1567
+ if not self.json_editor.toPlainText().strip():
1568
+ self._sync_json_editor()
1569
+
1570
+ def _relayout_responsive_sections(self):
1571
+ compact = self.width() < 720
1572
+ self._rebuild_segment_grid(
1573
+ self.source_mode_grid,
1574
+ [self.global_source_button, self.independent_source_button],
1575
+ compact,
1576
+ )
1577
+ self._rebuild_form_layout(compact)
1578
+ self._rebuild_button_grid(
1579
+ self.json_actions_grid,
1580
+ [self.json_sync_button, self.json_apply_button],
1581
+ compact,
1582
+ )
1583
+ self._rebuild_button_grid(
1584
+ self.footer_actions_grid,
1585
+ [self.save_button, self.close_button],
1586
+ compact,
1587
+ align_end=not compact,
1588
+ )
1589
+
1590
+ def _rebuild_segment_grid(self, layout: QGridLayout, buttons: list[QPushButton], compact: bool):
1591
+ self._clear_grid_layout(layout)
1592
+ if compact:
1593
+ for row, button in enumerate(buttons):
1594
+ layout.addWidget(button, row, 0)
1595
+ else:
1596
+ for col, button in enumerate(buttons):
1597
+ layout.addWidget(button, 0, col)
1598
+ layout.setColumnStretch(len(buttons), 1)
1599
+
1600
+ def _rebuild_form_layout(self, compact: bool):
1601
+ self._clear_grid_layout(self.form_layout)
1602
+ if compact:
1603
+ self.form_layout.addWidget(self.base_url_label, 0, 0)
1604
+ self.form_layout.addWidget(self.base_url_input, 1, 0)
1605
+ self.form_layout.addWidget(self.api_key_label, 2, 0)
1606
+ self.form_layout.addWidget(self.api_key_input, 3, 0)
1607
+ self.form_layout.addWidget(self.model_label, 4, 0)
1608
+ self.form_layout.addWidget(self.model_input, 5, 0)
1609
+ return
1610
+ self.form_layout.setColumnMinimumWidth(0, 88)
1611
+ self.form_layout.setColumnStretch(1, 1)
1612
+ self.form_layout.addWidget(self.base_url_label, 0, 0)
1613
+ self.form_layout.addWidget(self.base_url_input, 0, 1)
1614
+ self.form_layout.addWidget(self.api_key_label, 1, 0)
1615
+ self.form_layout.addWidget(self.api_key_input, 1, 1)
1616
+ self.form_layout.addWidget(self.model_label, 2, 0)
1617
+ self.form_layout.addWidget(self.model_input, 2, 1)
1618
+
1619
+ def _rebuild_button_grid(
1620
+ self,
1621
+ layout: QGridLayout,
1622
+ buttons: list[QPushButton],
1623
+ compact: bool,
1624
+ align_end: bool = False,
1625
+ ):
1626
+ self._clear_grid_layout(layout)
1627
+ if compact:
1628
+ for row, button in enumerate(buttons):
1629
+ layout.addWidget(button, row, 0)
1630
+ layout.setColumnStretch(0, 1)
1631
+ return
1632
+ start_col = 1 if align_end else 0
1633
+ if align_end:
1634
+ layout.setColumnStretch(0, 1)
1635
+ for index, button in enumerate(buttons):
1636
+ layout.addWidget(button, 0, start_col + index)
1637
+
1638
+ def _clear_grid_layout(self, layout: QGridLayout):
1639
+ while layout.count():
1640
+ item = layout.takeAt(0)
1641
+ widget = item.widget()
1642
+ if widget is not None:
1643
+ widget.setParent(layout.parentWidget())
1644
+
1645
+ def _make_segment_button(self, text: str, value: str, handler):
1646
+ button = QPushButton(text)
1647
+ button.setCursor(Qt.CursorShape.PointingHandCursor)
1648
+ button.setProperty("segmentValue", value)
1649
+ button.setMinimumHeight(42)
1650
+ button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
1651
+ button.clicked.connect(lambda _checked=False, current=value: handler(current))
1652
+ button.setStyleSheet(self._segment_button_stylesheet(False))
1653
+ return button
1654
+
1655
+ def _segment_button_stylesheet(self, active: bool) -> str:
1656
+ bg = "rgba(10, 132, 255, 0.96)" if active else "rgba(29, 32, 36, 0.96)"
1657
+ border = "rgba(104, 177, 255, 0.98)" if active else "rgba(86, 90, 98, 0.88)"
1658
+ fg = "#ffffff" if active else "rgba(236, 236, 236, 0.95)"
1659
+ hover_bg = "rgba(43, 146, 255, 1)" if active else "rgba(74, 79, 87, 0.98)"
1660
+ return (
1661
+ "QPushButton {"
1662
+ f"background: {bg};"
1663
+ f"border: 1px solid {border};"
1664
+ "border-radius: 18px;"
1665
+ "padding: 9px 16px;"
1666
+ f"color: {fg};"
1667
+ "font-size: 13px;"
1668
+ "font-weight: 700;"
1669
+ "}"
1670
+ f"QPushButton:hover {{ background: {hover_bg}; }}"
1671
+ "QPushButton:disabled { color: rgba(170, 170, 170, 0.8); }"
1672
+ )
1673
+
1674
+ def resizeEvent(self, event):
1675
+ self._relayout_responsive_sections()
1676
+ super().resizeEvent(event)
1677
+
1678
+
1679
+ # ---------------------------------------------------------------------------
1680
+ # Pet Manager — 可视化宠物管理窗口
1681
+ # ---------------------------------------------------------------------------
1682
+
1683
+ class PetManagerWindow(FramelessToolWindow):
1684
+ """宠物管理界面 — 预览、浏览、切换、导入宠物。
1685
+
1686
+ 类似 TermiPet 的宠物选择面板:
1687
+ - 左侧大预览区
1688
+ - 右侧宠物信息
1689
+ - 下方卡片网格
1690
+ - 底部导入按钮
1691
+ """
1692
+
1693
+ pet_selected = Signal(str) # pet_id
1694
+ import_requested = Signal()
1695
+ delete_requested = Signal(str) # pet_id
1696
+
1697
+ def __init__(self):
1698
+ super().__init__()
1699
+ self.setWindowTitle("Pet Manager")
1700
+ self.setFixedSize(480, 520)
1701
+ self._pets: list[PetPackage] = []
1702
+ self._current_pet_id = ""
1703
+ self._skin = "glass"
1704
+
1705
+ root = QWidget(self)
1706
+ root.setObjectName("petManagerRoot")
1707
+ root.setStyleSheet(termi_panel_stylesheet(20))
1708
+ self.setCentralWidget(root)
1709
+
1710
+ layout = QVBoxLayout(root)
1711
+ layout.setContentsMargins(0, 0, 0, 0)
1712
+ layout.setSpacing(0)
1713
+
1714
+ # -- Title bar --
1715
+ title_bar = QWidget()
1716
+ title_bar.setFixedHeight(48)
1717
+ title_layout = QHBoxLayout(title_bar)
1718
+ title_layout.setContentsMargins(20, 0, 12, 0)
1719
+ title_label = QLabel("Pet Manager")
1720
+ title_label.setStyleSheet("color: #f0f0f0; font-size: 16px; font-weight: 700;")
1721
+ title_layout.addWidget(title_label)
1722
+ title_layout.addStretch()
1723
+ close_btn = QPushButton("×")
1724
+ close_btn.setFixedSize(28, 28)
1725
+ close_btn.setStyleSheet(
1726
+ "QPushButton { background: rgba(200,80,70,0.7); border: none; "
1727
+ "border-radius: 14px; color: #fff; font-size: 16px; font-weight: 700; }"
1728
+ "QPushButton:hover { background: rgba(200,80,70,1); }"
1729
+ )
1730
+ close_btn.clicked.connect(self.hide)
1731
+ title_layout.addWidget(close_btn)
1732
+
1733
+ layout.addWidget(title_bar)
1734
+
1735
+ # -- Preview area --
1736
+ preview_section = QWidget()
1737
+ preview_section.setFixedHeight(160)
1738
+ preview_layout = QHBoxLayout(preview_section)
1739
+ preview_layout.setContentsMargins(24, 12, 24, 12)
1740
+
1741
+ self._preview = _PetManagerPreview(120, 120)
1742
+ self._preview.setFixedSize(120, 120)
1743
+ make_shadow(self._preview, blur=20, y=4, alpha=40)
1744
+ preview_layout.addWidget(self._preview, 0, Qt.AlignmentFlag.AlignCenter)
1745
+
1746
+ info_layout = QVBoxLayout()
1747
+ info_layout.setSpacing(6)
1748
+ self._name_label = QLabel()
1749
+ self._name_label.setStyleSheet("color: #f0f0f0; font-size: 15px; font-weight: 700;")
1750
+ self._desc_label = QLabel()
1751
+ self._desc_label.setStyleSheet("color: rgba(200,200,200,0.85); font-size: 12px;")
1752
+ self._desc_label.setWordWrap(True)
1753
+ self._kind_label = QLabel()
1754
+ self._kind_label.setStyleSheet("color: rgba(180,180,180,0.7); font-size: 11px;")
1755
+ info_layout.addWidget(self._name_label)
1756
+ info_layout.addWidget(self._desc_label)
1757
+ info_layout.addWidget(self._kind_label)
1758
+ info_layout.addStretch()
1759
+ preview_layout.addLayout(info_layout)
1760
+
1761
+ layout.addWidget(preview_section)
1762
+
1763
+ # -- Separator --
1764
+ sep = QFrame()
1765
+ sep.setFixedHeight(1)
1766
+ sep.setStyleSheet("background: rgba(86,90,98,0.5);")
1767
+ layout.addWidget(sep)
1768
+
1769
+ # -- Pet grid --
1770
+ grid_label = QLabel("All Pets")
1771
+ grid_label.setStyleSheet("color: #ccc; font-size: 13px; font-weight: 600; padding: 8px 24px 0;")
1772
+ layout.addWidget(grid_label)
1773
+
1774
+ scroll = QScrollArea()
1775
+ scroll.setWidgetResizable(True)
1776
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
1777
+ scroll.setStyleSheet(
1778
+ "QScrollArea { border: none; background: transparent; }"
1779
+ "QScrollBar:vertical { width: 4px; }"
1780
+ "QScrollBar::handle:vertical { background: rgba(120,120,120,0.4); border-radius: 2px; }"
1781
+ )
1782
+ scroll_content = QWidget()
1783
+ scroll_content.setStyleSheet("background: transparent;")
1784
+ self._grid_layout = QGridLayout(scroll_content)
1785
+ self._grid_layout.setContentsMargins(20, 8, 20, 8)
1786
+ self._grid_layout.setSpacing(8)
1787
+ scroll.setWidget(scroll_content)
1788
+ layout.addWidget(scroll, 1)
1789
+
1790
+ # -- Bottom bar --
1791
+ bottom = QWidget()
1792
+ bottom.setFixedHeight(56)
1793
+ bottom_layout = QHBoxLayout(bottom)
1794
+ bottom_layout.setContentsMargins(20, 8, 20, 8)
1795
+ import_btn = QPushButton(" Import Pet ")
1796
+ import_btn.setStyleSheet(
1797
+ "QPushButton { background: rgba(10,132,255,0.9); border: none; "
1798
+ "border-radius: 16px; padding: 8px 18px; color: #fff; font-size: 13px; font-weight: 600; }"
1799
+ "QPushButton:hover { background: rgba(10,132,255,1); }"
1800
+ )
1801
+ import_btn.clicked.connect(self.import_requested.emit)
1802
+ bottom_layout.addWidget(import_btn)
1803
+ bottom_layout.addStretch()
1804
+ layout.addWidget(bottom)
1805
+
1806
+ self._card_widgets: list[PetCardWidget] = []
1807
+
1808
+ def load_pets(self, pets: list[PetPackage], current_pet_id: str, skin: str):
1809
+ """刷新宠物列表和预览。"""
1810
+ self._pets = list(pets)
1811
+ self._current_pet_id = current_pet_id
1812
+ self._skin = skin
1813
+ self._rebuild_grid()
1814
+ self._update_preview()
1815
+
1816
+ def _rebuild_grid(self):
1817
+ # Clear old cards
1818
+ for card in self._card_widgets:
1819
+ self._grid_layout.removeWidget(card)
1820
+ card.deleteLater()
1821
+ self._card_widgets.clear()
1822
+
1823
+ cols = 4
1824
+ for idx, package in enumerate(self._pets):
1825
+ show_delete = package.kind == "sprite"
1826
+ card = PetCardWidget(
1827
+ package, skin=self._skin,
1828
+ selected=(package.id == self._current_pet_id),
1829
+ show_delete=show_delete,
1830
+ )
1831
+ card.clicked.connect(lambda _p=package.id: self._on_card_clicked(_p))
1832
+ if show_delete:
1833
+ card.delete_requested.connect(lambda _p=package.id: self.delete_requested.emit(_p))
1834
+ self._grid_layout.addWidget(card, idx // cols, idx % cols)
1835
+ self._card_widgets.append(card)
1836
+
1837
+ def _on_card_clicked(self, pet_id: str):
1838
+ if pet_id != self._current_pet_id:
1839
+ self.pet_selected.emit(pet_id)
1840
+
1841
+ def _update_preview(self):
1842
+ package = next((p for p in self._pets if p.id == self._current_pet_id), None)
1843
+ if package is None and self._pets:
1844
+ package = self._pets[0]
1845
+ if package is not None:
1846
+ self._preview.set_package(package)
1847
+ self._preview.set_skin(self._skin)
1848
+ self._name_label.setText(package.display_name)
1849
+ self._desc_label.setText(package.description)
1850
+ kind_text = "Drawn" if package.kind == "drawn" else "Sprite"
1851
+ self._kind_label.setText(f"Type: {kind_text}")
1852
+ # Update card selection states
1853
+ for card in self._card_widgets:
1854
+ card.set_selected(card.package_id == self._current_pet_id)
1855
+
1856
+
1857
+ class _PetManagerPreview(QWidget):
1858
+ """宠物管理器专用预览 — 居中绘制宠物。"""
1859
+
1860
+ def __init__(self, width: int, height: int):
1861
+ super().__init__()
1862
+ self.setFixedSize(width, height)
1863
+ self._package: PetPackage | None = None
1864
+ self._skin = "glass"
1865
+
1866
+ def set_package(self, package: PetPackage):
1867
+ self._package = package
1868
+ self.update()
1869
+
1870
+ def set_skin(self, skin: str):
1871
+ self._skin = skin
1872
+ self.update()
1873
+
1874
+ def paintEvent(self, _event):
1875
+ if self._package is None:
1876
+ return
1877
+ from PySide6.QtGui import QPainter
1878
+ from .widgets import draw_pet_body, SKIN_PALETTES
1879
+
1880
+ painter = QPainter(self)
1881
+ painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source)
1882
+ painter.fillRect(self.rect(), Qt.GlobalColor.transparent)
1883
+ painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceOver)
1884
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
1885
+ palette = SKIN_PALETTES[self._skin]
1886
+ painter.save()
1887
+ scale_x = self.width() / 252.0
1888
+ scale_y = self.height() / 220.0
1889
+ scale = min(scale_x, scale_y)
1890
+ painter.translate(
1891
+ (self.width() - 252 * scale) / 2,
1892
+ (self.height() - 220 * scale) / 2,
1893
+ )
1894
+ painter.scale(scale, scale)
1895
+ draw_pet_body(painter, self.rect(), self._package, palette,
1896
+ status_action=0, thinking=False, blink=False, float_phase=0)
1897
+ painter.restore()
1898
+
1899
+
1900
+ class _PersonaCard(QWidget):
1901
+ """Selectable persona card — label, description, selection state."""
1902
+
1903
+ clicked = Signal(str)
1904
+
1905
+ def __init__(self, persona_id: str, label: str, description: str,
1906
+ selected: bool = False):
1907
+ super().__init__()
1908
+ self._id = persona_id
1909
+ self._label = label
1910
+ self._description = description
1911
+ self._selected = selected
1912
+ self.setFixedSize(440, 64)
1913
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
1914
+
1915
+ def set_selected(self, selected: bool):
1916
+ self._selected = selected
1917
+ self.update()
1918
+
1919
+ def mousePressEvent(self, event):
1920
+ if event.button() == Qt.MouseButton.LeftButton:
1921
+ self.clicked.emit(self._id)
1922
+ event.accept()
1923
+ return
1924
+ super().mousePressEvent(event)
1925
+
1926
+ def paintEvent(self, _event):
1927
+ painter = QPainter(self)
1928
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
1929
+ rect = self.rect().adjusted(1, 1, -1, -1)
1930
+
1931
+ if self._selected:
1932
+ border = QColor(104, 177, 255)
1933
+ bg = QColor(22, 28, 38)
1934
+ dot_color = QColor(10, 132, 255)
1935
+ else:
1936
+ border = QColor(58, 62, 70)
1937
+ bg = QColor(16, 18, 22, 238)
1938
+ dot_color = QColor(86, 90, 98)
1939
+
1940
+ painter.setPen(QPen(border, 2 if self._selected else 1))
1941
+ painter.setBrush(bg)
1942
+ painter.drawRoundedRect(rect, 12, 12)
1943
+
1944
+ # Selection dot
1945
+ painter.setPen(Qt.PenStyle.NoPen)
1946
+ painter.setBrush(dot_color)
1947
+ painter.drawEllipse(QPoint(22, 32), 5, 5)
1948
+
1949
+ # Label
1950
+ label_font = QFont("Menlo")
1951
+ if not label_font.exactMatch():
1952
+ label_font = QFont()
1953
+ label_font.setPointSize(12)
1954
+ label_font.setBold(True)
1955
+ painter.setFont(label_font)
1956
+ painter.setPen(QColor(236, 236, 236))
1957
+ painter.drawText(QRect(38, 6, 390, 26),
1958
+ Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
1959
+ self._label)
1960
+
1961
+ # Description
1962
+ if self._description:
1963
+ desc_font = QFont()
1964
+ desc_font.setPointSize(10)
1965
+ painter.setFont(desc_font)
1966
+ painter.setPen(QColor(165, 165, 170))
1967
+ painter.drawText(QRect(38, 30, 390, 26),
1968
+ Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
1969
+ self._description)
1970
+
1971
+
1972
+ class PersonalityWindow(FramelessToolWindow):
1973
+ """Personality/persona settings window.
1974
+
1975
+ Lists all personas from catalog as selectable cards,
1976
+ with a read-only prompt preview at the bottom.
1977
+ """
1978
+
1979
+ def __init__(self):
1980
+ super().__init__()
1981
+ self.setWindowTitle("Vox Pet 性格设置")
1982
+ self.setMinimumSize(QSize(480, 420))
1983
+ self.resize(480, 460)
1984
+ self._cards: list[_PersonaCard] = []
1985
+ self._current_id = ""
1986
+
1987
+ root = QWidget()
1988
+ self.setCentralWidget(root)
1989
+ outer = QVBoxLayout(root)
1990
+ outer.setContentsMargins(0, 0, 0, 0)
1991
+
1992
+ panel = QFrame()
1993
+ panel.setObjectName("personalityPanel")
1994
+ make_shadow(panel, blur=34, y=10, alpha=78)
1995
+ outer.addWidget(panel)
1996
+
1997
+ layout = QVBoxLayout(panel)
1998
+ layout.setContentsMargins(0, 0, 0, 0)
1999
+ layout.setSpacing(0)
2000
+
2001
+ # Header
2002
+ header = QHBoxLayout()
2003
+ header.setContentsMargins(18, 18, 18, 16)
2004
+ header.setSpacing(12)
2005
+
2006
+ back_button = QPushButton("←")
2007
+ back_button.setObjectName("settingsBackButton")
2008
+ back_button.setFixedSize(40, 40)
2009
+ back_button.clicked.connect(self.hide)
2010
+ header.addWidget(back_button)
2011
+
2012
+ title_label = QLabel("性格设置")
2013
+ title_font = QFont()
2014
+ title_font.setPointSize(18)
2015
+ title_font.setBold(True)
2016
+ title_label.setFont(title_font)
2017
+ header.addWidget(title_label)
2018
+ header.addStretch(1)
2019
+
2020
+ self.badge = QLabel("")
2021
+ self.badge.setObjectName("settingsBadge")
2022
+ header.addWidget(self.badge, 0, Qt.AlignmentFlag.AlignTop)
2023
+ layout.addLayout(header)
2024
+
2025
+ # Scroll area for persona cards
2026
+ scroll = QScrollArea()
2027
+ scroll.setWidgetResizable(True)
2028
+ scroll.setFrameShape(QFrame.Shape.NoFrame)
2029
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
2030
+ scroll.setStyleSheet(
2031
+ "QScrollArea { background: transparent; border: none; }"
2032
+ "QScrollBar:vertical { background: transparent; width: 8px; margin: 2px 0 2px 0; }"
2033
+ "QScrollBar::handle:vertical {"
2034
+ "background: rgba(110, 110, 116, 0.42);"
2035
+ "border-radius: 4px;"
2036
+ "min-height: 24px;"
2037
+ "}"
2038
+ "QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }"
2039
+ "QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: transparent; }"
2040
+ )
2041
+
2042
+ scroll_content = QWidget()
2043
+ self._scroll_layout = QVBoxLayout(scroll_content)
2044
+ self._scroll_layout.setContentsMargins(18, 4, 18, 4)
2045
+ self._scroll_layout.setSpacing(8)
2046
+ self._scroll_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
2047
+ scroll.setWidget(scroll_content)
2048
+ layout.addWidget(scroll, 1)
2049
+
2050
+ # Prompt editor
2051
+ preview_card = QFrame()
2052
+ preview_card.setObjectName("promptCard")
2053
+ preview_layout = QVBoxLayout(preview_card)
2054
+ preview_layout.setContentsMargins(18, 14, 18, 14)
2055
+ preview_layout.setSpacing(8)
2056
+
2057
+ prompt_title = QLabel("当前 Prompt")
2058
+ prompt_title.setStyleSheet(
2059
+ "color: #f1f1f1; font-size: 13px; font-weight: 700; background: transparent;"
2060
+ )
2061
+ preview_layout.addWidget(prompt_title)
2062
+
2063
+ self._prompt_edit = QPlainTextEdit()
2064
+ self._prompt_edit.setObjectName("personalityPromptEditor")
2065
+ self._prompt_edit.setMinimumHeight(100)
2066
+ self._prompt_edit.setMaximumHeight(160)
2067
+ self._prompt_edit.setPlaceholderText("选择一个性格预设,然后可以在这里自定义 Prompt")
2068
+ preview_layout.addWidget(self._prompt_edit)
2069
+
2070
+ # Prompt actions
2071
+ prompt_actions = QHBoxLayout()
2072
+ prompt_actions.setSpacing(8)
2073
+ self._save_prompt_btn = QPushButton("保存修改")
2074
+ self._save_prompt_btn.setObjectName("secondaryAction")
2075
+ self._save_prompt_btn.clicked.connect(self._save_custom_prompt)
2076
+ prompt_actions.addWidget(self._save_prompt_btn)
2077
+
2078
+ self._reset_prompt_btn = QPushButton("恢复默认")
2079
+ self._reset_prompt_btn.setObjectName("secondaryAction")
2080
+ self._reset_prompt_btn.clicked.connect(self._reset_custom_prompt)
2081
+ prompt_actions.addWidget(self._reset_prompt_btn)
2082
+
2083
+ self._prompt_status = QLabel("")
2084
+ self._prompt_status.setStyleSheet(
2085
+ "color: rgba(164, 164, 164, 0.95); font-size: 11px; background: transparent;"
2086
+ )
2087
+ prompt_actions.addWidget(self._prompt_status, 1)
2088
+
2089
+ preview_layout.addLayout(prompt_actions)
2090
+
2091
+ layout.addWidget(preview_card)
2092
+
2093
+ # Close button
2094
+ footer = QHBoxLayout()
2095
+ footer.setContentsMargins(18, 10, 18, 18)
2096
+ footer.setSpacing(10)
2097
+ footer.addStretch(1)
2098
+ close_btn = QPushButton("关闭")
2099
+ close_btn.setObjectName("secondaryAction")
2100
+ close_btn.clicked.connect(self.hide)
2101
+ footer.addWidget(close_btn)
2102
+ layout.addLayout(footer)
2103
+
2104
+ self.apply_skin("dark")
2105
+
2106
+ def load_personas(self, personas: list[dict], current_id: str):
2107
+ """Refresh the persona list and selection."""
2108
+ self._current_id = current_id
2109
+ self._rebuild_cards(personas)
2110
+ self._update_prompt(preview=not bool(self._cards))
2111
+ self.badge.setText(
2112
+ next((p["label"] for p in personas if p["id"] == current_id), "")
2113
+ )
2114
+
2115
+ def _rebuild_cards(self, personas: list[dict]):
2116
+ for card in self._cards:
2117
+ self._scroll_layout.removeWidget(card)
2118
+ card.deleteLater()
2119
+ self._cards.clear()
2120
+
2121
+ for persona in personas:
2122
+ pid = str(persona.get("id", "")).strip()
2123
+ label = str(persona.get("label", pid)).strip() or pid
2124
+ description = str(persona.get("description", "")).strip()
2125
+ selected = pid == self._current_id
2126
+ card = _PersonaCard(pid, label, description, selected=selected)
2127
+ if pid != "__placeholder__":
2128
+ card.clicked.connect(self._on_card_clicked)
2129
+ self._scroll_layout.addWidget(card)
2130
+ self._cards.append(card)
2131
+
2132
+ def _on_card_clicked(self, persona_id: str):
2133
+ """Switch persona and update UI."""
2134
+ pai_config.set_active_persona(persona_id)
2135
+ self._current_id = persona_id
2136
+ for card in self._cards:
2137
+ card.set_selected(card._id == persona_id)
2138
+ self._update_prompt()
2139
+ persona = pai_config.get_persona(persona_id)
2140
+ self.badge.setText(str(persona.get("label", persona_id)) if persona else persona_id)
2141
+
2142
+ def _update_prompt(self, preview: bool = False):
2143
+ if preview:
2144
+ self._prompt_edit.setPlainText("")
2145
+ return
2146
+ persona = pai_config.get_persona(self._current_id)
2147
+ if persona is None:
2148
+ self._prompt_edit.setPlainText("")
2149
+ return
2150
+ prompt = str(persona.get("prompt", "")).strip()
2151
+ self._prompt_edit.setPlainText(prompt)
2152
+ self._prompt_status.setText("")
2153
+
2154
+ @staticmethod
2155
+ def _catalog_path() -> Path:
2156
+ override = (
2157
+ __import__("os")
2158
+ .environ.get("VOX_CODE_HOME", "")
2159
+ .strip()
2160
+ or __import__("os").environ.get("VOX_HOME", "").strip()
2161
+ )
2162
+ if override:
2163
+ return Path(override).expanduser() / "catalog.json"
2164
+ return Path.home() / ".vox-code" / "catalog.json"
2165
+
2166
+ def _save_custom_prompt(self):
2167
+ """Save edited prompt to catalog.json as per-id override."""
2168
+ text = self._prompt_edit.toPlainText().strip()
2169
+ if not text:
2170
+ self._prompt_status.setText("Prompt 不能为空")
2171
+ self._prompt_status.setStyleSheet(
2172
+ "color: #ffe4e4; font-size: 11px; background: transparent;"
2173
+ )
2174
+ return
2175
+
2176
+ path = self._catalog_path()
2177
+ path.parent.mkdir(parents=True, exist_ok=True)
2178
+
2179
+ # Load existing catalog overrides
2180
+ existing: dict = {}
2181
+ if path.exists():
2182
+ try:
2183
+ existing = json.loads(path.read_text(encoding="utf-8"))
2184
+ except (json.JSONDecodeError, OSError):
2185
+ existing = {}
2186
+
2187
+ personas_override: list[dict] = existing.get("personas", [])
2188
+ found = False
2189
+ for p in personas_override:
2190
+ if p.get("id") == self._current_id:
2191
+ p["prompt"] = text
2192
+ found = True
2193
+ break
2194
+ if not found:
2195
+ personas_override.append({"id": self._current_id, "prompt": text})
2196
+
2197
+ existing["personas"] = personas_override
2198
+ try:
2199
+ path.write_text(
2200
+ json.dumps(existing, ensure_ascii=False, indent=2),
2201
+ encoding="utf-8",
2202
+ )
2203
+ except OSError as exc:
2204
+ self._prompt_status.setText(f"保存失败: {exc}")
2205
+ self._prompt_status.setStyleSheet(
2206
+ "color: #ffe4e4; font-size: 11px; background: transparent;"
2207
+ )
2208
+ return
2209
+
2210
+ # Reload catalog so pai_config picks up the change
2211
+ pai_config.reload_catalog()
2212
+ self._prompt_status.setText("已保存 ✓")
2213
+ self._prompt_status.setStyleSheet(
2214
+ "color: #7fdb9a; font-size: 11px; background: transparent;"
2215
+ )
2216
+
2217
+ def _reset_custom_prompt(self):
2218
+ """Remove per-id override from catalog.json, restore default."""
2219
+ path = self._catalog_path()
2220
+ if path.exists():
2221
+ try:
2222
+ existing = json.loads(path.read_text(encoding="utf-8"))
2223
+ except (json.JSONDecodeError, OSError):
2224
+ existing = {}
2225
+
2226
+ personas_override = [
2227
+ p for p in existing.get("personas", []) if p.get("id") != self._current_id
2228
+ ]
2229
+ existing["personas"] = personas_override
2230
+ try:
2231
+ path.write_text(
2232
+ json.dumps(existing, ensure_ascii=False, indent=2),
2233
+ encoding="utf-8",
2234
+ )
2235
+ except OSError:
2236
+ pass
2237
+
2238
+ pai_config.reload_catalog()
2239
+ self._update_prompt()
2240
+ self._prompt_status.setText("已恢复默认 ✓")
2241
+ self._prompt_status.setStyleSheet(
2242
+ "color: rgba(164, 164, 164, 0.95); font-size: 11px; background: transparent;"
2243
+ )
2244
+
2245
+ def apply_skin(self, skin: str):
2246
+ self.setStyleSheet(
2247
+ "QMainWindow { background: transparent; }"
2248
+ "#personalityPanel {"
2249
+ "background: rgba(10, 12, 16, 244);"
2250
+ "border: 1px solid rgba(72, 76, 84, 220);"
2251
+ "border-radius: 26px;"
2252
+ "}"
2253
+ "#settingsBackButton {"
2254
+ "background: rgba(29, 32, 36, 0.96);"
2255
+ "border: 1px solid rgba(86, 90, 98, 0.88);"
2256
+ "border-radius: 20px;"
2257
+ "color: #f1f1f1;"
2258
+ "font-size: 18px;"
2259
+ "font-weight: 700;"
2260
+ "}"
2261
+ "#settingsBackButton:hover { background: rgba(74, 79, 87, 0.98); }"
2262
+ "#settingsBadge {"
2263
+ "padding: 6px 12px;"
2264
+ "border-radius: 14px;"
2265
+ "background: rgba(29, 32, 36, 0.96);"
2266
+ "border: 1px solid rgba(86, 90, 98, 0.88);"
2267
+ "color: rgba(214, 214, 214, 0.92);"
2268
+ "font-size: 12px;"
2269
+ "font-weight: 700;"
2270
+ "}"
2271
+ "#promptCard {"
2272
+ "background: rgba(22, 24, 28, 0.96);"
2273
+ "border: 1px solid rgba(58, 62, 70, 0.92);"
2274
+ "border-radius: 16px;"
2275
+ "margin: 0 18px;"
2276
+ "}"
2277
+ "QPlainTextEdit#personalityPromptEditor {"
2278
+ "background: rgba(44, 42, 43, 0.96);"
2279
+ "border: 1px solid rgba(96, 96, 102, 0.86);"
2280
+ "border-radius: 12px;"
2281
+ "padding: 10px 12px;"
2282
+ "color: #d8d8dc;"
2283
+ "font-size: 12px;"
2284
+ "font-family: Menlo, Monaco, monospace;"
2285
+ "}"
2286
+ "QLabel { color: #f1f1f1; font-size: 14px; }"
2287
+ "QPushButton#secondaryAction {"
2288
+ "background: rgba(29, 32, 36, 0.96);"
2289
+ "border: 1px solid rgba(86, 90, 98, 0.88);"
2290
+ "border-radius: 18px;"
2291
+ "padding: 10px 16px;"
2292
+ "color: #f1f1f1;"
2293
+ "font-size: 13px;"
2294
+ "font-weight: 700;"
2295
+ "min-height: 42px;"
2296
+ "}"
2297
+ "QPushButton#secondaryAction:hover { background: rgba(74, 79, 87, 0.98); }"
2298
+ )