Repository navigation
Expand file tree
/
Copy pathpet_window.py
More file actions
4070 lines (3760 loc) · 189 KB
/
Copy pathpet_window.py
File metadata and controls
4070 lines (3760 loc) · 189 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# -*- coding: utf-8 -*-
"""主窗口(原 oc.py 五、六区):桌宠本体、状态机、系统感知、对话、
记忆业务逻辑与程序入口。"""
import os
import sys
import re
import json
import time
import socket
import datetime
import random
import threading
import webbrowser
import ctypes
# ===== 修复 Qt 找不到平台插件(中文路径常见问题)=====
import PyQt5
_plugin_path = os.path.join(os.path.dirname(PyQt5.__file__), "Qt5", "plugins")
if os.path.isdir(_plugin_path):
os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = _plugin_path
import requests
try:
import psutil
except ImportError:
psutil = None
if sys.platform == "win32":
try:
import winsound
except Exception:
winsound = None
else:
winsound = None
from PyQt5.QtCore import (Qt, QTimer, QPropertyAnimation, QEasingCurve,
QPoint, QEvent, pyqtSignal)
from PyQt5.QtGui import QPixmap, QFont, QIcon
from PyQt5.QtWidgets import (QApplication, QWidget, QLabel, QMenu, QLineEdit,
QPushButton, QHBoxLayout, QVBoxLayout, QFrame,
QGraphicsOpacityEffect, QProgressBar, QDialog,
QSystemTrayIcon)
from config import * # 全部常量(图片路径/人设/台词/商城/天气/记忆参数)
from data import (data_dir, resource_path, load_json, save_json, load_api_key,
save_api_key, load_log, save_log, append_log,
ensure_data_files, load_memory, save_memory, load_settings)
from api_client import (SignalBus, ask_llm, ask_llm_stream, load_api_config,
save_api_config)
from context_budget import ContextBudget # 上下文预算 + 滚动摘要(纯 stdlib,不依赖 Qt)
import listening # 听歌状态机(纯 stdlib,不依赖 Qt/winrt)
import care # 情绪察觉 + 主动关心(纯 stdlib,只读心情/评测)
import music_folder # 本地音乐播放器(QMediaPlayer,主线程)
import single_instance # 单实例守卫(多次双击 exe 也只留一只麟)
from local_brain import local_reply as _local_reply
from memory import memory_log_dir, write_daily_log, extract_facts_local
from mood import (load_moods, save_moods, recent_records, NEGATIVE_KEYS,
mood_name, mood_reply, mood_emoji, pick_warm_word, add_record)
from mood_summary import build_prompt, parse_mood_json, should_summarize
from assessment import recent_assessment
from widgets import BubbleLabel, PetLabel
from crash_guard import install as install_crash_guard
from dialogs import (HistoryDialog, HelpDialog, MemoryDialog, PersonaDialog,
ProfileDialog, FocusDialog, BackpackDialog, ScheduleDialog,
RPSDialog, DiceDialog, OrganizeDialog,
NoteDialog, ShopDialog, ApiEditDialog, ApiManagerDialog,
SettingsDialog, MoodRecordDialog, MoodHistoryDialog,
MoodStatsDialog, CrisisSupportDialog, AssessmentDialog,
AssessmentHistoryDialog, MusicPlayerDialog, ChatWindow)
class _EventOverlay(QFrame):
"""随机事件选项条:pet 窗口内的普通子控件,不是独立窗口。
旧版用 EventDialog(QDialog 顶层窗口)。就算设了 NonModal +
WA_ShowWithoutActivating,它仍然是一个独立窗口:父窗口激活时系统会把它
当成可接收首键的窗口,用户随手敲的 Enter/Esc 就把它关掉,表现出来就是
「弹窗一闪而过、打字被打断」。
改成子控件之后根本不存在可抢焦点的窗口:键盘焦点永远留在原来获得焦点的
控件上;两个按钮都是 NoFocus,Enter/Esc 也不会误触(旧版 QDialog 里
QPushButton 默认 autoDefault,Enter 会直接替用户选了 A)。
"""
# 用户选择后发出:'a' / 'b';超时或外部取消时发 ''。
finished = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("event_overlay")
self.setFocusPolicy(Qt.NoFocus)
# 下面这个属性对子控件本身不生效(只对窗口有意义),保留是为了让
# 「事件条不激活窗口」的意图在代码里可读、可检索。
self.setAttribute(Qt.WA_ShowWithoutActivating, True)
self.chosen = None
self._timed_out = False
self._shown = False # 事件条自身的展示状态(与父窗口可见性解耦)
lay = QVBoxLayout(self)
lay.setContentsMargins(10, 6, 10, 6)
lay.setSpacing(4)
self.title_lab = QLabel("", self)
self.title_lab.setWordWrap(True)
self.title_lab.setFocusPolicy(Qt.NoFocus)
self.text_lab = QLabel("", self)
self.text_lab.setWordWrap(True)
self.text_lab.setFocusPolicy(Qt.NoFocus)
brow = QHBoxLayout()
brow.setContentsMargins(0, 0, 0, 0)
brow.setSpacing(6)
self.btn_a = QPushButton("A", self)
self.btn_b = QPushButton("B", self)
for b in (self.btn_a, self.btn_b):
# 关键:按钮不吃键盘焦点,Enter 不会误触,Tab 也进不来
b.setFocusPolicy(Qt.NoFocus)
b.setCursor(Qt.PointingHandCursor)
self.btn_a.clicked.connect(lambda: self._pick("a"))
self.btn_b.clicked.connect(lambda: self._pick("b"))
brow.addWidget(self.btn_a, 1)
brow.addWidget(self.btn_b, 1)
lay.addWidget(self.title_lab)
lay.addWidget(self.text_lab, 1)
lay.addLayout(brow)
def show(self):
"""展示:只在 pet 窗口内部浮到最上层,不涉及任何顶层窗口。"""
self._shown = True
super().show()
self.raise_()
def hide(self):
self._shown = False
super().hide()
def isVisible(self):
"""事件条是否处于「展示中」。
子控件的 Qt isVisible() 还要求父窗口可见;事件条是否在展示与父窗口
是否已显示是两件事(启动早期/无头测试里父窗口可能还没 show),
所以这里按自身展示状态回答,避免调用方误判成「没弹出来」。
"""
return self._shown
def load(self, ev):
"""把一条事件填进控件(不负责显示)。"""
self.chosen = None
self._timed_out = False
self.title_lab.setText("✨ " + str(ev.get("title") or "小事件"))
self.text_lab.setText(str(ev.get("text") or ""))
self.btn_a.setText("A. " + str(ev.get("a") or "…"))
self.btn_b.setText("B. " + str(ev.get("b") or "…"))
def _pick(self, key):
if self.chosen is not None or not self.isVisible():
return
self.chosen = key
self.hide()
self.finished.emit(key)
def timeout(self):
"""60 秒没人理:静默收起,不算用户做了选择。"""
if not self.isVisible():
return
self._timed_out = True
self.chosen = None
self.hide()
self.finished.emit("")
def reject(self):
"""兼容 QDialog.reject() 的语义(外部/测试代码可能直接调用):静默收起。"""
self._timed_out = True
self.chosen = None
self.hide()
self.finished.emit("")
class PetWindow(QWidget):
# 主人「正忙着」的阈值:最近 20 秒内有键鼠输入(本应用或其他程序)就不打扰
BUSY_INPUT_SECONDS = 20
def __init__(self):
super().__init__()
# ---- 自定义配置(settings.json,自动兼容旧档) ----
self.settings = load_settings()
# 置顶可关(设置面板里的勾选框):默认置顶,免得桌宠被别的窗口压住;
# 但打开面板时会临时让位,见 _pin_pet / _exec_dialog。
self._always_on_top = bool(self.settings.get("always_on_top", True))
self._dialog_depth = 0
self._topmost_now = None
_flags = Qt.FramelessWindowHint | Qt.Tool
if self._always_on_top:
_flags |= Qt.WindowStaysOnTopHint
self.setWindowFlags(_flags)
self.setAttribute(Qt.WA_TranslucentBackground)
self.setWindowTitle("丰裕之角桌宠 - 麟")
self.setFixedSize(340, 562)
self.scale = max(0.4, min(1.5, float(self.settings.get("pet_scale", 100)) / 100.0))
self.idle_timeout = int(self.settings.get("chat_interval", 1200))
self.event_min = max(1, int(self.settings.get("event_min", 30))) * 60
self.event_max = max(self.event_min, int(self.settings.get("event_max", 120)) * 60)
self.event_freq = random.randint(self.event_min, self.event_max)
self.event_weight = max(0, min(100, int(self.settings.get("event_weight", 60))))
self._feed_lock = 0.0
# ---- 背景框(商城购买后显示,放在最底层) ----
self.bg_label = QLabel(self)
self.bg_label.setGeometry(4, 120, 332, 404)
self.bg_label.hide()
self.bg_label.lower()
# ---- 气泡 / 桌宠 / 输入框 ----
self.bubble = BubbleLabel(self)
self.bubble.setGeometry(6, 8, 328, 118)
self.bubble.hide()
# 常驻气泡透明度效果:复用同一实例,避免替换效果时
# 删除仍在运行的淡出动画目标,导致空指针崩溃
self._bubble_effect = QGraphicsOpacityEffect(self.bubble)
self.bubble.setGraphicsEffect(self._bubble_effect)
self.pet = PetLabel(self)
self.pet.setGeometry(10, 126, 320, 392)
self.pet.setAlignment(Qt.AlignCenter)
self.pet.setCursor(Qt.OpenHandCursor)
self.input_frame = QFrame(self)
self.input_frame.setGeometry(6, 526, 328, 30)
self.input_frame.setStyleSheet("QFrame { background: rgba(40, 28, 20, 235); border-radius: 17px; }")
row = QHBoxLayout(self.input_frame)
row.setContentsMargins(12, 0, 6, 0)
row.setSpacing(4)
self.input = QLineEdit(self.input_frame)
self.input.setPlaceholderText("和麟说点什么…(回车发送,输入 #帮助 看指令)")
self.input.setStyleSheet("QLineEdit { background: transparent; border: none; color: #ffffff; font-size: 13px; }")
self.send_btn = QPushButton("发送", self.input_frame)
self.send_btn.setStyleSheet(
"QPushButton { background: #9c6b45; color: #ffffff; border: none;"
" border-radius: 12px; padding: 4px 14px; font-size: 12px; }"
"QPushButton:hover { background: #b58155; }")
row.addWidget(self.input, 1)
row.addWidget(self.send_btn)
# ---- 好感度条 ----
self.aff_bar = QFrame(self)
self.aff_bar.setGeometry(6, 496, 150, 24)
self.aff_bar.setStyleSheet("QFrame{background:rgba(40,28,20,235);border-radius:12px;}")
ah = QHBoxLayout(self.aff_bar)
ah.setContentsMargins(8, 2, 8, 2)
ah.setSpacing(4)
self.aff_bar_lab = QLabel("❤️")
self.aff_bar_lab.setStyleSheet("color:#ff8fa3;font-size:12px;background:transparent;")
self.aff_bar_prog = QProgressBar()
self.aff_bar_prog.setRange(0, 200)
self.aff_bar_prog.setValue(0)
self.aff_bar_prog.setFixedWidth(90)
self.aff_bar_prog.setTextVisible(False)
self.aff_bar_prog.setStyleSheet(
"QProgressBar{background:#5a3a28;border:none;border-radius:5px;height:8px;}"
"QProgressBar::chunk{background:#ff8fa3;border-radius:5px;}")
ah.addWidget(self.aff_bar_lab)
ah.addWidget(self.aff_bar_prog)
self.aff_bar.hide()
# ---- 专注倒计时标签(状态行左侧,与好感度条/天气互斥显示) ----
self.focus_label = QLabel("⏱ 00:00", self)
self.focus_label.setGeometry(6, 496, 218, 24)
self.focus_label.setStyleSheet(
"background:rgba(40,28,20,235);color:#ffe9c9;border-radius:12px;padding:4px 8px;font-size:12px;")
self.focus_label.setAlignment(Qt.AlignCenter)
self.focus_label.hide()
# 专注取消按钮:紧贴倒计时右侧,只在专注中显示
self.focus_cancel_btn = QPushButton("✕", self)
self.focus_cancel_btn.setGeometry(226, 496, 24, 24)
self.focus_cancel_btn.setToolTip("取消专注时钟")
self.focus_cancel_btn.setStyleSheet(
"QPushButton{background:#e8b7a0;border:none;border-radius:6px;"
"color:#5a2f1d;font-size:12px;font-weight:bold;}"
"QPushButton:hover{background:#e09a7c;}")
self.focus_cancel_btn.clicked.connect(self.cancel_focus)
self.focus_cancel_btn.hide()
# ---- 网络状态小图标 ----
self.net_label = QLabel("📵", self)
self.net_label.setGeometry(288, 128, 32, 32)
self.net_label.setStyleSheet("background:rgba(0,0,0,200);border-radius:16px;font-size:18px;")
self.net_label.setAlignment(Qt.AlignCenter)
self.net_label.hide()
# (雨天撑伞已并入主立绘状态机 _idle_file,不再用叠加层——
# umbrella.png 是完整立绘,叠加会出现"两只麟")
# ---- 天气小标签(底部状态行中段) ----
self.weather_label = QLabel("🌐", self)
self.weather_label.setGeometry(162, 496, 88, 24)
self.weather_label.setStyleSheet(
"background:rgba(40,28,20,235);color:#fff;border-radius:12px;padding:2px 6px;font-size:11px;")
self.weather_label.setAlignment(Qt.AlignCenter)
self.weather_label.hide()
# ---- 模式状态标签(底部状态行右段:AI 联网 / 离线保底) ----
self.mode_label = QLabel("", self)
self.mode_label.setGeometry(256, 496, 78, 24)
self.mode_label.setStyleSheet(
"background:rgba(40,28,20,235);color:#ffe9c9;border-radius:12px;padding:2px 6px;font-size:11px;")
self.mode_label.setAlignment(Qt.AlignCenter)
self.mode_label.hide()
# ---- 剪贴板互动条 ----
self.clip_overlay = QFrame(self)
self.clip_overlay.setGeometry(6, 122, 328, 44)
self.clip_overlay.setStyleSheet("QFrame{background:rgba(30,20,14,200);border-radius:10px;}")
ch = QHBoxLayout(self.clip_overlay)
ch.setContentsMargins(10, 4, 6, 4)
ch.setSpacing(6)
self.clip_lab = QLabel("📋 你复制了什么?")
self.clip_lab.setStyleSheet("color:#fff;background:transparent;font-size:12px;")
b1 = QPushButton("看看")
b1.setStyleSheet("QPushButton{background:#8c5a3c;color:#fff;border:none;border-radius:8px;padding:3px 10px;}")
b2 = QPushButton("吃掉")
b2.setStyleSheet("QPushButton{background:#b23a48;color:#fff;border:none;border-radius:8px;padding:3px 10px;}")
b1.clicked.connect(self._clip_look)
b2.clicked.connect(self._clip_eat)
ch.addWidget(self.clip_lab)
ch.addStretch(1)
ch.addWidget(b1)
ch.addWidget(b2)
self.clip_overlay.hide()
# ---- 随机事件选项条(pet 窗口内嵌覆盖层,不是弹窗)----
self.event_overlay = _EventOverlay(self)
self.event_overlay.setGeometry(10, 170, 320, 122)
self.event_overlay.hide()
self.event_overlay.finished.connect(self._on_event_overlay_finished)
# ---- 状态数据 ----
self.economy = load_json("economy.json", {"coins": 100, "last_checkin": "", "outfits": [],
"affinity": 0, "mood": 60, "current_outfit": ""})
self.coins = int(self.economy.get("coins", 100))
self.affinity = int(self.economy.get("affinity", 0))
self.current_prompt = PERSONA_PROMPT
self.profile = load_json("profile.json", {})
self.mood_value = max(0, min(100, int(self.economy.get("mood", 60))))
self.mood = "happy" if self.mood_value >= 70 else ("normal" if self.mood_value >= 40 else "sad")
self.current_outfit = str(self.economy.get("current_outfit", "") or "")
if self.current_outfit and not os.path.exists(resource_path("{}_close.png".format(self.current_outfit))):
self.current_outfit = "" # 换装图缺失时自动回退默认服装
self.pixmaps = {}
self.load_images()
self.pose = "idle"
self.mouth_on = False
self.wag_idx = 0
self.wag_cycles = 0
self.sentences = []
self.cur_idx = 0
self.dlg_state = "idle"
self.complete_time = 0.0
self.typing_pos = 0
self.busy = False
self.history = []
# 滚动摘要 + 按 token 预算裁剪;持久化在 chat_context.json(与 memory.json 互不影响)。
# 即使 CONTEXT_BUDGET_ENABLED=False 也保留这个对象:它只是状态容器,不会主动做任何事。
self.ctx = ContextBudget().load()
self._summary_pending = False # 摘要请求在途标记,避免重复发
# 聊天情绪总结(mood_summary.py):攒的用户消息数 + 在途标记。
# 计数只放内存:重启丢一次进度无所谓,少总结一轮而已。
self._chat_pending = 0
self._mood_summary_pending = False
# 听歌感知:每 15 秒采样一次「系统正在播放什么」,再决定要不要搭话
self.listener = listening.ListenTracker()
self._listen_warm = False # now_playing 是否已在后台线程预热过
self._listen_loading = False # 预热线程是否在跑
self._fade_anim = None
self._shake_anim = None
self._grav_anim = None
self._eat_timer = None
self._eat_count = 0
self._pending_food = None
self._pending_event = None
self.event_busy = False
self._wag_start = 0.0
self.weather_temp = ""
self.last_activity = time.time()
self.click_count = 0
self.drag_count = 0
self._last_pet_time = 0
self._last_clip = None
self.focus_active = False
self._focus_trust_until = 0 # 专注监督静默期限(聊天里说了在学习/工作就暂不提醒)
self._rainy_weather = False # 雨雪天待机撑伞(_idle_file 状态机)
self.focus_end = 0
self.focus_minutes = 0
self.sleep_mode = False
self.forced_sleep = False
self.manual_awake = False # #醒 强行叫醒:夜间也不再自动睡回去
self.game_mode = False
self._pre_game_pos = None
self.net_ok = True
self.net_ms = 0
self.weather = ""
self.last_event_time = time.time()
self.last_wake_date = ""
self._last_over_msg = 0
self._last_mem_msg = 0
self._last_ping_msg = 0
self._last_wx_msg = time.time()
self._last_chime_hour = -1
self.thunder_mode = False # 雷雨天气害怕待机
self.music_mode = False # 音乐播放中(播放器或八音盒)
self.music_end = 0 # 播放截止时间戳;0 = 不限时(手动停/睡/游戏/退出才停)
self._music_player = None # 八音盒播放器(music_box.MusicPlayer,降级后备)
self._mf_player = None # 本地音乐播放器(music_folder.MusicFolderPlayer)
self._music_dlg = None # 音乐面板(非模态;None=没开,hidden=最小化中)
self._chat_dlg = None # 聊天窗口(非模态;开着=窗口模式,关掉自动回气泡)
self._music_started_at = 0.0 # 起播时刻(clock_tick 收尾判断的宽限期基准)
self._event_defer = 0 # 随机事件弹窗因用户忙碌被推迟的次数
self._event_created_at = 0.0 # 当前待展示事件的生成时刻(0=未知)
self._event_dlg = None # 正在展示的随机事件小窗
self._help_dlg = None # 帮助窗(非模态,只开一个)
self._api_kind = None # 最近一次 API 请求的类型(chat/event/food/memory)
self._offline_hinted = False # 是否已提示过「没配 Key,正在离线保底」
self._pending_emotion = None # 回复完成后要播的表情
self._say_done_cb = None # say() 完成回调
self._emotion_file = None # 当前一次性表情帧
self._feed_streak = 0 # 连续喂食计数(打嗝用)
self._want_burp = False
self._sleep_pending = False # 打哈欠→入睡过渡中
self._memory_busy = False
self._pending_memory = None
self._last_daily_mem = ""
self._aff_was_visible = False # 专注前好感度条是否可见
self._last_mode_update = 0.0
# ---- 信号 ----
self.bus = SignalBus()
self.bus.reply_ready.connect(self._on_reply, Qt.QueuedConnection)
self.bus.reply_error_kind.connect(self._on_api_error_kind, Qt.QueuedConnection)
self.bus.event_ready.connect(self._on_event, Qt.QueuedConnection)
self.bus.food_ready.connect(self._on_food, Qt.QueuedConnection)
self.bus.memory_ready.connect(self._on_memory, Qt.QueuedConnection)
self.bus.weather_ready.connect(self._on_weather, Qt.QueuedConnection)
self.bus.ping_ready.connect(self._on_ping, Qt.QueuedConnection)
self.bus.summary_ready.connect(self._on_summary, Qt.QueuedConnection) # 滚动摘要回填
self.bus.mood_ready.connect(self._on_mood_summary, Qt.QueuedConnection) # 聊天情绪总结回写
self.bus.stream_ready.connect(self._on_stream_chunk, Qt.QueuedConnection) # 聊天窗口流式增量
self.send_btn.clicked.connect(self.send_message)
self.input.returnPressed.connect(self.send_message)
self.pet.clicked.connect(self._on_pet_clicked)
self.pet.drag_started.connect(self._on_drag_start)
self.pet.drag_ended.connect(self._on_drag_end)
self.pet.petted.connect(self._on_pet_hover)
self.bubble.clicked.connect(self._on_bubble_click)
# ---- 计时器 ----
self.type_timer = QTimer(self)
self.type_timer.setInterval(35)
self.type_timer.timeout.connect(self._type_char)
self.mouth_timer = QTimer(self)
self.mouth_timer.setInterval(160)
self.mouth_timer.timeout.connect(self._mouth_tick)
self.wag_timer = QTimer(self)
self.wag_timer.setInterval(200)
self.wag_timer.timeout.connect(self._wag_tick)
self.blink_timer = QTimer(self) # 待机眨眼(缺图自动跳过)
self.blink_timer.setInterval(4000)
self.blink_timer.timeout.connect(self._blink_tick)
self.blink_timer.start()
self._sleepy_timer = QTimer(self) # 睡意回复:慢悠悠晃脑袋
self._sleepy_timer.setInterval(600)
self._sleepy_timer.timeout.connect(self._sleepy_tick)
self.auto_advance_timer = QTimer(self)
self.auto_advance_timer.setSingleShot(True)
self.auto_advance_timer.timeout.connect(self._auto_advance)
self.auto_fade_timer = QTimer(self)
self.auto_fade_timer.setSingleShot(True)
self.auto_fade_timer.timeout.connect(self._auto_fade)
self.idle_timer = QTimer(self)
self.idle_timer.timeout.connect(self._idle_tick)
self.idle_timer.start(random.randint(15000, 25000))
self.clock_timer = QTimer(self) # 每秒:整点/昼夜/日程/专注显示
self.clock_timer.setInterval(1000)
self.clock_timer.timeout.connect(self._clock_tick)
self.clock_timer.start()
self.focus_timer = QTimer(self) # 专注监督
self.focus_timer.setInterval(2000)
self.focus_timer.timeout.connect(self._focus_check)
self.focus_timer.start()
self.sys_timer = QTimer(self) # CPU/内存监控
self.sys_timer.setInterval(5000)
self.sys_timer.timeout.connect(self._system_check)
self.sys_timer.start()
self.game_timer = QTimer(self) # 游戏侦测
self.game_timer.setInterval(5000)
self.game_timer.timeout.connect(self._game_check)
self.game_timer.start()
self.ping_timer = QTimer(self) # 网络延迟
self.ping_timer.setInterval(60000)
self.ping_timer.timeout.connect(self._ping_check)
self.ping_timer.start()
self.weather_timer = QTimer(self) # 天气
self.weather_timer.setInterval(3600000)
self.weather_timer.timeout.connect(self._weather_check)
self.weather_timer.start()
self.event_timer = QTimer(self) # 随机事件
self.event_timer.setInterval(60000)
self.event_timer.timeout.connect(self._event_tick)
self.event_timer.start()
self.listen_timer = QTimer(self) # 听歌感知:每 15 秒看一眼在放什么
self.listen_timer.setInterval(15000)
self.listen_timer.timeout.connect(self._listen_tick)
self.listen_timer.start()
# winrt 首次加载 ~110ms,放到后台线程预热;用户 3 秒后才被采样
QTimer.singleShot(3000, self._start_listen_warm)
# 主动关心:只读 mood.json / assessment.json(不评判、没有对错),
# 每 10 分钟看一眼;真说话前还要过 care 自己的 3 小时冷却 + 12 小时同状态冷却。
self.care = care.CareTracker()
self._care_analysis_until = 0.0 # 主人点了「一起分析」后的 30 分钟内,回复走陪伴分析口径
self.care_timer = QTimer(self)
self.care_timer.setInterval(10 * 60 * 1000)
self.care_timer.timeout.connect(self._care_tick)
self.care_timer.start()
QTimer.singleShot(45000, self._care_tick) # 启动 45 秒后先看一眼(不打扰启动)
# 随机事件覆盖层的两个一次性定时器(单条链,不会堆积):
# - retry:主人忙时推迟重试
# - auto :60 秒没人理就静默收起
self._event_retry_timer = QTimer(self)
self._event_retry_timer.setSingleShot(True)
self._event_retry_timer.setInterval(20000)
self._event_retry_timer.timeout.connect(self._show_event_dialog)
self._event_auto_timer = QTimer(self)
self._event_auto_timer.setSingleShot(True)
self._event_auto_timer.setInterval(60000)
self._event_auto_timer.timeout.connect(self._auto_close_event)
self.activity_timer = QTimer(self) # 无操作闲聊陪伴
self.activity_timer.setInterval(10000)
self.activity_timer.timeout.connect(self._idle_activity_check)
self.activity_timer.start()
# 全局活动监听 + 剪贴板
QApplication.instance().installEventFilter(self)
QApplication.clipboard().dataChanged.connect(self._on_clipboard_changed)
# 初始显示
self._show(self._idle_file())
self._apply_scale()
self._update_aff_bar()
self._update_daynight(time.localtime().tm_hour)
self._update_mode_label()
# 启动任务
QTimer.singleShot(900, self._greet)
QTimer.singleShot(3000, self._weather_check)
QTimer.singleShot(6000, self._ping_check)
QTimer.singleShot(2000, self._startup_memory)
QTimer.singleShot(4500, self._prewarm_music_audio)
# ---------- 图片 ----------
def _pet_pix(self, pix):
"""统一高度缩放:所有姿态帧按同一显示高度 390px(100% 缩放)缩放,
避免因画布尺寸不同导致角色忽大忽小。
仅当某图宽度超宽(横构图,未按统一画布重制)时退回宽度适配防溢出。"""
h = int(390 * self.scale)
w = int(h * pix.width() / max(1, pix.height()))
if w > int(320 * self.scale):
return pix.scaled(int(320 * self.scale), int(390 * self.scale),
Qt.KeepAspectRatio, Qt.SmoothTransformation)
return pix.scaledToHeight(h, Qt.SmoothTransformation)
def load_images(self):
self.pixmaps.clear()
names = set([IMG_CLOSE, IMG_OPEN, IMG_BACK, IMG_TURN, IMG_NING, IMG_SIT,
IMG_SLEEP, IMG_CLIMB, IMG_SWEAT, IMG_FAINT, IMG_OFFLINE,
IMG_LOW, IMG_HIGH, IMG_UMBRELLA, IMG_THINK, IMG_WORK,
IMG_WAVE, IMG_SAD, IMG_ANGRY, IMG_CRY, IMG_LOVE,
IMG_THUNDER, IMG_MUSIC, IMG_BURP, IMG_YAWN]
+ IMG_TAIL + IMG_BLINK + IMG_SURPRISED)
for name in names:
path = resource_path(name)
if not os.path.exists(path):
continue
pix = QPixmap(path)
if pix.isNull():
continue
self.pixmaps[name] = self._pet_pix(pix)
def _show(self, filename):
if not filename:
filename = IMG_CLOSE
pix = self.pixmaps.get(filename)
if pix is None:
path = resource_path(filename)
if os.path.exists(path):
p = QPixmap(path)
if not p.isNull():
pix = self._pet_pix(p)
self.pixmaps[filename] = pix
if pix is None:
pix = self.pixmaps.get(IMG_CLOSE)
if pix is not None:
self.pet.setPixmap(pix)
def _idle_file(self):
"""待机图优先级:换装 → 雷雨害怕 → 雨雪撑伞 → 心情低落 → 好感表情 → 默认"""
if self.current_outfit:
f = "{}_close.png".format(self.current_outfit)
if os.path.exists(resource_path(f)):
return f
if self.thunder_mode and os.path.exists(resource_path(IMG_THUNDER)):
return IMG_THUNDER
if getattr(self, "_rainy_weather", False) and not self.sleep_mode \
and not self.game_mode and os.path.exists(resource_path(IMG_UMBRELLA)):
return IMG_UMBRELLA
if self.mood_value < 40 and os.path.exists(resource_path(IMG_SAD)):
return IMG_SAD
if self.affinity < 30 and os.path.exists(resource_path(IMG_LOW)):
return IMG_LOW
if self.affinity >= 120:
if os.path.exists(resource_path(IMG_LOVE)):
return IMG_LOVE
if os.path.exists(resource_path(IMG_HIGH)):
return IMG_HIGH
return IMG_CLOSE
def _close_file(self):
if self.current_outfit:
f = "{}_close.png".format(self.current_outfit)
if os.path.exists(resource_path(f)):
return f
return IMG_CLOSE
def _open_file(self):
if self.current_outfit:
f = "{}_open.png".format(self.current_outfit)
if os.path.exists(resource_path(f)):
return f
return IMG_OPEN
def _tail_files(self):
"""换装尾巴动画:套装名_tail1/2/3.png 齐全时用套装尾巴,否则回退默认"""
if self.current_outfit:
names = ["{}_tail{}.png".format(self.current_outfit, i) for i in (1, 2, 3)]
if all(os.path.exists(resource_path(n)) for n in names):
return names
return IMG_TAIL
def set_outfit(self, name):
"""换装并持久化:name 为空或“默认”则脱掉;对应图片缺失返回 False"""
name = (name or "").strip()
if not name or name == "默认":
self.current_outfit = ""
else:
if not os.path.exists(resource_path("{}_close.png".format(name))):
return False
self.current_outfit = name
self.economy["current_outfit"] = self.current_outfit
save_json("economy.json", self.economy)
self._show(self._idle_file())
return True
def reload_images(self):
self.load_images()
mapping = {"idle": self._idle_file(), "talking": self._idle_file(), "wag": self._idle_file(),
"ning": IMG_NING, "back": IMG_BACK, "turn": IMG_TURN,
"thinking": (IMG_THINK if os.path.exists(resource_path(IMG_THINK)) else self._idle_file()),
"work": (IMG_WORK if os.path.exists(resource_path(IMG_WORK)) else self._idle_file()),
"wave": (IMG_WAVE if os.path.exists(resource_path(IMG_WAVE)) else self._idle_file()),
"emotion": (getattr(self, "_emotion_file", None)
if getattr(self, "_emotion_file", None)
and os.path.exists(resource_path(self._emotion_file))
else self._idle_file()),
"music": (IMG_MUSIC if os.path.exists(resource_path(IMG_MUSIC)) else self._idle_file()),
"sleep": (IMG_SLEEP if os.path.exists(resource_path(IMG_SLEEP)) else IMG_BACK),
"climb": (IMG_CLIMB if os.path.exists(resource_path(IMG_CLIMB)) else IMG_BACK),
"overheat": (IMG_SWEAT if os.path.exists(resource_path(IMG_SWEAT)) else IMG_NING),
"lowmem": (IMG_FAINT if os.path.exists(resource_path(IMG_FAINT)) else IMG_SIT),
"offline": (IMG_OFFLINE if os.path.exists(resource_path(IMG_OFFLINE)) else IMG_NING)}
self._show(mapping.get(self.pose, self._idle_file()))
def apply_bg(self, name):
path = resource_path("bg_{}.png".format(name))
if os.path.exists(path):
pix = QPixmap(path)
if not pix.isNull():
self.bg_label.setPixmap(pix.scaled(int(332 * self.scale), int(404 * self.scale),
Qt.KeepAspectRatio, Qt.SmoothTransformation))
self.bg_label.show()
self.bg_label.lower()
return True
return False
def _apply_scale(self):
"""按 settings.json 的 pet_scale 整体缩放窗口与控件:
- 坐标与控件尺寸等比缩放,但字体、控件高度/宽度设下限,
避免小缩放时文字被控件裁剪(数字上下被"文本框"挡住的问题);
- 缩放 <70% 时进入紧凑模式:隐藏好感度条,只留天气与接入状态。"""
s = self.scale
W, H = int(340 * s), int(562 * s)
self.setFixedSize(W, H)
self.bg_label.setGeometry(int(4 * s), int(120 * s), int(332 * s), int(404 * s))
self.bubble.setGeometry(int(6 * s), int(8 * s), int(328 * s), int(118 * s))
self.pet.setGeometry(int(10 * s), int(126 * s), int(320 * s), int(392 * s))
self.net_label.setGeometry(int(288 * s), int(128 * s), int(32 * s), int(32 * s))
self.clip_overlay.setGeometry(int(6 * s), int(122 * s), int(328 * s), int(44 * s))
# 事件覆盖层放在桌宠本体区域中部,绝不下探到底部输入框(y≈in_y)
ov_h = int(max(92, 122 * s))
self.event_overlay.setGeometry(int(10 * s), int(170 * s),
int(320 * s), ov_h)
# 底部相对布局:输入框(高度下限 22px)→ 状态行(高度下限 16px)
in_h = int(max(22, 30 * s))
in_y = H - in_h - max(2, int(4 * s))
self.input_frame.setGeometry(int(6 * s), in_y, int(328 * s), in_h)
st_h = int(max(16, 24 * s))
st_y = in_y - st_h - max(2, int(6 * s))
# 状态行字体随缩放(最小 7px),内边距同步缩放,保证不裁剪
fs = max(7, int(round(12 * s)))
pad = max(1, int(round(2 * s)))
def status_style(color):
return ("background:rgba(40,28,20,235);color:{};border-radius:{}px;"
"padding:{}px {}px;font-size:{}px;").format(
color, max(3, int(round(12 * s))), pad,
max(2, int(round(6 * s))), fs)
self.focus_label.setStyleSheet(status_style("#ffe9c9"))
self.weather_label.setStyleSheet(status_style("#ffffff"))
self.mode_label.setStyleSheet(status_style("#ffe9c9"))
# 事件覆盖层:外框 + 普通标签 + 两个选项按钮,字号随缩放
self.event_overlay.setStyleSheet(
"QFrame#event_overlay {{ background: rgba(30,20,14,238);"
" border: 1px solid rgba(214,170,120,120); border-radius: {}px; }}"
"QLabel {{ color: #fff6ea; background: transparent; font-size: {}px; }}"
.format(max(4, int(round(10 * s))), max(9, int(round(12 * s)))))
self.event_overlay.title_lab.setStyleSheet(
"color: #ffe9c9; background: transparent; font-size: {}px; font-weight: bold;"
.format(max(9, int(round(13 * s)))))
for _ovb in (self.event_overlay.btn_a, self.event_overlay.btn_b):
_ovb.setStyleSheet(
"QPushButton {{ background: #8c5a3c; color: #ffffff; border: none;"
" border-radius: {}px; padding: {}px {}px; font-size: {}px; }}"
"QPushButton:hover {{ background: #b58155; }}"
.format(max(4, int(round(8 * s))), max(1, int(round(3 * s))),
max(4, int(round(10 * s))), max(9, int(round(12 * s)))))
# 输入框与发送按钮字体随缩放
self.input.setStyleSheet(
"QLineEdit {{ background: transparent; border: none; color: #ffffff; font-size: {}px; }}"
.format(max(10, int(round(13 * s)))))
self.send_btn.setStyleSheet(
"QPushButton {{ background: #9c6b45; color: #ffffff; border: none;"
" border-radius: {}px; padding: {}px {}px; font-size: {}px; }}"
"QPushButton:hover {{ background: #b58155; }}"
.format(max(6, int(round(12 * s))), max(2, int(round(4 * s))),
max(4, int(round(14 * s))), max(9, int(round(12 * s)))))
compact = s < 0.7
if compact:
# 紧凑模式:隐藏好感度条,天气 + 接入状态并排
self.aff_bar.hide()
self.aff_bar.setGeometry(0, 0, 0, 0)
self._aff_was_visible = False
w_w = max(40, int(88 * s))
m_w = max(50, int(78 * s))
self.weather_label.setGeometry(int(6 * s), st_y, w_w, st_h)
mx = int(6 * s) + w_w + max(2, int(4 * s))
self.mode_label.setGeometry(mx, st_y, m_w, st_h)
# 专注时 focus 使用天气的位置(天气会隐藏),宽度截止到模式标签前,避免重叠
fw = max(30, mx - int(6 * s) - st_h - 4)
self.focus_label.setGeometry(int(6 * s), st_y, fw, st_h)
self.focus_cancel_btn.setGeometry(int(6 * s) + fw + 2, st_y, st_h, st_h)
else:
self.aff_bar.setGeometry(int(6 * s), st_y, int(150 * s), st_h)
fw = int(218 * s)
self.focus_label.setGeometry(int(6 * s), st_y, fw, st_h)
self.focus_cancel_btn.setGeometry(int(6 * s) + fw + 2, st_y, st_h, st_h)
self.weather_label.setGeometry(int(162 * s), st_y, int(88 * s), st_h)
self.mode_label.setGeometry(int(256 * s), st_y, int(78 * s), st_h)
# 好感度条内部:爱心字号、进度条宽度、内边距随缩放
self.aff_bar_lab.setStyleSheet(
"color:#ff8fa3;font-size:{}px;background:transparent;".format(fs))
self.aff_bar_prog.setFixedWidth(max(30, int(90 * s)))
lay = self.aff_bar.layout()
if lay is not None:
lay.setContentsMargins(max(4, int(8 * s)), max(1, int(2 * s)),
max(4, int(8 * s)), max(1, int(2 * s)))
self.load_images()
self._show(self._idle_file())
# ---------- 好感度 / 金币 / 心情 ----------
def _level_index(self, v):
idx = 0
for i, (mn, _, _) in enumerate(AFF_LEVELS):
if v >= mn:
idx = i
return idx
def _aff_level(self):
return self._level_index(self.affinity)
def _aff_level_name(self):
return AFF_LEVELS[self._level_index(self.affinity)]
def _update_aff_bar(self):
self.aff_bar_prog.setValue(self.affinity)
def add_affinity(self, n):
old_lv = self._aff_level()
self.affinity = max(0, self.affinity + n)
self.economy["affinity"] = self.affinity
save_json("economy.json", self.economy)
self._update_aff_bar()
new_lv = self._aff_level()
if new_lv > old_lv and new_lv in AFF_LINES:
self.say(AFF_LINES[new_lv], split=False)
self._show(self._idle_file())
def add_coins(self, n):
self.coins = max(0, self.coins + n)
self.economy["coins"] = self.coins
save_json("economy.json", self.economy)
def update_mood(self, delta, reason=""):
self.mood_value = max(0, min(100, self.mood_value + delta))
old = self.mood
if self.mood_value >= 70:
self.mood = "happy"
elif self.mood_value >= 40:
self.mood = "normal"
else:
self.mood = "sad"
if old != self.mood and not self.sleep_mode:
self.say({"happy": "(麟的心情变好啦!)", "normal": "(麟的心情平复了。)",
"sad": "(麟的心情有点低落…)"}[self.mood], split=False)
self.economy["mood"] = self.mood_value
save_json("economy.json", self.economy)
# ---------- 心理陪伴:情绪记录响应 ----------
def on_mood_recorded(self, rec):
"""用户保存情绪记录后,麟给出共情台词并播放对应表情"""
key = rec.get("mood", "neutral")
intensity = int(rec.get("intensity", 3))
reply = mood_reply(key)
if intensity >= 4:
reply = "({}/5){}".format(intensity, reply)
# 表情映射:负情绪 → 相应表情;开心 → 撒娇/摇尾巴
emo = None
if key in ("sad", "anxious", "lonely"):
emo = IMG_CRY if intensity >= 5 else IMG_SAD
elif key == "angry":
emo = IMG_ANGRY
elif key == "tired":
emo = IMG_YAWN
elif key in ("happy", "excited"):
emo = IMG_LOVE
if emo and os.path.exists(resource_path(emo)):
self.say(reply, split=False, on_done=self._emotion_after(emo, 1.5))
else:
self.say(reply, split=False)
if key in ("happy", "excited"):
QTimer.singleShot(2400, lambda: self.start_wag())
# 麟的心情随幸歌共情波动
if key in NEGATIVE_KEYS and intensity >= 3:
self.update_mood(-5, "共情幸歌的情绪")
elif key in ("happy", "excited"):
self.update_mood(5, "幸歌心情很好")
def on_assessment_done(self, result):
"""心理评测完成后,麟根据分档温柔回应(必要时提醒求助)"""
total = int(result.get("total", 0))
scale = result.get("scale_name", "心理评测")
level = result.get("level", "")
crisis = result.get("crisis")
category = result.get("category", "")
sev = result.get("severity")
if sev is None:
sev = (total / result.get("max", 1)) if result.get("max") else 0
if category == "人格性格" and not crisis:
reply = "({}:{})麟记住啦~性格没有好坏之分,这只是更了解自己的一小步。".format(
scale, level)
emo = IMG_HIGH if os.path.exists(resource_path(IMG_HIGH)) else IMG_LOVE
elif sev <= 0.2:
reply = "({}:{})状态不错哦~麟给你点赞,继续保持!".format(scale, level)
emo = IMG_LOVE
elif sev <= 0.45:
reply = "({}:{})这段时间辛苦了……多休息,多跟麟说说话,好不好?".format(
scale, level)
emo = IMG_SAD
else:
reply = "({}:{})幸歌,这个结果值得认真对待……麟会陪着你," \
"也建议你和信任的人聊聊,或寻求专业帮助。".format(scale, level)
emo = IMG_CRY if os.path.exists(resource_path(IMG_CRY)) else IMG_SAD
if crisis:
crisis_items = "、".join(result.get("crisis_items", []) or ["相关题目"])
reply += " 关于「{}」,如果此刻很难受,请一定联系信任的人或专业热线," \
"麟也会一直陪着你。".format(crisis_items)
if crisis or sev > 0.45:
reply += " 如果需要,随时可以跟麟说 #危机 查看心理援助热线。"
if emo and os.path.exists(resource_path(emo)):
self.say(reply, split=False, on_done=self._emotion_after(emo, 1.5))
else:
self.say(reply, split=False)
if sev > 0.45:
self.update_mood(-5, "担心幸歌的评测结果")
def _mood_care_check(self):
"""每天一次:若最近 7 天有高强度负情绪记录,麟主动关心一次"""
today = time.strftime("%Y-%m-%d")
data = load_moods()
if data.get("last_care_date") == today:
return
records = recent_records(data.get("records", []), 7)
heavy = [r for r in records
if r.get("mood") in NEGATIVE_KEYS
and int(r.get("intensity", 3)) >= 4]
if heavy and not self.sleep_mode and not self.game_mode:
data["last_care_date"] = today
save_moods(data)
self.say("(麟注意到你最近心情有些沉重……)幸歌,如果愿意的话,"
"可以跟麟说说,或者用 #情绪 记录一下今天的感觉,麟都会陪着你的。")
# ---------- Prompt 构建(稳定段进 system;易变段拼到本轮 user 尾部) ----------
def build_system_prompt(self):
"""只返回「稳定段」→ 作为唯一的 system 消息,逐字不变才能命中前缀缓存。
稳定段(按「几乎不变 → 偶尔变」排序):
御主档案 → 人设 → 睡眠段 → 排版规范 → 麟的记忆
易变段一律不放这里(见 build_volatile_prompt):
天气 / 幸歌情绪记录 / 心理评测 / 当前情绪 / 好感度 / 日期 / 在听什么
原因:好感度每轮 add_affinity(1) 都会变,它后面的一切(记忆、日期、
全部 history)都会被挤出缓存前缀。拆开后 system 整段稳定。
"""
prof = self.profile
parts = []
if prof and any(prof.values()):
parts.append("【御主档案】你的主人昵称:{};生日:{};他对你的称呼:{};你们的关系:{}".format(
prof.get("nickname", ""), prof.get("birthday", ""),
prof.get("how_to_call", ""), prof.get("relation", "")))
parts.append(self.current_prompt)
if self.sleep_mode:
parts.append(self._sleep_prompt_segment())
parts.append("【排版规范】当你需要输出较长内容(讲述故事、倾诉、详细解释)时:"
"按3~4个短句为一段,段与段之间只用单个换行;单句尽量不超过40字;"
"禁止超长无换行的句子与连续符号。短句日常对话保持简洁即可。")
mem_seg = self._memory_prompt_segment()
if mem_seg:
parts.append(mem_seg)
return "\n".join(x for x in parts if x)
def build_volatile_prompt(self):
"""只返回「易变段」:由 _build_chat_messages 拼到本轮最后一条 user 消息尾部。
注意:只拼进「发给 API 的那一份副本」,绝不写回 self.history,
也不进 chat_log.json —— 否则下一轮的缓存前缀会跟着变,白拆。
"""
parts = []
if self.weather:
parts.append("【当前天气】{}".format(self.weather))
# 心理陪伴:注入幸歌最近的情绪记录,让回复更有温度
moods = load_moods()
recs = moods.get("records", [])[-3:]
if recs:
seg = ";".join(
"{} {}({}/5){}".format(
(r.get("created_at") or "")[5:16],
mood_name(r.get("mood", "neutral")),
r.get("intensity", 3),
("[" + "、".join(r.get("tags", [])) + "]") if r.get("tags") else "")
for r in recs)
parts.append("【幸歌最近的情绪记录】{}。这只是背景参考(可能是很久前或一瞬间的记录),"
"不是现在的他。请温柔回应,不要指责、说教或强行讲道理;"
"**如果幸歌亲口说自己没事、开心、或不想聊这个话题,请完全相信并尊重他的表述,"
"顺着他的心情聊轻松的事,绝不要再追问、质疑或把话题拉回情绪——同一话题最多提一次。**".format(seg))
asm = recent_assessment()
if asm:
parts.append("【幸歌最近的心理评测】{}({}分,满分{})。请温和共情、不评判不说教;"
"若得分较高,可温柔建议其照顾好自己,必要时寻求专业帮助。".format(
asm.get("scale_name", ""), asm.get("total", 0), asm.get("max", 0)))
mood_txt = {"happy": "心情很好,说话要更活泼开朗",
"normal": "心情平稳",
"sad": "心情低落,回答要简短、带一点没精神的感觉"}[self.mood]
parts.append("【当前情绪】{}(数值{})。{}"
"注意:这是系统的粗略推测,**不是幸歌亲口说的**——如果他否认(说自己很好/没事),"
"请直接相信他,自然地聊下去,不要拿这个推测反复追问。".format(
mood_txt, self.mood_value,
"悲伤情绪下句子控制在30字内。" if self.mood == "sad" else ""))
lv = self._aff_level_name()
parts.append("【好感度】当前好感{},关系等级:{}({})。".format(self.affinity, lv[1], lv[2]))