Coverage for config_manager.py: 62%

1"""

2配置管理模块

3

4统一管理用户配置，所有配置文件存放在 ~/.qrclaw/ 目录下：

5- ~/.qrclaw/config.yaml 用户配置（API Key 等）

6- ~/.qrclaw/permissions.yaml 权限配置

7- ~/.qrclaw/MEMORY.md 中期记忆

8- ~/.qrclaw/sessions/ 会话历史

9- ~/.qrclaw/logs/ 日志文件

10"""

11

12 import os

13 import yaml

14 from pathlib import Path

15 from typing import Any

16 from qrclaw.logger import get_logger

17

18 logger = get_logger("qrclaw.config_manager")

19

20# 配置目录

21 CONFIG_DIR = Path.home() / ".qrclaw"

22 CONFIG_FILE = CONFIG_DIR / "config.yaml"

23

24# 默认配置

25 DEFAULT_CONFIG = {

26 "agent": {

27 "name": "QRClaw",

28 "max_iterations": 50,

29 },

30 "llm": {

31 "provider": "openai", # openai | vertex

32 "openai": {

33 "api_key": "",

34 "model": "gpt-4o",

35 "base_url": "",

36 "max_tokens": 128000,

37 },

38 "vertex": {

39 "api_key": "",

40 },

41 },

42 "search": {

43 "tavily_api_key": "",

44 },

45 "log": {

46 "level": "INFO",

47 "max_days": 30,

48 "to_file": True,

49 "to_console": True,

50 "console_level": "WARNING",

51 },

52 "heartbeat": {

53 "enabled": True,

54 "interval": 3600,

55 },

56 "compress": {

57 "threshold_ratio": 0.6,

58 "target_min_ratio": 0.20,

59 "target_max_ratio": 0.25,

60 },

61}

62

63# 配置注释

64 CONFIG_HEADER = """# ═══════════════════════════════════════════════════════════════

65# QRClaw 配置文件

66# ═══════════════════════════════════════════════════════════════

67# 文档：https://github.com/fu-qingrong/qrclaw

68# 修改配置后无需重启，下次启动自动生效。

69# ═══════════════════════════════════════════════════════════════

70

71"""

72

73

74 def ensure_config_dir():

75 """确保配置目录存在"""

76 CONFIG_DIR.mkdir(parents=True, exist_ok=True)

77

78

79 def _write_config(config: dict):

80 """写入配置文件"""

81 ensure_config_dir()

82 with open(CONFIG_FILE, "w", encoding="utf-8") as f:

83 f.write(CONFIG_HEADER)

84 yaml.dump(config, f, default_flow_style=False, allow_unicode=True, sort_keys=False)

85

86

87 def _deep_merge(base: dict, override: dict) -> dict:

88 """深度合并两个字典"""

89 result = base.copy()

90 for key, value in override.items():

91 if key in result and isinstance(result[key], dict) and isinstance(value, dict):

92 result[key] = _deep_merge(result[key], value)

93 else:

94 result[key] = value

95 return result

96

97

98 def init_config():

99 """初始化配置文件（不存在则创建默认配置）"""

100 ensure_config_dir()

101

102 if CONFIG_FILE.exists():

103 logger.debug(f"使用配置文件: {CONFIG_FILE}")

104 return

105

106 _write_config(DEFAULT_CONFIG)

107 logger.info(f"创建默认配置文件: {CONFIG_FILE}")

108 logger.info("请编辑配置文件，填入你的 API Key")

109

110

111 def load_config():

112 """加载配置并注入到环境变量"""

113 if not CONFIG_FILE.exists():

114 init_config()

115

116 try:

117 with open(CONFIG_FILE, "r", encoding="utf-8") as f:

118 config = yaml.safe_load(f) or {}

119

120 config = _deep_merge(DEFAULT_CONFIG, config)

121 _inject_to_env(config)

122 logger.debug(f"已加载配置: {CONFIG_FILE}")

123

124 except Exception as e:

125 logger.error(f"加载配置失败: {e}", exc_info=True)

126

127

128 def _inject_to_env(config: dict):

129 """将配置注入到环境变量"""

130 os.environ.setdefault("AGENT_NAME", config["agent"]["name"])

131 os.environ.setdefault("MAX_ITERATIONS", str(config["agent"]["max_iterations"]))

132

133 os.environ.setdefault("LLM_PROVIDER", config["llm"]["provider"])

134 os.environ.setdefault("OPENAI_API_KEY", config["llm"]["openai"]["api_key"])

135 os.environ.setdefault("OPENAI_MODEL", config["llm"]["openai"]["model"])

136 os.environ.setdefault("OPENAI_BASE_URL", config["llm"]["openai"]["base_url"])

137 os.environ.setdefault("MODEL_MAX_TOKENS", str(config["llm"]["openai"]["max_tokens"]))

138 os.environ.setdefault("VERTEX_API_KEY", config["llm"]["vertex"]["api_key"])

139

140 os.environ.setdefault("TAVILY_API_KEY", config["search"]["tavily_api_key"])

141

142 os.environ.setdefault("LOG_LEVEL", config["log"]["level"])

143 os.environ.setdefault("LOG_MAX_DAYS", str(config["log"]["max_days"]))

144 os.environ.setdefault("LOG_TO_FILE", str(config["log"]["to_file"]).lower())

145 os.environ.setdefault("LOG_TO_CONSOLE", str(config["log"]["to_console"]).lower())

146 os.environ.setdefault("LOG_CONSOLE_LEVEL", config["log"]["console_level"])

147

148 os.environ.setdefault("HEARTBEAT_ENABLED", str(config["heartbeat"]["enabled"]).lower())

149 os.environ.setdefault("HEARTBEAT_INTERVAL", str(config["heartbeat"]["interval"]))

150

151

152 def get_config() -> dict:

153 """获取完整配置字典"""

154 if not CONFIG_FILE.exists():

155 return DEFAULT_CONFIG.copy()

156

157 with open(CONFIG_FILE, "r", encoding="utf-8") as f:

158 config = yaml.safe_load(f) or {}

159

160 return _deep_merge(DEFAULT_CONFIG, config)

161

162

163 def get(key: str, default: Any = None) -> Any:

164 """获取配置项（支持点号路径，如 llm.openai.api_key）"""

165 config = get_config()

166 keys = key.split(".")

167 value = config

168

169 for k in keys:

170 if isinstance(value, dict) and k in value:

171 value = value[k]

172 else:

173 return default

174

175 return value

176

177

178 def set_config(key: str, value: Any):

179 """设置配置项（支持点号路径）"""

180 config = get_config()

181 keys = key.split(".")

182 target = config

183

184 for k in keys[:-1]:

185 if k not in target:

186 target[k] = {}

187 target = target[k]

188

189 target[keys[-1]] = value

190 _write_config(config)

191

192

193 def get_config_path() -> Path:

194 """获取配置文件路径"""

195 return CONFIG_FILE

196

197

198 def get_config_dir() -> Path:

199 """获取配置目录路径"""

200 return CONFIG_DIR