uparlor 0.1.0__tar.gz

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 (59) hide show
  1. uparlor-0.1.0/PKG-INFO +8 -0
  2. uparlor-0.1.0/client/__init__.py +0 -0
  3. uparlor-0.1.0/client/ai/__init__.py +6 -0
  4. uparlor-0.1.0/client/ai/attention.py +285 -0
  5. uparlor-0.1.0/client/ai/character.py +172 -0
  6. uparlor-0.1.0/client/ai/cognitive.py +48 -0
  7. uparlor-0.1.0/client/ai/config.py +159 -0
  8. uparlor-0.1.0/client/ai/impression.py +50 -0
  9. uparlor-0.1.0/client/ai/memory.py +69 -0
  10. uparlor-0.1.0/client/ai/mood.py +114 -0
  11. uparlor-0.1.0/client/ai/persona.py +23 -0
  12. uparlor-0.1.0/client/ai/service.py +708 -0
  13. uparlor-0.1.0/client/ai/social.py +118 -0
  14. uparlor-0.1.0/client/ai/social_config.json +20 -0
  15. uparlor-0.1.0/client/app.py +237 -0
  16. uparlor-0.1.0/client/config.py +63 -0
  17. uparlor-0.1.0/client/net/__init__.py +0 -0
  18. uparlor-0.1.0/client/net/connection.py +63 -0
  19. uparlor-0.1.0/client/net/dispatch.py +172 -0
  20. uparlor-0.1.0/client/net/messages.py +120 -0
  21. uparlor-0.1.0/client/panels/__init__.py +28 -0
  22. uparlor-0.1.0/client/panels/ai_chat.py +1175 -0
  23. uparlor-0.1.0/client/panels/chat.py +128 -0
  24. uparlor-0.1.0/client/panels/command.py +301 -0
  25. uparlor-0.1.0/client/panels/game_board.py +51 -0
  26. uparlor-0.1.0/client/panels/inventory.py +398 -0
  27. uparlor-0.1.0/client/panels/login.py +30 -0
  28. uparlor-0.1.0/client/panels/online.py +40 -0
  29. uparlor-0.1.0/client/panels/status.py +46 -0
  30. uparlor-0.1.0/client/panels/which_key.py +111 -0
  31. uparlor-0.1.0/client/protocol/__init__.py +0 -0
  32. uparlor-0.1.0/client/protocol/commands.py +71 -0
  33. uparlor-0.1.0/client/protocol/handler.py +91 -0
  34. uparlor-0.1.0/client/protocol/renderer.py +40 -0
  35. uparlor-0.1.0/client/registry.py +59 -0
  36. uparlor-0.1.0/client/state.py +248 -0
  37. uparlor-0.1.0/client/theme.tcss +271 -0
  38. uparlor-0.1.0/client/ui/__init__.py +31 -0
  39. uparlor-0.1.0/client/ui/canvas.py +168 -0
  40. uparlor-0.1.0/client/ui/input_handler.py +206 -0
  41. uparlor-0.1.0/client/ui/keyboard.py +169 -0
  42. uparlor-0.1.0/client/ui/layout.py +297 -0
  43. uparlor-0.1.0/client/ui/screen.py +427 -0
  44. uparlor-0.1.0/client/ui/space_menu.py +83 -0
  45. uparlor-0.1.0/client/ui/vim_mode.py +43 -0
  46. uparlor-0.1.0/client/widgets/__init__.py +20 -0
  47. uparlor-0.1.0/client/widgets/helpers.py +11 -0
  48. uparlor-0.1.0/client/widgets/input_bar.py +165 -0
  49. uparlor-0.1.0/client/widgets/prompt.py +23 -0
  50. uparlor-0.1.0/client/widgets/scrollbar.py +63 -0
  51. uparlor-0.1.0/client/widgets/tab_menu.py +305 -0
  52. uparlor-0.1.0/pyproject.toml +23 -0
  53. uparlor-0.1.0/setup.cfg +4 -0
  54. uparlor-0.1.0/uparlor.egg-info/PKG-INFO +8 -0
  55. uparlor-0.1.0/uparlor.egg-info/SOURCES.txt +57 -0
  56. uparlor-0.1.0/uparlor.egg-info/dependency_links.txt +1 -0
  57. uparlor-0.1.0/uparlor.egg-info/entry_points.txt +2 -0
  58. uparlor-0.1.0/uparlor.egg-info/requires.txt +2 -0
  59. uparlor-0.1.0/uparlor.egg-info/top_level.txt +1 -0
