dsh-plugin-xujie-memory 0.5.0 → 0.7.0

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 (84) hide show
  1. package/ACCEPTANCE.md +37 -0
  2. package/BUILD-PROVENANCE.json +53 -0
  3. package/CHANGELOG.md +40 -1
  4. package/LICENSE +21 -21
  5. package/README.md +67 -17
  6. package/README_EN.md +54 -8
  7. package/cordis.patch.yml +49 -49
  8. package/lib/client.js +17219 -0
  9. package/lib/controller.js +307 -0
  10. package/lib/index.js +200 -14
  11. package/lib/typert.host.d.ts +3 -0
  12. package/lib/typert.host.js +15104 -0
  13. package/lib/typert.remote-client.d.ts +40 -0
  14. package/lib/typert.remote-client.d.ts.map +1 -0
  15. package/lib/typert.remote-client.js +15093 -0
  16. package/lib/types.d.ts +13 -0
  17. package/lib/types.js +1 -0
  18. package/package.json +49 -5
  19. package/python/specs/characters/aloof.json +14 -14
  20. package/python/specs/characters/gentle.json +14 -14
  21. package/python/specs/characters/tsundere.json +14 -14
  22. package/python/specs/debt.v1.json +31 -31
  23. package/python/specs/nickname.v1.json +16 -16
  24. package/python/specs/preference.v1.json +16 -16
  25. package/python/xujie_memory/__init__.py +123 -123
  26. package/python/xujie_memory/admission.py +211 -211
  27. package/python/xujie_memory/audit.py +377 -377
  28. package/python/xujie_memory/benchmark.py +281 -281
  29. package/python/xujie_memory/character.py +199 -199
  30. package/python/xujie_memory/console.py +41 -41
  31. package/python/xujie_memory/consolidate.py +356 -356
  32. package/python/xujie_memory/due.py +121 -121
  33. package/python/xujie_memory/emotion.py +573 -573
  34. package/python/xujie_memory/engine.py +1333 -1312
  35. package/python/xujie_memory/errors.py +98 -98
  36. package/python/xujie_memory/importance.py +104 -104
  37. package/python/xujie_memory/judge.py +350 -350
  38. package/python/xujie_memory/management.py +203 -0
  39. package/python/xujie_memory/memory.py +71 -10
  40. package/python/xujie_memory/orchestrator.py +690 -666
  41. package/python/xujie_memory/phrasing.py +292 -292
  42. package/python/xujie_memory/privacy.py +105 -0
  43. package/python/xujie_memory/schema.sql +492 -480
  44. package/python/xujie_memory/server.py +76 -0
  45. package/python/xujie_memory/specs.py +170 -170
  46. package/python/xujie_memory/summary.py +238 -238
  47. package/scripts/build.mjs +19 -0
  48. package/scripts/prepack.mjs +9 -0
  49. package/scripts/test-all.mjs +9 -0
  50. package/scripts/test-node.mjs +10 -0
  51. package/src/client/MemoryPanel.tsx +536 -0
  52. package/src/client/NeuralField.tsx +88 -0
  53. package/src/client/css.d.ts +4 -0
  54. package/src/client/icons.tsx +28 -0
  55. package/src/client/index.tsx +59 -0
  56. package/src/client/memory-adapter.ts +67 -0
  57. package/src/client/model.ts +79 -0
  58. package/src/client/neural/engine.ts +999 -0
  59. package/src/client/neural/morphology.ts +219 -0
  60. package/src/client/panel.css +337 -0
  61. package/src/client/types.ts +6 -0
  62. package/src/controller.ts +88 -0
  63. package/src/generated/typert.host.js +449 -0
  64. package/src/generated/typert.remote-client.js +438 -0
  65. package/src/index.ts +2 -0
  66. package/src/types.ts +13 -0
  67. package/test/cordis-fixture.mjs +5 -0
  68. package/test/management_regression.py +187 -0
  69. package/test/node-regression.entry.mjs +52 -0
  70. package/vendor/DSH-LICENSE.txt +21 -0
  71. package/vendor/THIRD-PARTY.md +6 -0
  72. package/vendor/dsh-protocol/index.ts +484 -0
  73. package/vendor/dsh-protocol/json-value.ts +53 -0
  74. package/vendor/dsh-protocol/owned-value.ts +39 -0
  75. package/vendor/dsh-protocol/principal.ts +17 -0
  76. package/vendor/dsh-protocol/remote-access.ts +38 -0
  77. package/vendor/dsh-protocol/remote-error.ts +49 -0
  78. package/vendor/dsh-protocol/remote-info.ts +161 -0
  79. package/vendor/dsh-protocol/types.ts +939 -0
  80. package/vendor/licenses/jridgewell-gen-mapping-MIT.txt +19 -0
  81. package/vendor/licenses/jridgewell-resolve-uri-MIT.txt +19 -0
  82. package/vendor/licenses/jridgewell-sourcemap-codec-MIT.txt +19 -0
  83. package/vendor/licenses/jridgewell-trace-mapping-MIT.txt +19 -0
  84. package/vendor/licenses/zod-MIT.txt +21 -0