uparlor-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: uparlor
3
+ Version: 0.1.0
4
+ Summary: UParlor — 终端游戏厅 TUI 客户端
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: textual>=0.40
8
+ Requires-Dist: google-genai>=1.0
File without changes
@@ -0,0 +1,6 @@
1
+ """ai 包 — AI 伙伴系统"""
2
+
3
+ from .service import AIService
4
+ from .character import Character
5
+
6
+ __all__ = ["AIService", "Character"]
@@ -0,0 +1,285 @@
1
+ """AI 注意力系统 — Function Calling 驱动的选择性感知"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import deque
6
+ from typing import TYPE_CHECKING
7
+
8
+ from google.genai import types
9
+
10
+ if TYPE_CHECKING:
11
+ from ..state import ModuleStateManager
12
+
13
+ # ── 常量 ──
14
+
15
+ _MAX_CHAT_LINES = 15
16
+ _MAX_ONLINE_USERS = 20
17
+ _MAX_INVENTORY_ITEMS = 10
18
+ _MAX_GAME_DESC = 200
19
+
20
+
21
+ # ── 工具函数 ──
22
+
23
+ def look_chat(state: ModuleStateManager, **_kw) -> str:
24
+ """返回最近公聊消息"""
25
+ try:
26
+ entries = state.chat.entries[-_MAX_CHAT_LINES:]
27
+ lines = []
28
+ for e in entries:
29
+ if len(e) >= 4 and e[0] == "msg":
30
+ lines.append(f"{e[1]}> {e[2]}")
31
+ elif e[0] == "sys":
32
+ lines.append(f"[系统] {e[1]}")
33
+ return "\n".join(lines) if lines else "聊天室暂无消息"
34
+ except Exception:
35
+ return "无法查看聊天室"
36
+
37
+
38
+ def look_online(state: ModuleStateManager, **_kw) -> str:
39
+ """返回在线用户列表"""
40
+ try:
41
+ users = state.online.users
42
+ if not users:
43
+ return "当前没有在线用户"
44
+ names = []
45
+ for u in users[:_MAX_ONLINE_USERS]:
46
+ if isinstance(u, dict):
47
+ name = u.get("name", "?")
48
+ level = u.get("level", "")
49
+ names.append(f"{name}(Lv.{level})" if level else name)
50
+ else:
51
+ names.append(str(u))
52
+ result = f"在线 {len(users)} 人: " + ", ".join(names)
53
+ if len(users) > _MAX_ONLINE_USERS:
54
+ result += f" ...等共{len(users)}人"
55
+ return result
56
+ except Exception:
57
+ return "无法查看在线用户"
58
+
59
+
60
+ def look_inventory(state: ModuleStateManager, **_kw) -> str:
61
+ """返回背包物品"""
62
+ try:
63
+ inv = state.inventory
64
+ if not inv.items:
65
+ return f"背包为空。金币: {inv.gold}G"
66
+ lines = [f"{it['name']} x{it['count']}" +
67
+ (f" - {it['desc']}" if it.get("desc") else "")
68
+ for it in inv.items[:_MAX_INVENTORY_ITEMS]]
69
+ result = "\n".join(lines)
70
+ if inv.gold:
71
+ result += f"\n金币: {inv.gold}G"
72
+ return result
73
+ except Exception:
74
+ return "无法查看背包"
75
+
76
+
77
+ def look_game_room(state: ModuleStateManager, **_kw) -> str:
78
+ """返回当前游戏房间状态"""
79
+ try:
80
+ rd = state.game_board.room_data
81
+ if not rd:
82
+ return "当前不在任何游戏房间"
83
+ parts = []
84
+ game = rd.get("game_type") or rd.get("game", "")
85
+ if game:
86
+ parts.append(f"游戏: {game}")
87
+ players = rd.get("players")
88
+ if players:
89
+ if isinstance(players, dict):
90
+ pnames = list(players.values())
91
+ elif isinstance(players, list):
92
+ pnames = [p.get("name", "?") if isinstance(p, dict) else str(p)
93
+ for p in players]
94
+ else:
95
+ pnames = [str(players)]
96
+ parts.append(f"玩家: {', '.join(str(n) for n in pnames)}")
97
+ status = rd.get("state") or rd.get("status", "")
98
+ if status:
99
+ parts.append(f"状态: {status}")
100
+ # 优先: 客户端 handler 的 ai_describe 方法
101
+ if game:
102
+ from ..protocol.handler import get_handler
103
+ handler = get_handler(game)
104
+ if handler and hasattr(handler, 'ai_describe'):
105
+ try:
106
+ desc = handler.ai_describe(rd)
107
+ if desc:
108
+ parts.append(desc[:_MAX_GAME_DESC])
109
+ return " | ".join(parts)
110
+ except Exception:
111
+ pass
112
+ # 回退: 服务端 room_data 中的 ai_summary
113
+ ai_summary = rd.get("ai_summary")
114
+ if ai_summary:
115
+ parts.append(f"详情: {str(ai_summary)[:_MAX_GAME_DESC]}")
116
+ return " | ".join(parts) if parts else "游戏房间信息不完整"
117
+ except Exception:
118
+ return "无法查看游戏房间"
119
+
120
+
121
+ def look_player_status(state: ModuleStateManager, **_kw) -> str:
122
+ """返回玩家详细状态"""
123
+ try:
124
+ pd = state.status.player_data
125
+ if not pd:
126
+ return "未获取到玩家状态"
127
+ lines = []
128
+ for key, label in [("name", "名字"), ("level", "等级"),
129
+ ("gold", "金币"), ("title", "称号")]:
130
+ val = pd.get(key, "")
131
+ if val not in ("", None, 0):
132
+ lines.append(f"{label}: {val}")
133
+ # game_stats(由 send_player_status 传输)
134
+ gs = pd.get("game_stats")
135
+ if isinstance(gs, dict):
136
+ tw = gs.get("total_wins", 0)
137
+ tl = gs.get("total_losses", 0)
138
+ td = gs.get("total_draws", 0)
139
+ if tw or tl or td:
140
+ lines.append(f"战绩: {tw}胜 {tl}负 {td}平")
141
+ return "\n".join(lines) if lines else "玩家状态为空"
142
+ except Exception:
143
+ return "无法查看玩家状态"
144
+
145
+
146
+ def look_around(state: ModuleStateManager, **_kw) -> str:
147
+ """综合环境感知 — 位置 + 房间 + 周围人"""
148
+ parts = []
149
+ if state.location:
150
+ parts.append(f"位置: {state.location}")
151
+ parts.append(look_online(state))
152
+ room = look_game_room(state)
153
+ if "不在任何" not in room:
154
+ parts.append(room)
155
+ return "\n".join(parts) if parts else "周围没什么特别的"
156
+
157
+
158
+ # ── 工具注册表 ──
159
+
160
+ TOOLS = {
161
+ "look_chat": look_chat,
162
+ "look_online": look_online,
163
+ "look_inventory": look_inventory,
164
+ "look_game_room": look_game_room,
165
+ "look_player_status": look_player_status,
166
+ "look_around": look_around,
167
+ }
168
+
169
+ # ── Gemini Function Declarations ──
170
+
171
+ TOOL_DECLARATIONS = [
172
+ types.FunctionDeclaration(
173
+ name="look_chat",
174
+ description="查看聊天室最近的公共聊天消息",
175
+ parameters_json_schema={},
176
+ ),
177
+ types.FunctionDeclaration(
178
+ name="look_online",
179
+ description="查看当前在线的用户列表",
180
+ parameters_json_schema={},
181
+ ),
182
+ types.FunctionDeclaration(
183
+ name="look_inventory",
184
+ description="查看玩家的背包物品和金币",
185
+ parameters_json_schema={},
186
+ ),
187
+ types.FunctionDeclaration(
188
+ name="look_game_room",
189
+ description="查看当前游戏房间的状态、玩家和进度",
190
+ parameters_json_schema={},
191
+ ),
192
+ types.FunctionDeclaration(
193
+ name="look_player_status",
194
+ description="查看玩家的详细状态信息(等级、金币、段位等)",
195
+ parameters_json_schema={},
196
+ ),
197
+ types.FunctionDeclaration(
198
+ name="look_around",
199
+ description="环顾四周,了解当前位置、在线用户和游戏房间情况",
200
+ parameters_json_schema={},
201
+ ),
202
+ ]
203
+
204
+ GEMINI_TOOLS = [types.Tool(function_declarations=TOOL_DECLARATIONS)]
205
+
206
+
207
+ # ── 感知摘要 ──
208
+
209
+ class AwarenessSummary:
210
+ """生成一行极简环境摘要(~30 token)"""
211
+
212
+ @staticmethod
213
+ def build(state: ModuleStateManager, buffer: AttentionBuffer | None = None) -> str:
214
+ parts = []
215
+
216
+ # 位置
217
+ if state.location:
218
+ parts.append(state.location)
219
+
220
+ # 在线人数
221
+ try:
222
+ n = len(state.online.users)
223
+ if n:
224
+ parts.append(f"{n}人在线")
225
+ except Exception:
226
+ pass
227
+
228
+ # 公聊消息数
229
+ try:
230
+ chat_count = sum(1 for e in state.chat.entries if e[0] == "msg")
231
+ if chat_count:
232
+ parts.append(f"聊天室有{chat_count}条消息")
233
+ except Exception:
234
+ pass
235
+
236
+ # 背包
237
+ try:
238
+ item_count = len(state.inventory.items)
239
+ if item_count:
240
+ parts.append(f"背包{item_count}件物品")
241
+ except Exception:
242
+ pass
243
+
244
+ # 游戏房间
245
+ try:
246
+ if state.game_board.room_data:
247
+ rd = state.game_board.room_data
248
+ game = rd.get("game_type") or rd.get("game", "某游戏")
249
+ parts.append(f"在{game}房间中")
250
+ except Exception:
251
+ pass
252
+
253
+ summary = " | ".join(parts) if parts else "大厅"
254
+
255
+ # 事件缓冲
256
+ if buffer:
257
+ events = buffer.drain()
258
+ if events:
259
+ summary += "\n刚才: " + "; ".join(events)
260
+
261
+ return summary
262
+
263
+
264
+ # ── 事件缓冲区 ──
265
+
266
+ class AttentionBuffer:
267
+ """高优先级事件缓冲区 — 不需要 AI 主动查看,直接注入感知"""
268
+
269
+ def __init__(self, maxlen: int = 10):
270
+ self._events: deque[str] = deque(maxlen=maxlen)
271
+
272
+ def push(self, event: str):
273
+ self._events.append(event)
274
+
275
+ def drain(self) -> list[str]:
276
+ """取出所有事件并清空"""
277
+ events = list(self._events)
278
+ self._events.clear()
279
+ return events
280
+
281
+ def __len__(self) -> int:
282
+ return len(self._events)
283
+
284
+ def __bool__(self) -> bool:
285
+ return bool(self._events)
@@ -0,0 +1,172 @@
1
+ """角色管理 — 创建、加载、保存、列表"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ from dataclasses import dataclass, field, asdict
8
+ from pathlib import Path
9
+
10
+ from .config import (
11
+ char_dir, ensure_char_dir, list_character_ids,
12
+ load_json, save_json,
13
+ )
14
+
15
+
16
+ @dataclass
17
+ class Character:
18
+ id: str
19
+ name: str = ""
20
+ personality: str = ""
21
+ speech_style: str = ""
22
+ appearance: str = ""
23
+ backstory: str = ""
24
+ custom_rules: list[str] = field(default_factory=list)
25
+ created_at: float = 0.0
26
+
27
+
28
+ @dataclass
29
+ class CharacterSummary:
30
+ id: str
31
+ name: str
32
+
33
+
34
+ def _profile_path(char_id: str) -> Path:
35
+ return char_dir(char_id) / "profile.json"
36
+
37
+
38
+ def load_character(char_id: str) -> Character | None:
39
+ data = load_json(_profile_path(char_id), None)
40
+ if data is None:
41
+ return None
42
+ return Character(
43
+ id=char_id,
44
+ name=data.get("name", ""),
45
+ personality=data.get("personality", ""),
46
+ speech_style=data.get("speech_style", ""),
47
+ appearance=data.get("appearance", ""),
48
+ backstory=data.get("backstory", ""),
49
+ custom_rules=data.get("custom_rules", []),
50
+ created_at=data.get("created_at", 0.0),
51
+ )
52
+
53
+
54
+ def save_character(char: Character):
55
+ ensure_char_dir(char.id)
56
+ save_json(_profile_path(char.id), asdict(char))
57
+
58
+
59
+ def list_characters() -> list[CharacterSummary]:
60
+ result = []
61
+ for cid in list_character_ids():
62
+ data = load_json(_profile_path(cid), {})
63
+ result.append(CharacterSummary(id=cid, name=data.get("name", cid)))
64
+ return result
65
+
66
+
67
+ def generate_char_id() -> str:
68
+ """生成唯一角色 ID"""
69
+ return f"char_{int(time.time())}"
70
+
71
+
72
+ # ── Gemini 结构化提取 ──
73
+
74
+ _STRUCTURIZE_PROMPT = """\
75
+ 将用户的自由角色描述提取为 JSON 人设。
76
+ 输出格式(只输出 JSON,不要其他文字):
77
+ {
78
+ "name": "角色名字",
79
+ "personality": "性格特点",
80
+ "speech_style": "说话风格",
81
+ "appearance": "外貌描述",
82
+ "backstory": "背景故事",
83
+ "custom_rules": ["特殊规则1", "特殊规则2"]
84
+ }
85
+ 如果用户没提到某字段,填写合理的默认值。name 必须有值。"""
86
+
87
+
88
+ async def structurize_description(desc: str, api_key: str) -> Character:
89
+ """调用 Gemini 将自由文本提取为结构化 Character"""
90
+ from google import genai
91
+ from google.genai import types
92
+
93
+ client = genai.Client(api_key=api_key)
94
+ config = types.GenerateContentConfig(
95
+ system_instruction=_STRUCTURIZE_PROMPT,
96
+ max_output_tokens=400,
97
+ temperature=0.3,
98
+ )
99
+ resp = await client.aio.models.generate_content(
100
+ model="gemini-2.5-flash",
101
+ contents=desc,
102
+ config=config,
103
+ )
104
+ text = (resp.text or "").strip()
105
+ if "{" in text:
106
+ text = text[text.index("{"):text.rindex("}") + 1]
107
+ data = json.loads(text)
108
+
109
+ char_id = generate_char_id()
110
+ return Character(
111
+ id=char_id,
112
+ name=data.get("name", "未命名"),
113
+ personality=data.get("personality", ""),
114
+ speech_style=data.get("speech_style", ""),
115
+ appearance=data.get("appearance", ""),
116
+ backstory=data.get("backstory", ""),
117
+ custom_rules=data.get("custom_rules", []),
118
+ created_at=time.time(),
119
+ )
120
+
121
+
122
+ _REFINE_PROMPT = """\
123
+ 根据用户的修改意见调整角色设定。
124
+ 当前设定和用户意见已提供。请输出调整后的完整 JSON(只输出 JSON,不要其他文字):
125
+ {
126
+ "name": "角色名字",
127
+ "personality": "性格特点",
128
+ "speech_style": "说话风格",
129
+ "appearance": "外貌描述",
130
+ "backstory": "背景故事",
131
+ "custom_rules": ["特殊规则1", "特殊规则2"]
132
+ }"""
133
+
134
+
135
+ async def refine_character(char: Character, feedback: str, api_key: str) -> Character:
136
+ """根据用户反馈调整角色设定"""
137
+ from google import genai
138
+ from google.genai import types
139
+
140
+ current = json.dumps({
141
+ "name": char.name,
142
+ "personality": char.personality,
143
+ "speech_style": char.speech_style,
144
+ "appearance": char.appearance,
145
+ "backstory": char.backstory,
146
+ "custom_rules": char.custom_rules,
147
+ }, ensure_ascii=False, indent=2)
148
+ prompt = f"当前角色设定:\n{current}\n\n用户的修改意见: {feedback}"
149
+
150
+ client = genai.Client(api_key=api_key)
151
+ config = types.GenerateContentConfig(
152
+ system_instruction=_REFINE_PROMPT,
153
+ max_output_tokens=400,
154
+ temperature=0.3,
155
+ )
156
+ resp = await client.aio.models.generate_content(
157
+ model="gemini-2.5-flash",
158
+ contents=prompt,
159
+ config=config,
160
+ )
161
+ text = (resp.text or "").strip()
162
+ if "{" in text:
163
+ text = text[text.index("{"):text.rindex("}") + 1]
164
+ data = json.loads(text)
165
+
166
+ char.name = data.get("name", char.name)
167
+ char.personality = data.get("personality", char.personality)
168
+ char.speech_style = data.get("speech_style", char.speech_style)
169
+ char.appearance = data.get("appearance", char.appearance)
170
+ char.backstory = data.get("backstory", char.backstory)
171
+ char.custom_rules = data.get("custom_rules", char.custom_rules)
172
+ return char
@@ -0,0 +1,48 @@
1
+ """L3 认知状态 — 在想什么/想说什么/期待什么"""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class CognitiveState:
7
+ """认知状态:驱动主动搭话和内心想法的注入"""
8
+
9
+ def __init__(self, data: dict | None = None):
10
+ d = data or {}
11
+ self.on_mind: str = d.get("on_mind", "")
12
+ self.wants_to_say: str = d.get("wants_to_say", "")
13
+ self.anticipating: str = d.get("anticipating", "")
14
+ self.day_assessment: str = d.get("day_assessment", "")
15
+
16
+ def to_dict(self) -> dict:
17
+ return {
18
+ "on_mind": self.on_mind,
19
+ "wants_to_say": self.wants_to_say,
20
+ "anticipating": self.anticipating,
21
+ "day_assessment": self.day_assessment,
22
+ }
23
+
24
+ def update(self, data: dict):
25
+ if "on_mind" in data:
26
+ self.on_mind = data["on_mind"]
27
+ if "wants_to_say" in data:
28
+ self.wants_to_say = data["wants_to_say"]
29
+ if "anticipating" in data:
30
+ self.anticipating = data["anticipating"]
31
+ if "day_assessment" in data:
32
+ self.day_assessment = data["day_assessment"]
33
+
34
+ def to_prompt_text(self) -> str:
35
+ parts = []
36
+ if self.on_mind:
37
+ parts.append(f"你现在在想: {self.on_mind}")
38
+ if self.wants_to_say:
39
+ parts.append(f"你其实想说: {self.wants_to_say}")
40
+ if self.anticipating:
41
+ parts.append(f"你在期待: {self.anticipating}")
42
+ if self.day_assessment:
43
+ parts.append(f"今天的感受: {self.day_assessment}")
44
+ return "\n".join(parts)
45
+
46
+ @property
47
+ def has_something_to_say(self) -> bool:
48
+ return bool(self.wants_to_say)
@@ -0,0 +1,159 @@
1
+ """AI 配置管理 — ~/.uparlor/ai/ 全局配置 + 角色级配置"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ AI_DIR = Path.home() / ".uparlor" / "ai"
10
+ CHARACTERS_DIR = AI_DIR / "characters"
11
+
12
+ # ── 全局配置 ──
13
+
14
+ _DEFAULT_GLOBAL = {
15
+ "auto_start": False,
16
+ "last_character_id": "",
17
+ "api_key": "",
18
+ "model": "gemini-2.5-flash",
19
+ "attention_level": "normal",
20
+ }
21
+
22
+
23
+ def ensure_ai_dir():
24
+ AI_DIR.mkdir(parents=True, exist_ok=True)
25
+
26
+
27
+ def _global_config_path() -> Path:
28
+ return AI_DIR / "config.json"
29
+
30
+
31
+ def load_global_config() -> dict:
32
+ path = _global_config_path()
33
+ if not path.exists():
34
+ return dict(_DEFAULT_GLOBAL)
35
+ try:
36
+ with open(path, "r", encoding="utf-8") as f:
37
+ data = json.load(f)
38
+ merged = dict(_DEFAULT_GLOBAL)
39
+ merged.update(data)
40
+ return merged
41
+ except Exception:
42
+ return dict(_DEFAULT_GLOBAL)
43
+
44
+
45
+ def save_global_config(data: dict):
46
+ ensure_ai_dir()
47
+ with open(_global_config_path(), "w", encoding="utf-8") as f:
48
+ json.dump(data, f, ensure_ascii=False, indent=2)
49
+
50
+
51
+ # ── 角色目录管理 ──
52
+
53
+ def char_dir(char_id: str) -> Path:
54
+ return CHARACTERS_DIR / char_id
55
+
56
+
57
+ def ensure_char_dir(char_id: str):
58
+ char_dir(char_id).mkdir(parents=True, exist_ok=True)
59
+
60
+
61
+ def list_character_ids() -> list[str]:
62
+ """返回所有角色 ID(即 characters/ 下的子目录名)"""
63
+ if not CHARACTERS_DIR.exists():
64
+ return []
65
+ return sorted(
66
+ d.name for d in CHARACTERS_DIR.iterdir()
67
+ if d.is_dir() and (d / "profile.json").exists()
68
+ )
69
+
70
+
71
+ def delete_character(char_id: str):
72
+ d = char_dir(char_id)
73
+ if d.exists():
74
+ shutil.rmtree(d)
75
+
76
+
77
+ # ── 角色级 API 配置 ──
78
+
79
+ _DEFAULT_API_CONFIG = {
80
+ "api_key": "",
81
+ "model": "gemini-2.5-flash",
82
+ "summary_model": "gemini-2.5-flash",
83
+ "daily_token_limit": 100000,
84
+ "proactive_enabled": True,
85
+ "proactive_idle_minutes": 10,
86
+ "proactive_cooldown_minutes": 5,
87
+ }
88
+
89
+
90
+ def load_api_config(char_id: str) -> dict:
91
+ path = char_dir(char_id) / "api.json"
92
+ if not path.exists():
93
+ return dict(_DEFAULT_API_CONFIG)
94
+ try:
95
+ with open(path, "r", encoding="utf-8") as f:
96
+ data = json.load(f)
97
+ merged = dict(_DEFAULT_API_CONFIG)
98
+ merged.update(data)
99
+ return merged
100
+ except Exception:
101
+ return dict(_DEFAULT_API_CONFIG)
102
+
103
+
104
+ def save_api_config(char_id: str, data: dict):
105
+ ensure_char_dir(char_id)
106
+ path = char_dir(char_id) / "api.json"
107
+ with open(path, "w", encoding="utf-8") as f:
108
+ json.dump(data, f, ensure_ascii=False, indent=2)
109
+
110
+
111
+ # ── 角色级 token 统计 ──
112
+
113
+ def load_stats(char_id: str) -> dict:
114
+ path = char_dir(char_id) / "stats.json"
115
+ if not path.exists():
116
+ return {"today": "", "tokens": 0}
117
+ try:
118
+ with open(path, "r", encoding="utf-8") as f:
119
+ return json.load(f)
120
+ except Exception:
121
+ return {"today": "", "tokens": 0}
122
+
123
+
124
+ def save_stats(char_id: str, data: dict):
125
+ ensure_char_dir(char_id)
126
+ path = char_dir(char_id) / "stats.json"
127
+ with open(path, "w", encoding="utf-8") as f:
128
+ json.dump(data, f, ensure_ascii=False)
129
+
130
+
131
+ # ── 通用 JSON/Text IO ──
132
+
133
+ def load_json(path: Path, default=None):
134
+ if not path.exists():
135
+ return default if default is not None else {}
136
+ try:
137
+ with open(path, "r", encoding="utf-8") as f:
138
+ return json.load(f)
139
+ except Exception:
140
+ return default if default is not None else {}
141
+
142
+
143
+ def save_json(path: Path, data):
144
+ path.parent.mkdir(parents=True, exist_ok=True)
145
+ with open(path, "w", encoding="utf-8") as f:
146
+ json.dump(data, f, ensure_ascii=False, indent=2)
147
+
148
+
149
+ def load_text(path: Path) -> str:
150
+ if not path.exists():
151
+ return ""
152
+ with open(path, "r", encoding="utf-8") as f:
153
+ return f.read()
154
+
155
+
156
+ def save_text(path: Path, text: str):
157
+ path.parent.mkdir(parents=True, exist_ok=True)
158
+ with open(path, "w", encoding="utf-8") as f:
159
+ f.write(text)