@@ -1,199 +1,199 @@
1
- """角色卡:性格怎么调节情绪、关系和行为。
2
-
3
- 方案第七章。最要紧的一句是 7.3:
4
-
5
- > 「傲娇」不意味着每句都说「哼」。
6
-
7
- 所以这一层**不生成台词**。它输出的是**倾向**:
8
-
9
- 现在会不会先开口
10
- 嘴上放不放过
11
- 要不要对方给个解释
12
-
13
- 台词由主 AI 根据这份倾向去写。把「哼」写进角色卡,
14
- 三个傲娇角色就会说同一句话——那不是角色,是模板。
15
-
16
- ---
17
-
18
- ## 角色卡调的是幅度,不是事实
19
-
20
- 这是这一层的边界,也是它安全的原因。
21
-
22
- 角色卡可以决定「同一句话,直率角色 +30,温和角色 +10」(6.3),
23
- 可以让气消得快一点或慢一点,可以在修复时多回一点信任。
24
-
25
- 但它**不能**让一件没发生的事发生,也不能让一件发生过的事消失。
26
- 一个温柔的角色被冒犯了,气可能只有 10——但那件事依然开着,
27
- 依然要有人把话说开。**性格决定反应,不决定事实。**
28
-
29
- ## 为什么用乘数而不是绝对值
30
-
31
- 因为绝对值会让角色卡变成"重新定义一遍情绪系统"。
32
- 写成 `anger_sensitivity = 1.5` 而不是 `anger = 60`,
33
- 意味着角色卡只描述"这个人比别人激烈多少"——
34
- 基准变了,角色跟着变,不会出现"角色卡里的数字和默认值打架"。
35
- """
36
-
37
- from __future__ import annotations
38
-
39
- import json
40
- from dataclasses import dataclass, field
41
- from pathlib import Path
42
-
43
-
44
- @dataclass(frozen=True)
45
- class CharacterSpec:
46
- """一个角色的设定。字段都是**调节量**,不是事实。"""
47
-
48
- name: str
49
- # 性格标签。只用于展示与检索,**不参与任何计算**。
50
- # 参与计算的永远是下面那些具体参数——
51
- # 否则"傲娇"就会变成一个写死的分支。
52
- persona: str = ""
53
-
54
- # ---- 情绪:同样的事,各人的反应幅度与消退速度不同(6.3)
55
- anger_sensitivity: float = 1.0 # 被冒犯时气多少的乘数
56
- anger_half_life_days: float | None = None # None = 用默认(半天)
57
- sadness_half_life_days: float | None = None
58
-
59
- # ---- 关系:掉得多快、修得多快
60
- trust_damage: float = 1.0 # 信任受损的乘数
61
- trust_repair: float = 1.0 # 修复时信任回升的乘数
62
-
63
- # ---- 行为倾向(不是台词)
64
- speak_first: bool = False # 有隔阂时会不会主动开口
65
- hold_words: bool = False # 气消了,嘴上还放不放过
66
- demand_explanation: bool = False # 要不要对方给个解释
67
-
68
- # ---- 表达强度。给主 AI 的一个软提示,不参与判定。
69
- intensity: str = "normal" # soft / normal / sharp
70
-
71
- def __post_init__(self) -> None:
72
- if self.anger_sensitivity <= 0 or self.trust_damage <= 0:
73
- raise ValueError(f"{self.name}: 乘数必须为正")
74
- if self.trust_repair <= 0:
75
- raise ValueError(f"{self.name}: 修复乘数必须为正")
76
-
77
-
78
- @dataclass
79
- class Stance:
80
- """此刻的行为倾向。**这不是台词,是台词该怎么写。**
81
-
82
- 分开的理由:7.4 那三个角色说的三句话,背后是**同一份状态**。
83
- 状态是算出来的,说法是写出来的。这一层只负责算。
84
- """
85
-
86
- tone: str = "normal" # cold / normal / warm / sharp
87
- speak_first: bool = False
88
- will_mention_it: bool = False
89
- needs_apology: bool = False
90
- reasons: list[str] = field(default_factory=list)
91
-
92
- def as_dict(self) -> dict:
93
- return {
94
- "tone": self.tone,
95
- "speak_first": self.speak_first,
96
- "will_mention_it": self.will_mention_it,
97
- "needs_apology": self.needs_apology,
98
- "reasons": list(self.reasons),
99
- }
100
-
101
-
102
- class CharacterRegistry:
103
- """从 `specs/characters/*.json` 读角色卡。"""
104
-
105
- def __init__(self, specs: dict[str, CharacterSpec]):
106
- self._specs = specs
107
-
108
- @classmethod
109
- def from_dir(cls, path: str | Path) -> "CharacterRegistry":
110
- specs: dict[str, CharacterSpec] = {}
111
- for f in sorted(Path(path).glob("*.json")):
112
- data = json.loads(f.read_text(encoding="utf-8"))
113
- spec = CharacterSpec(
114
- name=data["name"],
115
- persona=data.get("persona", ""),
116
- anger_sensitivity=float(data.get("anger_sensitivity", 1.0)),
117
- anger_half_life_days=data.get("anger_half_life_days"),
118
- sadness_half_life_days=data.get("sadness_half_life_days"),
119
- trust_damage=float(data.get("trust_damage", 1.0)),
120
- trust_repair=float(data.get("trust_repair", 1.0)),
121
- speak_first=bool(data.get("speak_first", False)),
122
- hold_words=bool(data.get("hold_words", False)),
123
- demand_explanation=bool(data.get("demand_explanation", False)),
124
- intensity=data.get("intensity", "normal"),
125
- )
126
- specs[spec.name] = spec
127
- return cls(specs)
128
-
129
- def get(self, name: str) -> CharacterSpec:
130
- if name not in self._specs:
131
- raise KeyError(f"没有这个角色: {name}")
132
- return self._specs[name]
133
-
134
- def all(self) -> list[CharacterSpec]:
135
- return list(self._specs.values())
136
-
137
-
138
- def stance(
139
- character: CharacterSpec,
140
- *,
141
- anger: float,
142
- trust: float,
143
- closeness: float,
144
- open_grievances: int,
145
- ) -> Stance:
146
- """此刻这个角色大概会怎么做。
147
-
148
- 只看四样东西:还有多气、信任多少、多亲近、有没有事没说开。
149
- 这四样都是**别的层算出来的**,角色卡不重新定义它们。
150
-
151
- 判断顺序本身就是立场:
152
-
153
- 1. 还在气头上 → 谁都不主动,嘴上也不客气
154
- 2. 气消了但事没关 → 这才是 7.4 那三个角色真正分岔的地方
155
- 3. 事关了 → 正常
156
- """
157
- st = Stance()
158
-
159
- # (1) 气还没消。这时候性格差异会被压平——
160
- # 再温柔的人,正在气头上也不会主动递台阶。
161
- if anger >= 40:
162
- st.tone = "sharp" if character.hold_words or character.intensity == "sharp" else "cold"
163
- st.speak_first = False
164
- st.will_mention_it = True
165
- st.needs_apology = True
166
- st.reasons.append(f"还在生气({anger:.0f})")
167
- return st
168
-
169
- # (2) 气消了,但还有事没说开。**7.4 就是这一段。**
170
- if open_grievances > 0:
171
- st.will_mention_it = True
172
- st.needs_apology = character.demand_explanation or closeness < 40
173
-
174
- if character.speak_first:
175
- # 温柔:愿意主动把话说开
176
- st.tone = "warm"
177
- st.speak_first = True
178
- st.reasons.append("气消了,还有事没说开;这个角色愿意先开口")
179
- elif character.hold_words:
180
- # 傲娇:心里不怎么生气了,嘴上不放过
181
- st.tone = "sharp"
182
- st.speak_first = False
183
- st.reasons.append("气消了,还有事没说开;嘴上不肯先软")
184
- else:
185
- # 高冷:气消了,隔阂还在,等对方开口
186
- st.tone = "cold"
187
- st.speak_first = False
188
- st.reasons.append("气消了,还有事没说开;不会主动提")
189
-
190
- # 气虽然消了,但如果信任已经很低,傲娇的"嘴硬"会变成真的疏远
191
- if trust < 30 and character.hold_words:
192
- st.reasons.append("信任已经很低,嘴硬不再只是嘴硬")
193
- return st
194
-
195
- # (3) 没事了。
196
- st.tone = "warm" if closeness >= 50 else "normal"
197
- st.speak_first = character.speak_first
198
- st.reasons.append("没有未了结的事")
199
- return st
1
+ """角色卡:性格怎么调节情绪、关系和行为。
2
+
3
+ 方案第七章。最要紧的一句是 7.3:
4
+
5
+ > 「傲娇」不意味着每句都说「哼」。
6
+
7
+ 所以这一层**不生成台词**。它输出的是**倾向**:
8
+
9
+ 现在会不会先开口
10
+ 嘴上放不放过
11
+ 要不要对方给个解释
12
+
13
+ 台词由主 AI 根据这份倾向去写。把「哼」写进角色卡,
14
+ 三个傲娇角色就会说同一句话——那不是角色,是模板。
15
+
16
+ ---
17
+
18
+ ## 角色卡调的是幅度,不是事实
19
+
20
+ 这是这一层的边界,也是它安全的原因。
21
+
22
+ 角色卡可以决定「同一句话,直率角色 +30,温和角色 +10」(6.3),
23
+ 可以让气消得快一点或慢一点,可以在修复时多回一点信任。
24
+
25
+ 但它**不能**让一件没发生的事发生,也不能让一件发生过的事消失。
26
+ 一个温柔的角色被冒犯了,气可能只有 10——但那件事依然开着,
27
+ 依然要有人把话说开。**性格决定反应,不决定事实。**
28
+
29
+ ## 为什么用乘数而不是绝对值
30
+
31
+ 因为绝对值会让角色卡变成"重新定义一遍情绪系统"。
32
+ 写成 `anger_sensitivity = 1.5` 而不是 `anger = 60`,
33
+ 意味着角色卡只描述"这个人比别人激烈多少"——
34
+ 基准变了,角色跟着变,不会出现"角色卡里的数字和默认值打架"。
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ from dataclasses import dataclass, field
41
+ from pathlib import Path
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class CharacterSpec:
46
+ """一个角色的设定。字段都是**调节量**,不是事实。"""
47
+
48
+ name: str
49
+ # 性格标签。只用于展示与检索,**不参与任何计算**。
50
+ # 参与计算的永远是下面那些具体参数——
51
+ # 否则"傲娇"就会变成一个写死的分支。
52
+ persona: str = ""
53
+
54
+ # ---- 情绪:同样的事,各人的反应幅度与消退速度不同(6.3)
55
+ anger_sensitivity: float = 1.0 # 被冒犯时气多少的乘数
56
+ anger_half_life_days: float | None = None # None = 用默认(半天)
57
+ sadness_half_life_days: float | None = None
58
+
59
+ # ---- 关系:掉得多快、修得多快
60
+ trust_damage: float = 1.0 # 信任受损的乘数
61
+ trust_repair: float = 1.0 # 修复时信任回升的乘数
62
+
63
+ # ---- 行为倾向(不是台词)
64
+ speak_first: bool = False # 有隔阂时会不会主动开口
65
+ hold_words: bool = False # 气消了,嘴上还放不放过
66
+ demand_explanation: bool = False # 要不要对方给个解释
67
+
68
+ # ---- 表达强度。给主 AI 的一个软提示,不参与判定。
69
+ intensity: str = "normal" # soft / normal / sharp
70
+
71
+ def __post_init__(self) -> None:
72
+ if self.anger_sensitivity <= 0 or self.trust_damage <= 0:
73
+ raise ValueError(f"{self.name}: 乘数必须为正")
74
+ if self.trust_repair <= 0:
75
+ raise ValueError(f"{self.name}: 修复乘数必须为正")
76
+
77
+
78
+ @dataclass
79
+ class Stance:
80
+ """此刻的行为倾向。**这不是台词,是台词该怎么写。**
81
+
82
+ 分开的理由:7.4 那三个角色说的三句话,背后是**同一份状态**。
83
+ 状态是算出来的,说法是写出来的。这一层只负责算。
84
+ """
85
+
86
+ tone: str = "normal" # cold / normal / warm / sharp
87
+ speak_first: bool = False
88
+ will_mention_it: bool = False
89
+ needs_apology: bool = False
90
+ reasons: list[str] = field(default_factory=list)
91
+
92
+ def as_dict(self) -> dict:
93
+ return {
94
+ "tone": self.tone,
95
+ "speak_first": self.speak_first,
96
+ "will_mention_it": self.will_mention_it,
97
+ "needs_apology": self.needs_apology,
98
+ "reasons": list(self.reasons),
99
+ }
100
+
101
+
102
+ class CharacterRegistry:
103
+ """从 `specs/characters/*.json` 读角色卡。"""
104
+
105
+ def __init__(self, specs: dict[str, CharacterSpec]):
106
+ self._specs = specs
107
+
108
+ @classmethod
109
+ def from_dir(cls, path: str | Path) -> "CharacterRegistry":
110
+ specs: dict[str, CharacterSpec] = {}
111
+ for f in sorted(Path(path).glob("*.json")):
112
+ data = json.loads(f.read_text(encoding="utf-8"))
113
+ spec = CharacterSpec(
114
+ name=data["name"],
115
+ persona=data.get("persona", ""),
116
+ anger_sensitivity=float(data.get("anger_sensitivity", 1.0)),
117
+ anger_half_life_days=data.get("anger_half_life_days"),
118
+ sadness_half_life_days=data.get("sadness_half_life_days"),
119
+ trust_damage=float(data.get("trust_damage", 1.0)),
120
+ trust_repair=float(data.get("trust_repair", 1.0)),
121
+ speak_first=bool(data.get("speak_first", False)),
122
+ hold_words=bool(data.get("hold_words", False)),
123
+ demand_explanation=bool(data.get("demand_explanation", False)),
124
+ intensity=data.get("intensity", "normal"),
125
+ )
126
+ specs[spec.name] = spec
127
+ return cls(specs)
128
+
129
+ def get(self, name: str) -> CharacterSpec:
130
+ if name not in self._specs:
131
+ raise KeyError(f"没有这个角色: {name}")
132
+ return self._specs[name]
133
+
134
+ def all(self) -> list[CharacterSpec]:
135
+ return list(self._specs.values())
136
+
137
+
138
+ def stance(
139
+ character: CharacterSpec,
140
+ *,
141
+ anger: float,
142
+ trust: float,
143
+ closeness: float,
144
+ open_grievances: int,
145
+ ) -> Stance:
146
+ """此刻这个角色大概会怎么做。
147
+
148
+ 只看四样东西:还有多气、信任多少、多亲近、有没有事没说开。
149
+ 这四样都是**别的层算出来的**,角色卡不重新定义它们。
150
+
151
+ 判断顺序本身就是立场:
152
+
153
+ 1. 还在气头上 → 谁都不主动,嘴上也不客气
154
+ 2. 气消了但事没关 → 这才是 7.4 那三个角色真正分岔的地方
155
+ 3. 事关了 → 正常
156
+ """
157
+ st = Stance()
158
+
159
+ # (1) 气还没消。这时候性格差异会被压平——
160
+ # 再温柔的人,正在气头上也不会主动递台阶。
161
+ if anger >= 40:
162
+ st.tone = "sharp" if character.hold_words or character.intensity == "sharp" else "cold"
163
+ st.speak_first = False
164
+ st.will_mention_it = True
165
+ st.needs_apology = True
166
+ st.reasons.append(f"还在生气({anger:.0f})")
167
+ return st
168
+
169
+ # (2) 气消了,但还有事没说开。**7.4 就是这一段。**
170
+ if open_grievances > 0:
171
+ st.will_mention_it = True
172
+ st.needs_apology = character.demand_explanation or closeness < 40
173
+
174
+ if character.speak_first:
175
+ # 温柔:愿意主动把话说开
176
+ st.tone = "warm"
177
+ st.speak_first = True
178
+ st.reasons.append("气消了,还有事没说开;这个角色愿意先开口")
179
+ elif character.hold_words:
180
+ # 傲娇:心里不怎么生气了,嘴上不放过
181
+ st.tone = "sharp"
182
+ st.speak_first = False
183
+ st.reasons.append("气消了,还有事没说开;嘴上不肯先软")
184
+ else:
185
+ # 高冷:气消了,隔阂还在,等对方开口
186
+ st.tone = "cold"
187
+ st.speak_first = False
188
+ st.reasons.append("气消了,还有事没说开;不会主动提")
189
+
190
+ # 气虽然消了,但如果信任已经很低,傲娇的"嘴硬"会变成真的疏远
191
+ if trust < 30 and character.hold_words:
192
+ st.reasons.append("信任已经很低,嘴硬不再只是嘴硬")
193
+ return st
194
+
195
+ # (3) 没事了。
196
+ st.tone = "warm" if closeness >= 50 else "normal"
197
+ st.speak_first = character.speak_first
198
+ st.reasons.append("没有未了结的事")
199
+ return st
@@ -1,41 +1,41 @@
1
- """Windows 控制台的编码兜底。
2
-
3
- 演示文本里有 `−`(U+2212)、`⚠`、`✓`、`█` 这类字符。中文 Windows 的控制台
4
- 默认是 GBK(cp936),编不出来的字符会让 `print` 抛 `UnicodeEncodeError`——
5
- 而且通常发生在演示中段,看起来像逻辑坏了,其实是输出编码。
6
-
7
- 这里只放宽**错误处理**(`errors="replace"`),不改编码:
8
-
9
- cmd / PowerShell(cp936) 中文照常显示,个别符号退化成 `?`
10
- 已是 UTF-8 的终端 不受影响(Linux、CI、chcp 65001)
11
-
12
- **不要把它做成 import 副作用**:库被谁 import 都可能,改全局 stdout 是调用方的事。
13
- """
14
-
15
- from __future__ import annotations
16
-
17
- import sys
18
-
19
- _UTF8 = {"utf-8", "utf8", "utf-16", "utf-16-le", "utf-16-be", "utf-32"}
20
-
21
-
22
- def make_console_safe() -> bool:
23
- """让 stdout/stderr 遇到编不出的字符时退化,而不是抛异常。
24
-
25
- 返回是否真的动了标准流(已是 UTF-8 时返回 False)。
26
- """
27
- changed = False
28
- for stream in (sys.stdout, sys.stderr):
29
- if stream is None or not hasattr(stream, "reconfigure"):
30
- continue
31
- encoding = (getattr(stream, "encoding", "") or "").lower()
32
- if encoding in _UTF8:
33
- continue
34
- try:
35
- stream.reconfigure(errors="replace")
36
- except (ValueError, OSError):
37
- # 被别的框架接管过的流可能不允许重新配置——那就保持原样,
38
- # 宁可少一层保护,也不要在这里把演示弄挂。
39
- continue
40
- changed = True
41
- return changed
1
+ """Windows 控制台的编码兜底。
2
+
3
+ 演示文本里有 `−`(U+2212)、`⚠`、`✓`、`█` 这类字符。中文 Windows 的控制台
4
+ 默认是 GBK(cp936),编不出来的字符会让 `print` 抛 `UnicodeEncodeError`——
5
+ 而且通常发生在演示中段,看起来像逻辑坏了,其实是输出编码。
6
+
7
+ 这里只放宽**错误处理**(`errors="replace"`),不改编码:
8
+
9
+ cmd / PowerShell(cp936) 中文照常显示,个别符号退化成 `?`
10
+ 已是 UTF-8 的终端 不受影响(Linux、CI、chcp 65001)
11
+
12
+ **不要把它做成 import 副作用**:库被谁 import 都可能,改全局 stdout 是调用方的事。
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import sys
18
+
19
+ _UTF8 = {"utf-8", "utf8", "utf-16", "utf-16-le", "utf-16-be", "utf-32"}
20
+
21
+
22
+ def make_console_safe() -> bool:
23
+ """让 stdout/stderr 遇到编不出的字符时退化,而不是抛异常。
24
+
25
+ 返回是否真的动了标准流(已是 UTF-8 时返回 False)。
26
+ """
27
+ changed = False
28
+ for stream in (sys.stdout, sys.stderr):
29
+ if stream is None or not hasattr(stream, "reconfigure"):
30
+ continue
31
+ encoding = (getattr(stream, "encoding", "") or "").lower()
32
+ if encoding in _UTF8:
33
+ continue
34
+ try:
35
+ stream.reconfigure(errors="replace")
36
+ except (ValueError, OSError):
37
+ # 被别的框架接管过的流可能不允许重新配置——那就保持原样,
38
+ # 宁可少一层保护,也不要在这里把演示弄挂。
39
+ continue
40
+ changed = True
41
+ return changed