jarvis-ai-assistant 0.1.222__py3-none-any.whl → 0.7.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 (162) hide show
  1. jarvis/__init__.py +1 -1
  2. jarvis/jarvis_agent/__init__.py +1143 -245
  3. jarvis/jarvis_agent/agent_manager.py +97 -0
  4. jarvis/jarvis_agent/builtin_input_handler.py +12 -10
  5. jarvis/jarvis_agent/config_editor.py +57 -0
  6. jarvis/jarvis_agent/edit_file_handler.py +392 -99
  7. jarvis/jarvis_agent/event_bus.py +48 -0
  8. jarvis/jarvis_agent/events.py +157 -0
  9. jarvis/jarvis_agent/file_context_handler.py +79 -0
  10. jarvis/jarvis_agent/file_methodology_manager.py +117 -0
  11. jarvis/jarvis_agent/jarvis.py +1117 -147
  12. jarvis/jarvis_agent/main.py +78 -34
  13. jarvis/jarvis_agent/memory_manager.py +195 -0
  14. jarvis/jarvis_agent/methodology_share_manager.py +174 -0
  15. jarvis/jarvis_agent/prompt_manager.py +82 -0
  16. jarvis/jarvis_agent/prompts.py +46 -9
  17. jarvis/jarvis_agent/protocols.py +4 -1
  18. jarvis/jarvis_agent/rewrite_file_handler.py +141 -0
  19. jarvis/jarvis_agent/run_loop.py +146 -0
  20. jarvis/jarvis_agent/session_manager.py +9 -9
  21. jarvis/jarvis_agent/share_manager.py +228 -0
  22. jarvis/jarvis_agent/shell_input_handler.py +23 -3
  23. jarvis/jarvis_agent/stdio_redirect.py +295 -0
  24. jarvis/jarvis_agent/task_analyzer.py +212 -0
  25. jarvis/jarvis_agent/task_manager.py +154 -0
  26. jarvis/jarvis_agent/task_planner.py +496 -0
  27. jarvis/jarvis_agent/tool_executor.py +8 -4
  28. jarvis/jarvis_agent/tool_share_manager.py +139 -0
  29. jarvis/jarvis_agent/user_interaction.py +42 -0
  30. jarvis/jarvis_agent/utils.py +54 -0
  31. jarvis/jarvis_agent/web_bridge.py +189 -0
  32. jarvis/jarvis_agent/web_output_sink.py +53 -0
  33. jarvis/jarvis_agent/web_server.py +751 -0
  34. jarvis/jarvis_c2rust/__init__.py +26 -0
  35. jarvis/jarvis_c2rust/cli.py +613 -0
  36. jarvis/jarvis_c2rust/collector.py +258 -0
  37. jarvis/jarvis_c2rust/library_replacer.py +1122 -0
  38. jarvis/jarvis_c2rust/llm_module_agent.py +1300 -0
  39. jarvis/jarvis_c2rust/optimizer.py +960 -0
  40. jarvis/jarvis_c2rust/scanner.py +1681 -0
  41. jarvis/jarvis_c2rust/transpiler.py +2325 -0
  42. jarvis/jarvis_code_agent/build_validation_config.py +133 -0
  43. jarvis/jarvis_code_agent/code_agent.py +1605 -178
  44. jarvis/jarvis_code_agent/code_analyzer/__init__.py +62 -0
  45. jarvis/jarvis_code_agent/code_analyzer/base_language.py +74 -0
  46. jarvis/jarvis_code_agent/code_analyzer/build_validator/__init__.py +44 -0
  47. jarvis/jarvis_code_agent/code_analyzer/build_validator/base.py +102 -0
  48. jarvis/jarvis_code_agent/code_analyzer/build_validator/cmake.py +59 -0
  49. jarvis/jarvis_code_agent/code_analyzer/build_validator/detector.py +125 -0
  50. jarvis/jarvis_code_agent/code_analyzer/build_validator/fallback.py +69 -0
  51. jarvis/jarvis_code_agent/code_analyzer/build_validator/go.py +38 -0
  52. jarvis/jarvis_code_agent/code_analyzer/build_validator/java_gradle.py +44 -0
  53. jarvis/jarvis_code_agent/code_analyzer/build_validator/java_maven.py +38 -0
  54. jarvis/jarvis_code_agent/code_analyzer/build_validator/makefile.py +50 -0
  55. jarvis/jarvis_code_agent/code_analyzer/build_validator/nodejs.py +93 -0
  56. jarvis/jarvis_code_agent/code_analyzer/build_validator/python.py +129 -0
  57. jarvis/jarvis_code_agent/code_analyzer/build_validator/rust.py +54 -0
  58. jarvis/jarvis_code_agent/code_analyzer/build_validator/validator.py +154 -0
  59. jarvis/jarvis_code_agent/code_analyzer/build_validator.py +43 -0
  60. jarvis/jarvis_code_agent/code_analyzer/context_manager.py +363 -0
  61. jarvis/jarvis_code_agent/code_analyzer/context_recommender.py +18 -0
  62. jarvis/jarvis_code_agent/code_analyzer/dependency_analyzer.py +132 -0
  63. jarvis/jarvis_code_agent/code_analyzer/file_ignore.py +330 -0
  64. jarvis/jarvis_code_agent/code_analyzer/impact_analyzer.py +781 -0
  65. jarvis/jarvis_code_agent/code_analyzer/language_registry.py +185 -0
  66. jarvis/jarvis_code_agent/code_analyzer/language_support.py +89 -0
  67. jarvis/jarvis_code_agent/code_analyzer/languages/__init__.py +31 -0
  68. jarvis/jarvis_code_agent/code_analyzer/languages/c_cpp_language.py +231 -0
  69. jarvis/jarvis_code_agent/code_analyzer/languages/go_language.py +183 -0
  70. jarvis/jarvis_code_agent/code_analyzer/languages/python_language.py +219 -0
  71. jarvis/jarvis_code_agent/code_analyzer/languages/rust_language.py +209 -0
  72. jarvis/jarvis_code_agent/code_analyzer/llm_context_recommender.py +451 -0
  73. jarvis/jarvis_code_agent/code_analyzer/symbol_extractor.py +77 -0
  74. jarvis/jarvis_code_agent/code_analyzer/tree_sitter_extractor.py +48 -0
  75. jarvis/jarvis_code_agent/lint.py +275 -13
  76. jarvis/jarvis_code_agent/utils.py +142 -0
  77. jarvis/jarvis_code_analysis/checklists/loader.py +20 -6
  78. jarvis/jarvis_code_analysis/code_review.py +583 -548
  79. jarvis/jarvis_data/config_schema.json +339 -28
  80. jarvis/jarvis_git_squash/main.py +22 -13
  81. jarvis/jarvis_git_utils/git_commiter.py +171 -55
  82. jarvis/jarvis_mcp/sse_mcp_client.py +22 -15
  83. jarvis/jarvis_mcp/stdio_mcp_client.py +4 -4
  84. jarvis/jarvis_mcp/streamable_mcp_client.py +36 -16
  85. jarvis/jarvis_memory_organizer/memory_organizer.py +753 -0
  86. jarvis/jarvis_methodology/main.py +48 -63
  87. jarvis/jarvis_multi_agent/__init__.py +302 -43
  88. jarvis/jarvis_multi_agent/main.py +70 -24
  89. jarvis/jarvis_platform/ai8.py +40 -23
  90. jarvis/jarvis_platform/base.py +210 -49
  91. jarvis/jarvis_platform/human.py +11 -1
  92. jarvis/jarvis_platform/kimi.py +82 -76
  93. jarvis/jarvis_platform/openai.py +73 -1
  94. jarvis/jarvis_platform/registry.py +8 -15
  95. jarvis/jarvis_platform/tongyi.py +115 -101
  96. jarvis/jarvis_platform/yuanbao.py +89 -63
  97. jarvis/jarvis_platform_manager/main.py +194 -132
  98. jarvis/jarvis_platform_manager/service.py +122 -86
  99. jarvis/jarvis_rag/cli.py +156 -53
  100. jarvis/jarvis_rag/embedding_manager.py +155 -12
  101. jarvis/jarvis_rag/llm_interface.py +10 -13
  102. jarvis/jarvis_rag/query_rewriter.py +63 -12
  103. jarvis/jarvis_rag/rag_pipeline.py +222 -40
  104. jarvis/jarvis_rag/reranker.py +26 -3
  105. jarvis/jarvis_rag/retriever.py +270 -14
  106. jarvis/jarvis_sec/__init__.py +3605 -0
  107. jarvis/jarvis_sec/checkers/__init__.py +32 -0
  108. jarvis/jarvis_sec/checkers/c_checker.py +2680 -0
  109. jarvis/jarvis_sec/checkers/rust_checker.py +1108 -0
  110. jarvis/jarvis_sec/cli.py +116 -0
  111. jarvis/jarvis_sec/report.py +257 -0
  112. jarvis/jarvis_sec/status.py +264 -0
  113. jarvis/jarvis_sec/types.py +20 -0
  114. jarvis/jarvis_sec/workflow.py +219 -0
  115. jarvis/jarvis_smart_shell/main.py +405 -137
  116. jarvis/jarvis_stats/__init__.py +13 -0
  117. jarvis/jarvis_stats/cli.py +387 -0
  118. jarvis/jarvis_stats/stats.py +711 -0
  119. jarvis/jarvis_stats/storage.py +612 -0
  120. jarvis/jarvis_stats/visualizer.py +282 -0
  121. jarvis/jarvis_tools/ask_user.py +1 -0
  122. jarvis/jarvis_tools/base.py +18 -2
  123. jarvis/jarvis_tools/clear_memory.py +239 -0
  124. jarvis/jarvis_tools/cli/main.py +220 -144
  125. jarvis/jarvis_tools/execute_script.py +52 -12
  126. jarvis/jarvis_tools/file_analyzer.py +17 -12
  127. jarvis/jarvis_tools/generate_new_tool.py +46 -24
  128. jarvis/jarvis_tools/read_code.py +277 -18
  129. jarvis/jarvis_tools/read_symbols.py +141 -0
  130. jarvis/jarvis_tools/read_webpage.py +86 -13
  131. jarvis/jarvis_tools/registry.py +294 -90
  132. jarvis/jarvis_tools/retrieve_memory.py +227 -0
  133. jarvis/jarvis_tools/save_memory.py +194 -0
  134. jarvis/jarvis_tools/search_web.py +62 -28
  135. jarvis/jarvis_tools/sub_agent.py +205 -0
  136. jarvis/jarvis_tools/sub_code_agent.py +217 -0
  137. jarvis/jarvis_tools/virtual_tty.py +330 -62
  138. jarvis/jarvis_utils/builtin_replace_map.py +4 -5
  139. jarvis/jarvis_utils/clipboard.py +90 -0
  140. jarvis/jarvis_utils/config.py +607 -50
  141. jarvis/jarvis_utils/embedding.py +3 -0
  142. jarvis/jarvis_utils/fzf.py +57 -0
  143. jarvis/jarvis_utils/git_utils.py +251 -29
  144. jarvis/jarvis_utils/globals.py +174 -17
  145. jarvis/jarvis_utils/http.py +58 -79
  146. jarvis/jarvis_utils/input.py +899 -153
  147. jarvis/jarvis_utils/methodology.py +210 -83
  148. jarvis/jarvis_utils/output.py +220 -137
  149. jarvis/jarvis_utils/utils.py +1906 -135
  150. jarvis_ai_assistant-0.7.0.dist-info/METADATA +465 -0
  151. jarvis_ai_assistant-0.7.0.dist-info/RECORD +192 -0
  152. {jarvis_ai_assistant-0.1.222.dist-info → jarvis_ai_assistant-0.7.0.dist-info}/entry_points.txt +8 -2
  153. jarvis/jarvis_git_details/main.py +0 -265
  154. jarvis/jarvis_platform/oyi.py +0 -357
  155. jarvis/jarvis_tools/edit_file.py +0 -255
  156. jarvis/jarvis_tools/rewrite_file.py +0 -195
  157. jarvis_ai_assistant-0.1.222.dist-info/METADATA +0 -767
  158. jarvis_ai_assistant-0.1.222.dist-info/RECORD +0 -110
  159. /jarvis/{jarvis_git_details → jarvis_memory_organizer}/__init__.py +0 -0
  160. {jarvis_ai_assistant-0.1.222.dist-info → jarvis_ai_assistant-0.7.0.dist-info}/WHEEL +0 -0
  161. {jarvis_ai_assistant-0.1.222.dist-info → jarvis_ai_assistant-0.7.0.dist-info}/licenses/LICENSE +0 -0
  162. {jarvis_ai_assistant-0.1.222.dist-info → jarvis_ai_assistant-0.7.0.dist-info}/top_level.txt +0 -0
@@ -6,10 +6,15 @@ import signal
6
6
  import subprocess
7
7
  import sys
8
8
  import time
9
+ import atexit
10
+ import errno
9
11
  from pathlib import Path
10
- from typing import Any, Callable, Dict, Optional
12
+ from typing import Any, Callable, Dict, List, Optional, Tuple
13
+ from datetime import datetime, date
11
14
 
12
15
  import yaml # type: ignore
16
+ from rich.align import Align
17
+ from rich.console import RenderableType
13
18
 
14
19
  from jarvis import __version__
15
20
  from jarvis.jarvis_utils.config import (
@@ -19,10 +24,176 @@ from jarvis.jarvis_utils.config import (
19
24
  )
20
25
  from jarvis.jarvis_utils.embedding import get_context_token_count
21
26
  from jarvis.jarvis_utils.globals import get_in_chat, get_interrupt, set_interrupt
27
+ from jarvis.jarvis_utils.input import user_confirm
22
28
  from jarvis.jarvis_utils.output import OutputType, PrettyOutput
23
29
 
30
+ # 向后兼容:导出 get_yes_no 供外部模块引用
31
+ get_yes_no = user_confirm
32
+
24
33
  g_config_file = None
25
34
 
35
+ COMMAND_MAPPING = {
36
+ # jarvis主命令
37
+ "jvs": "jarvis",
38
+ # 代码代理
39
+ "jca": "jarvis-code-agent",
40
+ # 智能shell
41
+ "jss": "jarvis-smart-shell",
42
+ # 平台管理
43
+ "jpm": "jarvis-platform-manager",
44
+ # Git提交
45
+ "jgc": "jarvis-git-commit",
46
+ # 代码审查
47
+ "jcr": "jarvis-code-review",
48
+ # Git压缩
49
+ "jgs": "jarvis-git-squash",
50
+ # 多代理
51
+ "jma": "jarvis-multi-agent",
52
+ # 代理
53
+ "ja": "jarvis-agent",
54
+ # 工具
55
+ "jt": "jarvis-tool",
56
+ # 方法论
57
+ "jm": "jarvis-methodology",
58
+ # RAG
59
+ "jrg": "jarvis-rag",
60
+ # 统计
61
+ "jst": "jarvis-stats",
62
+ # 记忆整理
63
+ "jmo": "jarvis-memory-organizer",
64
+ # 安全分析
65
+ "jsec": "jarvis-sec",
66
+ # C2Rust迁移
67
+ "jc2r": "jarvis-c2rust",
68
+ }
69
+
70
+ # RAG 依赖检测工具函数(更精确)
71
+ _RAG_REQUIRED_MODULES = [
72
+ "langchain",
73
+ "langchain_community",
74
+ "chromadb",
75
+ "sentence_transformers",
76
+ "rank_bm25",
77
+ "unstructured",
78
+ ]
79
+ _RAG_OPTIONAL_MODULES = [
80
+ "langchain_huggingface",
81
+ ]
82
+
83
+
84
+ def get_missing_rag_modules() -> List[str]:
85
+ """
86
+ 返回缺失的 RAG 关键依赖模块列表。
87
+ 仅检查必要模块,不导入模块,避免副作用。
88
+ """
89
+ try:
90
+ from importlib.util import find_spec
91
+
92
+ missing = [m for m in _RAG_REQUIRED_MODULES if find_spec(m) is None]
93
+ return missing
94
+ except Exception:
95
+ # 任何异常都视为无法确认,保持保守策略
96
+ return _RAG_REQUIRED_MODULES[:] # 视为全部缺失
97
+
98
+
99
+ def is_rag_installed() -> bool:
100
+ """
101
+ 更准确的 RAG 安装检测:确认关键依赖模块均可用。
102
+ """
103
+ return len(get_missing_rag_modules()) == 0
104
+
105
+
106
+ def is_editable_install() -> bool:
107
+ """
108
+ 检测当前 Jarvis 是否以可编辑模式安装(pip/uv install -e .)。
109
+
110
+ 判断顺序:
111
+ 1. 读取 PEP 610 的 direct_url.json(dir_info.editable)
112
+ 2. 兼容旧式 .egg-link 安装
113
+ 3. 启发式回退:源码路径上游存在 .git 且不在 site-packages/dist-packages
114
+ """
115
+ # 优先使用 importlib.metadata 读取 distribution 的 direct_url.json
116
+ try:
117
+ import importlib.metadata as metadata # Python 3.8+
118
+ except Exception:
119
+ metadata = None # type: ignore
120
+
121
+ def _check_direct_url() -> Optional[bool]:
122
+ if metadata is None:
123
+ return None
124
+ candidates = ["jarvis-ai-assistant", "jarvis_ai_assistant"]
125
+ for name in candidates:
126
+ try:
127
+ dist = metadata.distribution(name)
128
+ except Exception:
129
+ continue
130
+ try:
131
+ files = dist.files or []
132
+ for f in files:
133
+ try:
134
+ if f.name == "direct_url.json":
135
+ p = Path(str(dist.locate_file(f)))
136
+ if p.exists():
137
+ with open(p, "r", encoding="utf-8", errors="ignore") as fp:
138
+ info = json.load(fp)
139
+ dir_info = info.get("dir_info") or {}
140
+ if isinstance(dir_info, dict) and bool(dir_info.get("editable")):
141
+ return True
142
+ # 兼容部分工具可能写入顶层 editable 字段
143
+ if bool(info.get("editable")):
144
+ return True
145
+ return False # 找到了 direct_url.json 但未标记 editable
146
+ except Exception:
147
+ continue
148
+ except Exception:
149
+ continue
150
+ return None
151
+
152
+ res = _check_direct_url()
153
+ if res is True:
154
+ return True
155
+ if res is False:
156
+ # 明确不是可编辑安装
157
+ return False
158
+
159
+ # 兼容旧式 .egg-link 可编辑安装
160
+ try:
161
+ module_path = Path(__file__).resolve()
162
+ pkg_root = module_path.parent.parent # jarvis 包根目录
163
+ for entry in sys.path:
164
+ try:
165
+ p = Path(entry)
166
+ if not p.exists() or not p.is_dir():
167
+ continue
168
+ for egg in p.glob("*.egg-link"):
169
+ try:
170
+ text = egg.read_text(encoding="utf-8", errors="ignore")
171
+ first_line = (text.strip().splitlines() or [""])[0]
172
+ if not first_line:
173
+ continue
174
+ src_path = Path(first_line).resolve()
175
+ # 当前包根目录在 egg-link 指向的源码路径下,视为可编辑安装
176
+ if str(pkg_root).startswith(str(src_path)):
177
+ return True
178
+ except Exception:
179
+ continue
180
+ except Exception:
181
+ continue
182
+ except Exception:
183
+ pass
184
+
185
+ # 启发式回退:源码仓库路径
186
+ try:
187
+ parents = list(Path(__file__).resolve().parents)
188
+ has_git = any((d / ".git").exists() for d in parents)
189
+ in_site = any(("site-packages" in str(d)) or ("dist-packages" in str(d)) for d in parents)
190
+ if has_git and not in_site:
191
+ return True
192
+ except Exception:
193
+ pass
194
+
195
+ return False
196
+
26
197
 
27
198
  def _setup_signal_handler() -> None:
28
199
  """设置SIGINT信号处理函数"""
@@ -40,40 +211,690 @@ def _setup_signal_handler() -> None:
40
211
  signal.signal(signal.SIGINT, sigint_handler)
41
212
 
42
213
 
43
- def _show_welcome_message(welcome_str: str) -> None:
44
- """显示欢迎信息
214
+ # ----------------------------
215
+ # 单实例文件锁(放置于初始化早期使用)
216
+ # ----------------------------
217
+ _INSTANCE_LOCK_PATH: Optional[Path] = None
45
218
 
46
- 参数:
47
- welcome_str: 欢迎信息字符串
219
+
220
+ def _get_instance_lock_path(lock_name: str = "instance.lock") -> Path:
221
+ try:
222
+ data_dir = Path(str(get_data_dir()))
223
+ except Exception:
224
+ data_dir = Path(os.path.expanduser("~/.jarvis"))
225
+ data_dir.mkdir(parents=True, exist_ok=True)
226
+ return data_dir / lock_name
227
+
228
+
229
+ def _read_lock_owner_pid(lock_path: Path) -> Optional[int]:
230
+ try:
231
+ txt = lock_path.read_text(encoding="utf-8", errors="ignore").strip()
232
+ if not txt:
233
+ return None
234
+ try:
235
+ info = json.loads(txt)
236
+ pid = info.get("pid")
237
+ return int(pid) if pid is not None else None
238
+ except Exception:
239
+ # 兼容纯数字PID
240
+ return int(txt)
241
+ except Exception:
242
+ return None
243
+
244
+
245
+ def _is_process_alive(pid: int) -> bool:
246
+ if pid is None or pid <= 0:
247
+ return False
248
+ try:
249
+ os.kill(pid, 0)
250
+ except ProcessLookupError:
251
+ return False
252
+ except PermissionError:
253
+ # 无权限但进程存在
254
+ return True
255
+ except OSError as e:
256
+ # 某些平台上,EPERM 表示进程存在但无权限
257
+ if getattr(e, "errno", None) == errno.EPERM:
258
+ return True
259
+ return False
260
+ else:
261
+ return True
262
+
263
+
264
+ def _release_instance_lock() -> None:
265
+ global _INSTANCE_LOCK_PATH
266
+ try:
267
+ if _INSTANCE_LOCK_PATH and _INSTANCE_LOCK_PATH.exists():
268
+ _INSTANCE_LOCK_PATH.unlink()
269
+ except Exception:
270
+ # 清理失败不影响退出
271
+ pass
272
+ _INSTANCE_LOCK_PATH = None
273
+
274
+
275
+ def _acquire_single_instance_lock(lock_name: str = "instance.lock") -> None:
48
276
  """
49
- if not welcome_str:
50
- return
277
+ 在数据目录(~/.jarvis 或配置的数据目录)下创建实例锁,防止重复启动。
278
+ 如果检测到已有存活实例,提示后退出。
279
+ """
280
+ global _INSTANCE_LOCK_PATH
281
+ lock_path = _get_instance_lock_path(lock_name)
51
282
 
52
- jarvis_ascii_art = f"""
53
- ██╗ █████╗ ██████╗ ██╗ ██╗██╗███████╗
54
- ██║██╔══██╗██╔══██╗██║ ██║██║██╔════╝
55
- ██║███████║██████╔╝██║ ██║██║███████╗
56
- ██╗██║██╔══██║██╔══██╗╚██╗ ██╔╝██║╚════██║
57
- ╚████║██║ ██║██║ ██║ ╚████╔╝ ██║███████║
58
- ╚═══╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═══╝ ╚═╝╚══════╝
59
- {welcome_str}
283
+ # 已存在锁:检查是否为有效存活实例
284
+ if lock_path.exists():
285
+ pid = _read_lock_owner_pid(lock_path)
286
+ if pid and _is_process_alive(pid):
287
+ PrettyOutput.print(
288
+ f"检测到已有一个 Jarvis 实例正在运行 (PID: {pid})。\n"
289
+ f"如果确认不存在正在运行的实例,请删除锁文件后重试:{lock_path}",
290
+ OutputType.WARNING,
291
+ )
292
+ sys.exit(0)
293
+ # 尝试移除陈旧锁
294
+ try:
295
+ lock_path.unlink()
296
+ except Exception:
297
+ PrettyOutput.print(
298
+ f"无法删除旧锁文件:{lock_path},请手动清理后重试。",
299
+ OutputType.ERROR,
300
+ )
301
+ sys.exit(1)
60
302
 
61
- https://github.com/skyfireitdiy/Jarvis
62
- v{__version__}
63
- """
64
- PrettyOutput.print_gradient_text(jarvis_ascii_art, (0, 120, 255), (0, 255, 200))
303
+ # 原子创建锁文件,避免并发竞争
304
+ flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
305
+ try:
306
+ fd = os.open(str(lock_path), flags)
307
+ with os.fdopen(fd, "w", encoding="utf-8") as fp:
308
+ payload = {
309
+ "pid": os.getpid(),
310
+ "time": int(time.time()),
311
+ "argv": sys.argv[:10],
312
+ }
313
+ try:
314
+ fp.write(json.dumps(payload, ensure_ascii=False))
315
+ except Exception:
316
+ fp.write(str(os.getpid()))
317
+ _INSTANCE_LOCK_PATH = lock_path
318
+ atexit.register(_release_instance_lock)
319
+ except FileExistsError:
320
+ # 极端并发下再次校验
321
+ pid = _read_lock_owner_pid(lock_path)
322
+ if pid and _is_process_alive(pid):
323
+ PrettyOutput.print(
324
+ f"检测到已有一个 Jarvis 实例正在运行 (PID: {pid})。",
325
+ OutputType.WARNING,
326
+ )
327
+ sys.exit(0)
328
+ PrettyOutput.print(
329
+ f"锁文件已存在但可能为陈旧状态:{lock_path},请手动删除后重试。",
330
+ OutputType.ERROR,
331
+ )
332
+ sys.exit(1)
333
+ except Exception as e:
334
+ PrettyOutput.print(f"创建实例锁失败: {e}", OutputType.ERROR)
335
+ sys.exit(1)
65
336
 
66
337
 
67
- def _check_git_updates() -> bool:
68
- """检查并更新git仓库
338
+ def _check_pip_updates() -> bool:
339
+ """检查pip安装的Jarvis是否有更新
340
+
341
+ 返回:
342
+ bool: 是否执行了更新(成功更新返回True以触发重启)
343
+ """
344
+ import urllib.request
345
+ import urllib.error
346
+ from packaging import version
347
+
348
+ # 检查上次检查日期
349
+ last_check_file = Path(str(get_data_dir())) / "last_pip_check"
350
+ today_str = date.today().strftime("%Y-%m-%d")
351
+
352
+ if last_check_file.exists():
353
+ try:
354
+ last_check_date = last_check_file.read_text().strip()
355
+ if last_check_date == today_str:
356
+ return False
357
+ except Exception:
358
+ pass
359
+
360
+ try:
361
+ # 获取PyPI上的最新版本
362
+ url = "https://pypi.org/pypi/jarvis-ai-assistant/json"
363
+ try:
364
+ with urllib.request.urlopen(url, timeout=5) as response:
365
+ data = json.loads(response.read().decode())
366
+ latest_version = data["info"]["version"]
367
+ except (urllib.error.URLError, KeyError, json.JSONDecodeError):
368
+ return False
369
+
370
+ # 比较版本
371
+ current_ver = version.parse(__version__)
372
+ latest_ver = version.parse(latest_version)
373
+
374
+ if latest_ver > current_ver:
375
+ PrettyOutput.print(
376
+ f"检测到新版本 v{latest_version} (当前版本: v{__version__})",
377
+ OutputType.INFO,
378
+ )
379
+
380
+ # 检测是否在虚拟环境中
381
+ hasattr(sys, "real_prefix") or (
382
+ hasattr(sys, "base_prefix") and sys.base_prefix != sys.prefix
383
+ )
384
+
385
+ # 检测是否可用 uv(优先使用虚拟环境内的uv,其次PATH中的uv)
386
+ from shutil import which as _which
387
+ uv_executable: Optional[str] = None
388
+ if sys.platform == "win32":
389
+ venv_uv = Path(sys.prefix) / "Scripts" / "uv.exe"
390
+ else:
391
+ venv_uv = Path(sys.prefix) / "bin" / "uv"
392
+ if venv_uv.exists():
393
+ uv_executable = str(venv_uv)
394
+ else:
395
+ path_uv = _which("uv")
396
+ if path_uv:
397
+ uv_executable = path_uv
398
+
399
+ # 检测是否安装了 RAG 特性(更精确)
400
+ from jarvis.jarvis_utils.utils import (
401
+ is_rag_installed as _is_rag_installed,
402
+ ) # 延迟导入避免潜在循环依赖
403
+ rag_installed = _is_rag_installed()
404
+
405
+ # 更新命令
406
+ package_spec = (
407
+ "jarvis-ai-assistant[rag]" if rag_installed else "jarvis-ai-assistant"
408
+ )
409
+ if uv_executable:
410
+ cmd_list = [uv_executable, "pip", "install", "--upgrade", package_spec]
411
+ update_cmd = f"uv pip install --upgrade {package_spec}"
412
+ else:
413
+ cmd_list = [
414
+ sys.executable,
415
+ "-m",
416
+ "pip",
417
+ "install",
418
+ "--upgrade",
419
+ package_spec,
420
+ ]
421
+ update_cmd = f"{sys.executable} -m pip install --upgrade {package_spec}"
422
+
423
+ # 自动尝试升级(失败时提供手动命令)
424
+ try:
425
+ PrettyOutput.print("正在自动更新 Jarvis,请稍候...", OutputType.INFO)
426
+ result = subprocess.run(
427
+ cmd_list,
428
+ capture_output=True,
429
+ text=True,
430
+ encoding="utf-8",
431
+ errors="replace",
432
+ timeout=600,
433
+ )
434
+ if result.returncode == 0:
435
+ PrettyOutput.print("更新成功,正在重启以应用新版本...", OutputType.SUCCESS)
436
+ # 更新检查日期,避免重复触发
437
+ last_check_file.write_text(today_str)
438
+ return True
439
+ else:
440
+ err = (result.stderr or result.stdout or "").strip()
441
+ if err:
442
+ PrettyOutput.print(
443
+ f"自动更新失败,错误信息(已截断): {err[:500]}",
444
+ OutputType.WARNING,
445
+ )
446
+ PrettyOutput.print(
447
+ f"请手动执行以下命令更新: {update_cmd}", OutputType.INFO
448
+ )
449
+ except Exception:
450
+ PrettyOutput.print("自动更新出现异常,已切换为手动更新方式。", OutputType.WARNING)
451
+ PrettyOutput.print(
452
+ f"请手动执行以下命令更新: {update_cmd}", OutputType.INFO
453
+ )
454
+
455
+ # 更新检查日期
456
+ last_check_file.write_text(today_str)
457
+
458
+ except Exception:
459
+ # 静默处理错误,不影响正常使用
460
+ pass
461
+
462
+ return False
463
+
464
+
465
+ def _check_jarvis_updates() -> bool:
466
+ """检查并更新Jarvis本身(git仓库或pip包)
69
467
 
70
468
  返回:
71
469
  bool: 是否需要重启进程
72
470
  """
73
- script_dir = Path(os.path.dirname(os.path.dirname(__file__)))
74
- from jarvis.jarvis_utils.git_utils import check_and_update_git_repo
471
+ # 从当前文件目录向上查找包含 .git 的仓库根目录,修复原先只检查 src/jarvis 的问题
472
+ try:
473
+ script_path = Path(__file__).resolve()
474
+ repo_root: Optional[Path] = None
475
+ for d in [script_path.parent] + list(script_path.parents):
476
+ if (d / ".git").exists():
477
+ repo_root = d
478
+ break
479
+ except Exception:
480
+ repo_root = None
75
481
 
76
- return check_and_update_git_repo(str(script_dir))
482
+ # 先检查是否是git源码安装(找到仓库根目录即认为是源码安装)
483
+ if repo_root and (repo_root / ".git").exists():
484
+ from jarvis.jarvis_utils.git_utils import check_and_update_git_repo
485
+
486
+ return check_and_update_git_repo(str(repo_root))
487
+
488
+ # 检查是否是pip/uv pip安装的版本
489
+ return _check_pip_updates()
490
+
491
+
492
+ def _show_usage_stats(welcome_str: str) -> None:
493
+ """显示Jarvis使用统计信息"""
494
+ from jarvis.jarvis_utils.output import OutputType, PrettyOutput
495
+
496
+ try:
497
+
498
+ from rich.console import Console, Group
499
+ from rich.panel import Panel
500
+ from rich.table import Table
501
+ from rich.text import Text
502
+
503
+ console = Console()
504
+
505
+ from jarvis.jarvis_stats.stats import StatsManager
506
+
507
+ # 获取所有可用的指标
508
+ all_metrics = StatsManager.list_metrics()
509
+
510
+ # 根据指标名称和标签自动分类
511
+ categorized_stats: Dict[str, Dict[str, Any]] = {
512
+ "tool": {"title": "🔧 工具调用", "metrics": {}, "suffix": "次"},
513
+ "code": {"title": "📝 代码修改", "metrics": {}, "suffix": "次"},
514
+ "lines": {"title": "📊 代码行数", "metrics": {}, "suffix": "行"},
515
+ "commit": {"title": "💾 提交统计", "metrics": {}, "suffix": "个"},
516
+ "command": {"title": "📱 命令使用", "metrics": {}, "suffix": "次"},
517
+ "adoption": {"title": "🎯 采纳情况", "metrics": {}, "suffix": ""},
518
+ "other": {"title": "📦 其他指标", "metrics": {}, "suffix": ""},
519
+ }
520
+
521
+ # 遍历所有指标,使用快速总量读取以避免全量扫描
522
+ for metric in all_metrics:
523
+ try:
524
+ total = StatsManager.get_metric_total(metric)
525
+ except Exception:
526
+ total = 0.0
527
+
528
+ if not total or total <= 0:
529
+ continue
530
+
531
+ # 优先使用元信息中的分组(在写入指标时已记录)
532
+ info = StatsManager.get_metric_info(metric) or {}
533
+ group = info.get("group", "other")
534
+
535
+ if group == "tool":
536
+ categorized_stats["tool"]["metrics"][metric] = int(total)
537
+ elif group == "code_agent":
538
+ # 根据指标名称细分
539
+ if metric.startswith("code_lines_"):
540
+ categorized_stats["lines"]["metrics"][metric] = int(total)
541
+ elif "commit" in metric:
542
+ categorized_stats["commit"]["metrics"][metric] = int(total)
543
+ else:
544
+ categorized_stats["code"]["metrics"][metric] = int(total)
545
+ elif group == "command":
546
+ categorized_stats["command"]["metrics"][metric] = int(total)
547
+ else:
548
+ categorized_stats["other"]["metrics"][metric] = int(total)
549
+
550
+ # 合并长短命令的历史统计数据
551
+ command_stats = categorized_stats["command"]["metrics"]
552
+ if command_stats:
553
+ merged_stats: Dict[str, int] = {}
554
+ for metric, count in command_stats.items():
555
+ long_command = COMMAND_MAPPING.get(metric, metric)
556
+ merged_stats[long_command] = merged_stats.get(long_command, 0) + count
557
+ categorized_stats["command"]["metrics"] = merged_stats
558
+
559
+ # 计算采纳率并添加到统计中
560
+ commit_stats = categorized_stats["commit"]["metrics"]
561
+ # 使用精确的指标名称
562
+ generated_commits = commit_stats.get("commits_generated", 0)
563
+ accepted_commits = commit_stats.get("commits_accepted", 0)
564
+
565
+ # 如果有 generated,则计算采纳率
566
+ if generated_commits > 0:
567
+ adoption_rate = (accepted_commits / generated_commits) * 100
568
+ categorized_stats["adoption"]["metrics"][
569
+ "adoption_rate"
570
+ ] = f"{adoption_rate:.1f}%"
571
+ categorized_stats["adoption"]["metrics"][
572
+ "commits_status"
573
+ ] = f"{accepted_commits}/{generated_commits}"
574
+
575
+ # 构建输出
576
+ has_data = False
577
+ stats_output = []
578
+
579
+ for category, data in categorized_stats.items():
580
+ if data["metrics"]:
581
+ has_data = True
582
+ stats_output.append((data["title"], data["metrics"], data["suffix"]))
583
+
584
+ # 显示统计信息
585
+ if has_data:
586
+ # 1. 创建统计表格
587
+ from rich import box
588
+
589
+ table = Table(
590
+ show_header=True,
591
+ header_style="bold magenta",
592
+ title_justify="center",
593
+ box=box.ROUNDED,
594
+ padding=(0, 1),
595
+ )
596
+ table.add_column("分类", style="cyan", no_wrap=True, width=12)
597
+ table.add_column("指标", style="white", width=20)
598
+ table.add_column("数量", style="green", justify="right", width=10)
599
+ table.add_column("分类", style="cyan", no_wrap=True, width=12)
600
+ table.add_column("指标", style="white", width=20)
601
+ table.add_column("数量", style="green", justify="right", width=10)
602
+
603
+ # 收集所有要显示的数据
604
+ all_rows = []
605
+ for title, stats, suffix in stats_output:
606
+ if stats:
607
+ sorted_stats = sorted(
608
+ stats.items(), key=lambda item: item[1], reverse=True
609
+ )
610
+ for i, (metric, count) in enumerate(sorted_stats):
611
+ display_name = metric.replace("_", " ").title()
612
+ category_title = title if i == 0 else ""
613
+ # 处理不同类型的count值
614
+ if isinstance(count, (int, float)):
615
+ count_str = f"{count:,} {suffix}"
616
+ else:
617
+ # 对于字符串类型的count(如百分比或比率),直接使用
618
+ count_str = str(count)
619
+ all_rows.append((category_title, display_name, count_str))
620
+
621
+ # 以3行2列的方式添加数据
622
+ has_content = len(all_rows) > 0
623
+ # 计算需要多少行来显示所有数据
624
+ total_rows = len(all_rows)
625
+ rows_needed = (total_rows + 1) // 2 # 向上取整,因为是2列布局
626
+
627
+ for i in range(rows_needed):
628
+ left_idx = i
629
+ right_idx = i + rows_needed
630
+
631
+ if left_idx < len(all_rows):
632
+ left_row = all_rows[left_idx]
633
+ else:
634
+ left_row = ("", "", "")
635
+
636
+ if right_idx < len(all_rows):
637
+ right_row = all_rows[right_idx]
638
+ else:
639
+ right_row = ("", "", "")
640
+
641
+ table.add_row(
642
+ left_row[0],
643
+ left_row[1],
644
+ left_row[2],
645
+ right_row[0],
646
+ right_row[1],
647
+ right_row[2],
648
+ )
649
+
650
+ # 2. 创建总结面板
651
+ summary_content = []
652
+
653
+ # 总结统计
654
+ total_tools = sum(
655
+ count
656
+ for title, stats, _ in stats_output
657
+ if "工具" in title
658
+ for metric, count in stats.items()
659
+ )
660
+ total_changes = sum(
661
+ count
662
+ for title, stats, _ in stats_output
663
+ if "代码修改" in title
664
+ for metric, count in stats.items()
665
+ )
666
+
667
+ # 统计代码行数
668
+ lines_stats = categorized_stats["lines"]["metrics"]
669
+ total_lines_added = lines_stats.get(
670
+ "code_lines_inserted", lines_stats.get("code_lines_added", 0)
671
+ )
672
+ total_lines_deleted = lines_stats.get("code_lines_deleted", 0)
673
+ total_lines_modified = total_lines_added + total_lines_deleted
674
+
675
+ if total_tools > 0 or total_changes > 0 or total_lines_modified > 0:
676
+ parts = []
677
+ if total_tools > 0:
678
+ parts.append(f"工具调用 {total_tools:,} 次")
679
+ if total_changes > 0:
680
+ parts.append(f"代码修改 {total_changes:,} 次")
681
+ if total_lines_modified > 0:
682
+ parts.append(f"修改代码行数 {total_lines_modified:,} 行")
683
+
684
+ if parts:
685
+ summary_content.append(f"📈 总计: {', '.join(parts)}")
686
+
687
+ # 添加代码采纳率显示
688
+ adoption_metrics = categorized_stats["adoption"]["metrics"]
689
+ if "adoption_rate" in adoption_metrics:
690
+ summary_content.append(
691
+ f"✅ 代码采纳率: {adoption_metrics['adoption_rate']}"
692
+ )
693
+
694
+ # 计算节省的时间
695
+ time_saved_seconds = 0
696
+ tool_stats = categorized_stats["tool"]["metrics"]
697
+ code_agent_changes = categorized_stats["code"]["metrics"]
698
+ lines_stats = categorized_stats["lines"]["metrics"]
699
+ # commit_stats is already defined above
700
+ command_stats = categorized_stats["command"]["metrics"]
701
+
702
+ # 统一的工具使用时间估算(每次调用节省2分钟)
703
+ DEFAULT_TOOL_TIME_SAVINGS = 2 * 60 # 秒
704
+
705
+ # 计算所有工具的时间节省
706
+ for tool_name, count in tool_stats.items():
707
+ time_saved_seconds += count * DEFAULT_TOOL_TIME_SAVINGS
708
+
709
+ # 其他类型的时间计算
710
+ total_code_agent_calls = sum(code_agent_changes.values())
711
+ time_saved_seconds += total_code_agent_calls * 10 * 60
712
+ time_saved_seconds += lines_stats.get("code_lines_added", 0) * 0.8 * 60
713
+ time_saved_seconds += lines_stats.get("code_lines_deleted", 0) * 0.2 * 60
714
+ time_saved_seconds += sum(commit_stats.values()) * 10 * 60
715
+ time_saved_seconds += sum(command_stats.values()) * 1 * 60
716
+
717
+ time_str = ""
718
+ hours = 0
719
+ if time_saved_seconds > 0:
720
+ total_minutes = int(time_saved_seconds / 60)
721
+ seconds = int(time_saved_seconds % 60)
722
+ hours = total_minutes // 60
723
+ minutes = total_minutes % 60
724
+ # 只显示小时和分钟
725
+ if hours > 0:
726
+ time_str = f"{hours} 小时 {minutes} 分钟"
727
+ elif total_minutes > 0:
728
+ time_str = f"{minutes} 分钟 {seconds} 秒"
729
+ else:
730
+ time_str = f"{seconds} 秒"
731
+
732
+ if summary_content:
733
+ summary_content.append("") # Add a separator line
734
+ summary_content.append(f"⏱️ 节省时间: 约 {time_str}")
735
+
736
+ encouragement = ""
737
+ # 计算各级时间单位
738
+ total_work_days = hours // 8 # 总工作日数
739
+ work_years = total_work_days // 240 # 每年约240个工作日
740
+ remaining_days_after_years = total_work_days % 240
741
+ work_months = remaining_days_after_years // 20 # 每月约20个工作日
742
+ remaining_days_after_months = remaining_days_after_years % 20
743
+ work_days = remaining_days_after_months
744
+ remaining_hours = int(hours % 8) # 剩余不足一个工作日的小时数
745
+
746
+ # 构建时间描述
747
+ time_parts = []
748
+ if work_years > 0:
749
+ time_parts.append(f"{work_years} 年")
750
+ if work_months > 0:
751
+ time_parts.append(f"{work_months} 个月")
752
+ if work_days > 0:
753
+ time_parts.append(f"{work_days} 个工作日")
754
+ if remaining_hours > 0:
755
+ time_parts.append(f"{remaining_hours} 小时")
756
+
757
+ if time_parts:
758
+ time_description = "、".join(time_parts)
759
+ if work_years >= 1:
760
+ encouragement = (
761
+ f"🎉 相当于节省了 {time_description} 的工作时间!"
762
+ )
763
+ elif work_months >= 1:
764
+ encouragement = (
765
+ f"🚀 相当于节省了 {time_description} 的工作时间!"
766
+ )
767
+ elif work_days >= 1:
768
+ encouragement = (
769
+ f"💪 相当于节省了 {time_description} 的工作时间!"
770
+ )
771
+ else:
772
+ encouragement = (
773
+ f"✨ 相当于节省了 {time_description} 的工作时间!"
774
+ )
775
+ elif hours >= 1:
776
+ encouragement = f"⭐ 相当于节省了 {int(hours)} 小时的工作时间,积少成多,继续保持!"
777
+ if encouragement:
778
+ summary_content.append(encouragement)
779
+
780
+ # 3. 组合并打印
781
+ from rich import box
782
+
783
+ # 右侧内容:总体表现 + 使命与愿景
784
+ right_column_items = []
785
+
786
+ # 欢迎信息 Panel
787
+ if welcome_str:
788
+ jarvis_ascii_art_str = """
789
+ ██╗ █████╗ ██████╗ ██╗ ██╗██╗███████╗
790
+ ██║██╔══██╗██╔══██╗██║ ██║██║██╔════╝
791
+ ██║███████║██████╔╝██║ ██║██║███████╗
792
+ ██╗██║██╔══██║██╔══██╗╚██╗ ██╔╝██║╚════██║
793
+ ╚████║██║ ██║██║ ██║ ╚████╔╝ ██║███████║
794
+ ╚═══╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═══╝ ╚═╝╚══════╝"""
795
+
796
+ welcome_panel_content = Group(
797
+ Align.center(Text(jarvis_ascii_art_str, style="bold blue")),
798
+ Align.center(Text(welcome_str, style="bold")),
799
+ "", # for a blank line
800
+ Align.center(Text(f"v{__version__}")),
801
+ Align.center(Text("https://github.com/skyfireitdiy/Jarvis")),
802
+ )
803
+
804
+ welcome_panel = Panel(
805
+ welcome_panel_content, border_style="yellow", expand=True
806
+ )
807
+ right_column_items.append(welcome_panel)
808
+ if summary_content:
809
+ summary_panel = Panel(
810
+ Text("\n".join(summary_content), justify="left"),
811
+ title="✨ 总体表现 ✨",
812
+ title_align="center",
813
+ border_style="green",
814
+ expand=True,
815
+ )
816
+ right_column_items.append(summary_panel)
817
+
818
+ # 愿景 Panel
819
+ vision_text = Text(
820
+ "让开发者与AI成为共生伙伴",
821
+ justify="center",
822
+ style="italic",
823
+ )
824
+ vision_panel = Panel(
825
+ vision_text,
826
+ title="🔭 愿景 (Vision) 🔭",
827
+ title_align="center",
828
+ border_style="cyan",
829
+ expand=True,
830
+ )
831
+ right_column_items.append(vision_panel)
832
+
833
+ # 使命 Panel
834
+ mission_text = Text(
835
+ "让灵感高效落地为代码与行动",
836
+ justify="center",
837
+ style="italic",
838
+ )
839
+ mission_panel = Panel(
840
+ mission_text,
841
+ title="🎯 使命 (Mission) 🎯",
842
+ title_align="center",
843
+ border_style="magenta",
844
+ expand=True,
845
+ )
846
+ right_column_items.append(mission_panel)
847
+
848
+ right_column_group = Group(*right_column_items)
849
+
850
+ layout_renderable: RenderableType
851
+
852
+ if console.width < 200:
853
+ # 上下布局
854
+ layout_items: List[RenderableType] = []
855
+ layout_items.append(right_column_group)
856
+ if has_content:
857
+ layout_items.append(Align.center(table))
858
+ layout_renderable = Group(*layout_items)
859
+ else:
860
+ # 左右布局(当前)
861
+ layout_table = Table(
862
+ show_header=False,
863
+ box=None,
864
+ padding=0,
865
+ expand=True,
866
+ pad_edge=False,
867
+ )
868
+ # 左右布局,左侧为总结信息,右侧为统计表格
869
+ layout_table.add_column(ratio=5) # 左侧
870
+ layout_table.add_column(ratio=5) # 右侧
871
+
872
+ if has_content:
873
+ # 将总结信息放在左侧,统计表格放在右侧(表格居中显示)
874
+ layout_table.add_row(right_column_group, Align.center(table))
875
+ else:
876
+ # 如果没有统计数据,则总结信息占满
877
+ layout_table.add_row(right_column_group)
878
+ layout_renderable = layout_table
879
+
880
+ # 打印最终的布局
881
+ if has_content or summary_content:
882
+ # 将整体布局封装在一个最终的Panel中,以提供整体边框
883
+ final_panel = Panel(
884
+ layout_renderable,
885
+ title="Jarvis AI Assistant",
886
+ title_align="center",
887
+ border_style="blue",
888
+ box=box.HEAVY,
889
+ padding=(0, 1),
890
+ )
891
+ console.print(final_panel)
892
+ except Exception as e:
893
+ # 输出错误信息以便调试
894
+ import traceback
895
+
896
+ PrettyOutput.print(f"统计显示出错: {str(e)}", OutputType.ERROR)
897
+ PrettyOutput.print(traceback.format_exc(), OutputType.ERROR)
77
898
 
78
899
 
79
900
  def init_env(welcome_str: str, config_file: Optional[str] = None) -> None:
@@ -83,27 +904,182 @@ def init_env(welcome_str: str, config_file: Optional[str] = None) -> None:
83
904
  welcome_str: 欢迎信息字符串
84
905
  config_file: 配置文件路径,默认为None(使用~/.jarvis/config.yaml)
85
906
  """
907
+ # 0. 检查是否处于Jarvis打开的终端环境,避免嵌套
908
+ try:
909
+ if os.environ.get("JARVIS_TERMINAL") == "1":
910
+ PrettyOutput.print(
911
+ "检测到当前终端由 Jarvis 打开。再次启动可能导致嵌套。",
912
+ OutputType.WARNING,
913
+ )
914
+ if not user_confirm("是否仍要继续启动 Jarvis?", default=False):
915
+ PrettyOutput.print("已取消启动以避免终端嵌套。", OutputType.INFO)
916
+ sys.exit(0)
917
+ except Exception:
918
+ pass
919
+
86
920
  # 1. 设置信号处理
87
921
  _setup_signal_handler()
88
922
 
89
923
  # 2. 统计命令使用
90
924
  count_cmd_usage()
91
925
 
92
- # 3. 显示欢迎信息
93
- if welcome_str:
94
- _show_welcome_message(welcome_str)
95
-
96
- # 4. 设置配置文件
926
+ # 3. 设置配置文件
97
927
  global g_config_file
98
928
  g_config_file = config_file
99
929
  load_config()
100
930
 
101
- # 5. 检查git更新
102
- if _check_git_updates():
931
+ # 4. 显示历史统计数据(仅在显示欢迎信息时显示)
932
+ if welcome_str:
933
+ _show_usage_stats(welcome_str)
934
+
935
+ # 5. 检查Jarvis更新
936
+ if _check_jarvis_updates():
103
937
  os.execv(sys.executable, [sys.executable] + sys.argv)
104
938
  sys.exit(0)
105
939
 
106
940
 
941
+ def _interactive_config_setup(config_file_path: Path):
942
+ """交互式配置引导"""
943
+ from jarvis.jarvis_platform.registry import PlatformRegistry
944
+ from jarvis.jarvis_utils.input import (
945
+ get_choice,
946
+ get_single_line_input as get_input,
947
+ user_confirm as get_yes_no,
948
+ )
949
+
950
+ PrettyOutput.print(
951
+ "欢迎使用 Jarvis!未找到配置文件,现在开始引导配置。", OutputType.INFO
952
+ )
953
+
954
+ # 1. 选择平台
955
+ registry = PlatformRegistry.get_global_platform_registry()
956
+ platforms = registry.get_available_platforms()
957
+ platform_name = get_choice("请选择您要使用的AI平台", platforms)
958
+
959
+ # 2. 配置环境变量
960
+ platform_class = registry.platforms.get(platform_name)
961
+ if not platform_class:
962
+ PrettyOutput.print(f"平台 '{platform_name}' 加载失败。", OutputType.ERROR)
963
+ sys.exit(1)
964
+
965
+ env_vars = {}
966
+ required_keys = platform_class.get_required_env_keys()
967
+ defaults = platform_class.get_env_defaults()
968
+ config_guide = platform_class.get_env_config_guide()
969
+ if required_keys:
970
+ PrettyOutput.print(
971
+ f"请输入 {platform_name} 平台所需的配置信息:", OutputType.INFO
972
+ )
973
+
974
+ # 如果有配置指导,先显示总体说明
975
+ if config_guide:
976
+ # 为避免 PrettyOutput 在循环中为每行加框,先拼接后统一打印
977
+ guide_lines = ["", "配置获取方法:"]
978
+ for key in required_keys:
979
+ if key in config_guide and config_guide[key]:
980
+ guide_lines.append("")
981
+ guide_lines.append(f"{key} 获取方法:")
982
+ guide_lines.append(str(config_guide[key]))
983
+ PrettyOutput.print("\n".join(guide_lines), OutputType.INFO)
984
+ else:
985
+ # 若无指导,仍需遍历以保持后续逻辑一致
986
+ pass
987
+
988
+ for key in required_keys:
989
+ # 显示该环境变量的配置指导(上文已统一打印,此处不再逐条打印)
990
+
991
+ default_value = defaults.get(key, "")
992
+ prompt_text = f" - {key}"
993
+ if default_value:
994
+ prompt_text += f" (默认: {default_value})"
995
+ prompt_text += ": "
996
+
997
+ value = get_input(prompt_text, default=default_value)
998
+ env_vars[key] = value
999
+ os.environ[key] = value # 立即设置环境变量以便后续测试
1000
+
1001
+ # 3. 选择模型
1002
+ try:
1003
+ platform_instance = registry.create_platform(platform_name)
1004
+ if not platform_instance:
1005
+ PrettyOutput.print(f"无法创建平台 '{platform_name}'。", OutputType.ERROR)
1006
+ sys.exit(1)
1007
+
1008
+ model_list_tuples = platform_instance.get_model_list()
1009
+ model_choices = [f"{name} ({desc})" for name, desc in model_list_tuples]
1010
+ model_display_name = get_choice("请选择要使用的模型", model_choices)
1011
+
1012
+ # 从显示名称反向查找模型ID
1013
+ selected_index = model_choices.index(model_display_name)
1014
+ model_name, _ = model_list_tuples[selected_index]
1015
+
1016
+ except Exception:
1017
+ PrettyOutput.print("获取模型列表失败", OutputType.ERROR)
1018
+ if not get_yes_no("无法获取模型列表,是否继续配置?"):
1019
+ sys.exit(1)
1020
+ model_name = get_input("请输入模型名称:")
1021
+
1022
+ # 4. 测试配置
1023
+ PrettyOutput.print("正在测试配置...", OutputType.INFO)
1024
+ test_passed = False
1025
+ try:
1026
+ platform_instance = registry.create_platform(platform_name)
1027
+ if platform_instance:
1028
+ platform_instance.set_model_name(model_name)
1029
+ response_generator = platform_instance.chat("hello")
1030
+ response = "".join(response_generator)
1031
+ if response:
1032
+ PrettyOutput.print(
1033
+ f"测试成功,模型响应: {response}", OutputType.SUCCESS
1034
+ )
1035
+ test_passed = True
1036
+ else:
1037
+ PrettyOutput.print("测试失败,模型没有响应。", OutputType.ERROR)
1038
+ else:
1039
+ PrettyOutput.print("测试失败,无法创建平台实例。", OutputType.ERROR)
1040
+ except Exception:
1041
+ PrettyOutput.print("测试失败", OutputType.ERROR)
1042
+
1043
+ # 5. 交互式确认并应用配置(不直接生成配置文件)
1044
+ config_data = {
1045
+ "ENV": env_vars,
1046
+ "JARVIS_PLATFORM": platform_name,
1047
+ "JARVIS_MODEL": model_name,
1048
+ }
1049
+
1050
+ if not test_passed:
1051
+ if not get_yes_no("配置测试失败,是否仍要应用该配置并继续?", default=False):
1052
+ PrettyOutput.print("已取消配置。", OutputType.INFO)
1053
+ sys.exit(0)
1054
+
1055
+ # 6. 选择其他功能开关与可选项(复用统一逻辑)
1056
+ _collect_optional_config_interactively(config_data)
1057
+
1058
+ # 7. 应用到当前会话并写入配置文件(基于交互结果,不从默认值生成)
1059
+ set_global_env_data(config_data)
1060
+ _process_env_variables(config_data)
1061
+ try:
1062
+ schema_path = (
1063
+ Path(__file__).parent.parent / "jarvis_data" / "config_schema.json"
1064
+ )
1065
+ config_file_path.parent.mkdir(parents=True, exist_ok=True)
1066
+ header = ""
1067
+ if schema_path.exists():
1068
+ header = f"# yaml-language-server: $schema={str(schema_path.absolute())}\n"
1069
+ _prune_defaults_with_schema(config_data)
1070
+ yaml_str = yaml.dump(config_data, allow_unicode=True, sort_keys=False)
1071
+ with open(config_file_path, "w", encoding="utf-8") as f:
1072
+ if header:
1073
+ f.write(header)
1074
+ f.write(yaml_str)
1075
+ PrettyOutput.print(f"配置文件已生成: {config_file_path}", OutputType.SUCCESS)
1076
+ PrettyOutput.print("配置完成,请重新启动Jarvis。", OutputType.INFO)
1077
+ sys.exit(0)
1078
+ except Exception:
1079
+ PrettyOutput.print("写入配置文件失败", OutputType.ERROR)
1080
+ sys.exit(1)
1081
+
1082
+
107
1083
  def load_config():
108
1084
  config_file = g_config_file
109
1085
  config_file_path = (
@@ -118,24 +1094,11 @@ def load_config():
118
1094
  if old_config_file.exists(): # 旧的配置文件存在
119
1095
  _read_old_config_file(old_config_file)
120
1096
  else:
121
- # 生成默认配置文件
122
- schema_path = (
123
- Path(__file__).parent.parent / "jarvis_data" / "config_schema.json"
124
- )
125
- if schema_path.exists():
126
- try:
127
- config_file_path.parent.mkdir(parents=True, exist_ok=True)
128
- generate_default_config(str(schema_path), str(config_file_path))
129
- PrettyOutput.print(
130
- f"已生成默认配置文件: {config_file_path}", OutputType.INFO
131
- )
132
- except Exception as e:
133
- PrettyOutput.print(f"生成默认配置文件失败: {e}", OutputType.ERROR)
1097
+ _interactive_config_setup(config_file_path)
134
1098
  else:
135
1099
  _load_and_process_config(str(config_file_path.parent), str(config_file_path))
136
1100
 
137
1101
 
138
- from typing import Tuple
139
1102
 
140
1103
 
141
1104
  def _load_config_file(config_file: str) -> Tuple[str, dict]:
@@ -191,6 +1154,563 @@ def _process_env_variables(config_data: dict) -> None:
191
1154
  )
192
1155
 
193
1156
 
1157
+ def _ask_config_bool(config_data: dict, ask_all: bool, _key: str, _tip: str, _default: bool) -> bool:
1158
+ """询问并设置布尔类型配置项"""
1159
+ try:
1160
+ if not ask_all and _key in config_data:
1161
+ return False
1162
+ from jarvis.jarvis_utils.input import user_confirm as get_yes_no
1163
+ cur = bool(config_data.get(_key, _default))
1164
+ val = get_yes_no(_tip, default=cur)
1165
+ if bool(val) == cur:
1166
+ return False
1167
+ config_data[_key] = bool(val)
1168
+ return True
1169
+ except Exception:
1170
+ return False
1171
+
1172
+
1173
+ def _ask_config_str(config_data: dict, ask_all: bool, _key: str, _tip: str, _default: str = "") -> bool:
1174
+ """询问并设置字符串类型配置项"""
1175
+ try:
1176
+ if not ask_all and _key in config_data:
1177
+ return False
1178
+ from jarvis.jarvis_utils.input import get_single_line_input
1179
+ cur = str(config_data.get(_key, _default or ""))
1180
+ val = get_single_line_input(f"{_tip}", default=cur)
1181
+ v = ("" if val is None else str(val)).strip()
1182
+ if v == cur:
1183
+ return False
1184
+ config_data[_key] = v
1185
+ return True
1186
+ except Exception:
1187
+ return False
1188
+
1189
+
1190
+ def _ask_config_optional_str(config_data: dict, ask_all: bool, _key: str, _tip: str, _default: str = "") -> bool:
1191
+ """询问并设置可选字符串类型配置项(空输入表示不改变)"""
1192
+ try:
1193
+ if not ask_all and _key in config_data:
1194
+ return False
1195
+ from jarvis.jarvis_utils.input import get_single_line_input
1196
+ cur = str(config_data.get(_key, _default or ""))
1197
+ val = get_single_line_input(f"{_tip}", default=cur)
1198
+ if val is None:
1199
+ return False
1200
+ s = str(val).strip()
1201
+ if s == "" or s == cur:
1202
+ return False
1203
+ config_data[_key] = s
1204
+ return True
1205
+ except Exception:
1206
+ return False
1207
+
1208
+
1209
+ def _ask_config_int(config_data: dict, ask_all: bool, _key: str, _tip: str, _default: int) -> bool:
1210
+ """询问并设置整数类型配置项"""
1211
+ try:
1212
+ if not ask_all and _key in config_data:
1213
+ return False
1214
+ from jarvis.jarvis_utils.input import get_single_line_input
1215
+ cur = str(config_data.get(_key, _default))
1216
+ val_str = get_single_line_input(f"{_tip}", default=cur)
1217
+ s = "" if val_str is None else str(val_str).strip()
1218
+ if s == "" or s == cur:
1219
+ return False
1220
+ try:
1221
+ v = int(s)
1222
+ except Exception:
1223
+ return False
1224
+ if str(v) == cur:
1225
+ return False
1226
+ config_data[_key] = v
1227
+ return True
1228
+ except Exception:
1229
+ return False
1230
+
1231
+
1232
+ def _ask_config_list(config_data: dict, ask_all: bool, _key: str, _tip: str) -> bool:
1233
+ """询问并设置列表类型配置项(逗号分隔)"""
1234
+ try:
1235
+ if not ask_all and _key in config_data:
1236
+ return False
1237
+ from jarvis.jarvis_utils.input import get_single_line_input
1238
+ cur_val = config_data.get(_key, [])
1239
+ if isinstance(cur_val, list):
1240
+ cur_display = ", ".join([str(x) for x in cur_val])
1241
+ else:
1242
+ cur_display = str(cur_val or "")
1243
+ val = get_single_line_input(f"{_tip}", default=cur_display)
1244
+ if val is None:
1245
+ return False
1246
+ s = str(val).strip()
1247
+ if s == cur_display.strip():
1248
+ return False
1249
+ if not s:
1250
+ return False
1251
+ items = [x.strip() for x in s.split(",") if x.strip()]
1252
+ if isinstance(cur_val, list) and items == cur_val:
1253
+ return False
1254
+ config_data[_key] = items
1255
+ return True
1256
+ except Exception:
1257
+ return False
1258
+
1259
+
1260
+ def _collect_basic_switches(config_data: dict, ask_all: bool) -> bool:
1261
+ """收集基础开关配置"""
1262
+ changed = False
1263
+ changed = _ask_config_bool(
1264
+ config_data, ask_all,
1265
+ "JARVIS_ENABLE_GIT_JCA_SWITCH",
1266
+ "是否在检测到Git仓库时,提示并可自动切换到代码开发模式(jca)?",
1267
+ False,
1268
+ ) or changed
1269
+ changed = _ask_config_bool(
1270
+ config_data, ask_all,
1271
+ "JARVIS_ENABLE_STARTUP_CONFIG_SELECTOR",
1272
+ "在进入默认通用代理前,是否先列出可用配置(agent/multi_agent/roles)供选择?",
1273
+ False,
1274
+ ) or changed
1275
+ return changed
1276
+
1277
+
1278
+ def _collect_ui_experience_config(config_data: dict, ask_all: bool) -> bool:
1279
+ """收集UI体验相关配置"""
1280
+ changed = False
1281
+ try:
1282
+ import platform as _platform_mod
1283
+ _default_pretty = False if _platform_mod.system() == "Windows" else True
1284
+ except Exception:
1285
+ _default_pretty = True
1286
+
1287
+ changed = _ask_config_bool(
1288
+ config_data, ask_all,
1289
+ "JARVIS_PRETTY_OUTPUT",
1290
+ "是否启用更美观的终端输出(Pretty Output)?",
1291
+ _default_pretty,
1292
+ ) or changed
1293
+ changed = _ask_config_bool(
1294
+ config_data, ask_all,
1295
+ "JARVIS_PRINT_PROMPT",
1296
+ "是否打印发送给模型的提示词(Prompt)?",
1297
+ False,
1298
+ ) or changed
1299
+ changed = _ask_config_bool(
1300
+ config_data, ask_all,
1301
+ "JARVIS_IMMEDIATE_ABORT",
1302
+ "是否启用立即中断?\n- 选择 是/true:在对话输出流的每次迭代中检测到用户中断(例如 Ctrl+C)时,立即返回当前已生成的内容并停止继续输出。\n- 选择 否/false:不会在输出过程中立刻返回,而是按既有流程处理(不中途打断输出)。",
1303
+ False,
1304
+ ) or changed
1305
+ return changed
1306
+
1307
+
1308
+ def _collect_analysis_config(config_data: dict, ask_all: bool) -> bool:
1309
+ """收集代码分析相关配置"""
1310
+ changed = False
1311
+ changed = _ask_config_bool(
1312
+ config_data, ask_all,
1313
+ "JARVIS_ENABLE_STATIC_ANALYSIS",
1314
+ "是否启用静态代码分析(Static Analysis)?",
1315
+ True,
1316
+ ) or changed
1317
+ changed = _ask_config_bool(
1318
+ config_data, ask_all,
1319
+ "JARVIS_ENABLE_BUILD_VALIDATION",
1320
+ "是否启用构建验证(Build Validation)?在代码编辑后自动验证代码能否成功编译/构建。",
1321
+ True,
1322
+ ) or changed
1323
+ changed = _ask_config_int(
1324
+ config_data, ask_all,
1325
+ "JARVIS_BUILD_VALIDATION_TIMEOUT",
1326
+ "构建验证的超时时间(秒,默认30秒)",
1327
+ 30,
1328
+ ) or changed
1329
+ changed = _ask_config_bool(
1330
+ config_data, ask_all,
1331
+ "JARVIS_ENABLE_IMPACT_ANALYSIS",
1332
+ "是否启用编辑影响范围分析(Impact Analysis)?分析代码编辑的影响范围,识别可能受影响的文件、函数、测试等。",
1333
+ True,
1334
+ ) or changed
1335
+ return changed
1336
+
1337
+
1338
+ def _collect_agent_features_config(config_data: dict, ask_all: bool) -> bool:
1339
+ """收集Agent功能相关配置"""
1340
+ changed = False
1341
+ changed = _ask_config_bool(
1342
+ config_data, ask_all,
1343
+ "JARVIS_USE_METHODOLOGY",
1344
+ "是否启用方法论系统(Methodology)?",
1345
+ True,
1346
+ ) or changed
1347
+ changed = _ask_config_bool(
1348
+ config_data, ask_all,
1349
+ "JARVIS_USE_ANALYSIS",
1350
+ "是否启用分析流程(Analysis)?",
1351
+ True,
1352
+ ) or changed
1353
+ changed = _ask_config_bool(
1354
+ config_data, ask_all,
1355
+ "JARVIS_FORCE_SAVE_MEMORY",
1356
+ "是否强制保存会话记忆?",
1357
+ False,
1358
+ ) or changed
1359
+ return changed
1360
+
1361
+
1362
+ def _collect_session_config(config_data: dict, ask_all: bool) -> bool:
1363
+ """收集会话与调试相关配置"""
1364
+ changed = False
1365
+ changed = _ask_config_bool(
1366
+ config_data, ask_all,
1367
+ "JARVIS_SAVE_SESSION_HISTORY",
1368
+ "是否保存会话记录?",
1369
+ False,
1370
+ ) or changed
1371
+ changed = _ask_config_bool(
1372
+ config_data, ask_all,
1373
+ "JARVIS_PRINT_ERROR_TRACEBACK",
1374
+ "是否在错误输出时打印回溯调用链?",
1375
+ False,
1376
+ ) or changed
1377
+ changed = _ask_config_bool(
1378
+ config_data, ask_all,
1379
+ "JARVIS_SKIP_PREDEFINED_TASKS",
1380
+ "是否跳过预定义任务加载(不读取 pre-command 列表)?",
1381
+ False,
1382
+ ) or changed
1383
+ return changed
1384
+
1385
+
1386
+ def _collect_safety_config(config_data: dict, ask_all: bool) -> bool:
1387
+ """收集代码与工具操作安全提示配置"""
1388
+ changed = False
1389
+ changed = _ask_config_bool(
1390
+ config_data, ask_all,
1391
+ "JARVIS_EXECUTE_TOOL_CONFIRM",
1392
+ "执行工具前是否需要确认?",
1393
+ False,
1394
+ ) or changed
1395
+ changed = _ask_config_bool(
1396
+ config_data, ask_all,
1397
+ "JARVIS_CONFIRM_BEFORE_APPLY_PATCH",
1398
+ "应用补丁前是否需要确认?",
1399
+ False,
1400
+ ) or changed
1401
+ return changed
1402
+
1403
+
1404
+ def _collect_data_and_token_config(config_data: dict, ask_all: bool) -> bool:
1405
+ """收集数据目录与最大输入Token配置"""
1406
+ changed = False
1407
+ from jarvis.jarvis_utils.config import get_data_dir as _get_data_dir
1408
+ changed = _ask_config_optional_str(
1409
+ config_data, ask_all,
1410
+ "JARVIS_DATA_PATH",
1411
+ f"是否自定义数据目录路径(JARVIS_DATA_PATH)?留空使用默认: {_get_data_dir()}",
1412
+ ) or changed
1413
+ changed = _ask_config_int(
1414
+ config_data, ask_all,
1415
+ "JARVIS_MAX_INPUT_TOKEN_COUNT",
1416
+ "自定义最大输入Token数量(留空使用默认: 32000)",
1417
+ 32000,
1418
+ ) or changed
1419
+ changed = _ask_config_int(
1420
+ config_data, ask_all,
1421
+ "JARVIS_TOOL_FILTER_THRESHOLD",
1422
+ "设置AI工具筛选阈值 (当可用工具数超过此值时触发AI筛选, 默认30)",
1423
+ 30,
1424
+ ) or changed
1425
+ return changed
1426
+
1427
+
1428
+ def _collect_planning_config(config_data: dict, ask_all: bool) -> bool:
1429
+ """收集规划相关配置"""
1430
+ changed = False
1431
+ changed = _ask_config_bool(
1432
+ config_data, ask_all,
1433
+ "JARVIS_PLAN_ENABLED",
1434
+ "是否默认启用任务规划?当 Agent 初始化时 plan 参数未指定,将从此配置加载",
1435
+ True,
1436
+ ) or changed
1437
+ changed = _ask_config_int(
1438
+ config_data, ask_all,
1439
+ "JARVIS_PLAN_MAX_DEPTH",
1440
+ "任务规划的最大层数(限制递归拆分深度,默认2;仅在启用规划时生效)",
1441
+ 2,
1442
+ ) or changed
1443
+ return changed
1444
+
1445
+
1446
+ def _collect_advanced_config(config_data: dict, ask_all: bool) -> bool:
1447
+ """收集高级配置(自动总结、脚本超时等)"""
1448
+ changed = False
1449
+ changed = _ask_config_int(
1450
+ config_data, ask_all,
1451
+ "JARVIS_AUTO_SUMMARY_ROUNDS",
1452
+ "基于对话轮次的自动总结阈值(达到该轮次后自动总结并清理历史,默认50)",
1453
+ 50,
1454
+ ) or changed
1455
+ changed = _ask_config_int(
1456
+ config_data, ask_all,
1457
+ "JARVIS_SCRIPT_EXECUTION_TIMEOUT",
1458
+ "脚本执行超时时间(秒,默认300,仅非交互模式生效)",
1459
+ 300,
1460
+ ) or changed
1461
+ changed = _ask_config_int(
1462
+ config_data, ask_all,
1463
+ "JARVIS_ADDON_PROMPT_THRESHOLD",
1464
+ "附加提示的触发阈值(字符数,默认1024)。当消息长度超过此值时,会自动添加默认的附加提示",
1465
+ 1024,
1466
+ ) or changed
1467
+ changed = _ask_config_bool(
1468
+ config_data, ask_all,
1469
+ "JARVIS_ENABLE_INTENT_RECOGNITION",
1470
+ "是否启用意图识别功能?用于智能上下文推荐中的LLM意图提取和语义分析",
1471
+ True,
1472
+ ) or changed
1473
+ return changed
1474
+
1475
+
1476
+ def _collect_directory_config(config_data: dict, ask_all: bool) -> bool:
1477
+ """收集目录类配置(逗号分隔)"""
1478
+ changed = False
1479
+ changed = _ask_config_list(
1480
+ config_data, ask_all,
1481
+ "JARVIS_TOOL_LOAD_DIRS",
1482
+ "指定工具加载目录(逗号分隔,留空跳过):",
1483
+ ) or changed
1484
+ changed = _ask_config_list(
1485
+ config_data, ask_all,
1486
+ "JARVIS_METHODOLOGY_DIRS",
1487
+ "指定方法论加载目录(逗号分隔,留空跳过):",
1488
+ ) or changed
1489
+ changed = _ask_config_list(
1490
+ config_data, ask_all,
1491
+ "JARVIS_AGENT_DEFINITION_DIRS",
1492
+ "指定 agent 定义加载目录(逗号分隔,留空跳过):",
1493
+ ) or changed
1494
+ changed = _ask_config_list(
1495
+ config_data, ask_all,
1496
+ "JARVIS_MULTI_AGENT_DIRS",
1497
+ "指定 multi_agent 加载目录(逗号分隔,留空跳过):",
1498
+ ) or changed
1499
+ changed = _ask_config_list(
1500
+ config_data, ask_all,
1501
+ "JARVIS_ROLES_DIRS",
1502
+ "指定 roles 加载目录(逗号分隔,留空跳过):",
1503
+ ) or changed
1504
+ changed = _ask_config_list(
1505
+ config_data, ask_all,
1506
+ "JARVIS_AFTER_TOOL_CALL_CB_DIRS",
1507
+ "指定工具调用后回调实现目录(逗号分隔,留空跳过):",
1508
+ ) or changed
1509
+ return changed
1510
+
1511
+
1512
+ def _collect_web_search_config(config_data: dict, ask_all: bool) -> bool:
1513
+ """收集Web搜索配置"""
1514
+ changed = False
1515
+ changed = _ask_config_optional_str(
1516
+ config_data, ask_all,
1517
+ "JARVIS_WEB_SEARCH_PLATFORM",
1518
+ "配置 Web 搜索平台名称(留空跳过):",
1519
+ ) or changed
1520
+ changed = _ask_config_optional_str(
1521
+ config_data, ask_all,
1522
+ "JARVIS_WEB_SEARCH_MODEL",
1523
+ "配置 Web 搜索模型名称(留空跳过):",
1524
+ ) or changed
1525
+ return changed
1526
+
1527
+
1528
+ def _ask_git_check_mode(config_data: dict, ask_all: bool) -> bool:
1529
+ """询问Git校验模式"""
1530
+ try:
1531
+ _key = "JARVIS_GIT_CHECK_MODE"
1532
+ if not ask_all and _key in config_data:
1533
+ return False
1534
+ from jarvis.jarvis_utils.input import get_choice
1535
+ from jarvis.jarvis_utils.config import get_git_check_mode
1536
+ current_mode = config_data.get(_key, get_git_check_mode())
1537
+ choices = ["strict", "warn"]
1538
+ tip = (
1539
+ "请选择 Git 仓库检查模式 (JARVIS_GIT_CHECK_MODE):\n"
1540
+ "此设置决定了当在 Git 仓库中检测到未提交的更改时,Jarvis应如何处理。\n"
1541
+ "这对于确保代码修改和提交操作在干净的工作区上进行至关重要。\n"
1542
+ " - strict: (推荐) 如果存在未提交的更改,则中断相关操作(如代码修改、自动提交)。\n"
1543
+ " 这可以防止意外覆盖或丢失本地工作。\n"
1544
+ " - warn: 如果存在未提交的更改,仅显示警告信息,然后继续执行操作。\n"
1545
+ " 适用于您希望绕过检查并自行管理仓库状态的场景。"
1546
+ )
1547
+ new_mode = get_choice(tip, choices)
1548
+ if new_mode == current_mode:
1549
+ return False
1550
+ config_data[_key] = new_mode
1551
+ return True
1552
+ except Exception:
1553
+ return False
1554
+
1555
+
1556
+ def _ask_patch_format_mode(config_data: dict, ask_all: bool) -> bool:
1557
+ """询问补丁格式模式"""
1558
+ try:
1559
+ _key = "JARVIS_PATCH_FORMAT"
1560
+ if not ask_all and _key in config_data:
1561
+ return False
1562
+ from jarvis.jarvis_utils.input import get_choice
1563
+ from jarvis.jarvis_utils.config import get_patch_format
1564
+ current_mode = config_data.get(_key, get_patch_format())
1565
+ choices = ["all", "search", "search_range"]
1566
+ tip = (
1567
+ "请选择补丁格式处理模式 (JARVIS_PATCH_FORMAT):\n"
1568
+ "该设置影响 edit_file_handler 在处理补丁时允许的匹配方式。\n"
1569
+ " - all: 同时支持 SEARCH 与 SEARCH_START/SEARCH_END 两种模式(默认)。\n"
1570
+ " - search: 仅允许精确片段匹配(SEARCH)。更稳定,适合较弱模型或严格控制改动。\n"
1571
+ " - search_range: 仅允许范围匹配(SEARCH_START/SEARCH_END)。更灵活,适合较强模型和块内细粒度修改。"
1572
+ )
1573
+ new_mode = get_choice(tip, choices)
1574
+ if new_mode == current_mode:
1575
+ return False
1576
+ config_data[_key] = new_mode
1577
+ return True
1578
+ except Exception:
1579
+ return False
1580
+
1581
+
1582
+ def _collect_git_config(config_data: dict, ask_all: bool) -> bool:
1583
+ """收集Git相关配置"""
1584
+ changed = False
1585
+ changed = _ask_git_check_mode(config_data, ask_all) or changed
1586
+ changed = _ask_patch_format_mode(config_data, ask_all) or changed
1587
+ changed = _ask_config_optional_str(
1588
+ config_data, ask_all,
1589
+ "JARVIS_GIT_COMMIT_PROMPT",
1590
+ "自定义 Git 提交提示模板(留空跳过):",
1591
+ ) or changed
1592
+ return changed
1593
+
1594
+
1595
+ def _collect_rag_config(config_data: dict, ask_all: bool) -> bool:
1596
+ """收集RAG配置"""
1597
+ changed = False
1598
+ try:
1599
+ from jarvis.jarvis_utils.config import (
1600
+ get_rag_embedding_model as _get_rag_embedding_model,
1601
+ get_rag_rerank_model as _get_rag_rerank_model,
1602
+ )
1603
+ from jarvis.jarvis_utils.input import user_confirm as get_yes_no
1604
+ from jarvis.jarvis_utils.input import get_single_line_input
1605
+
1606
+ rag_default_embed = _get_rag_embedding_model()
1607
+ rag_default_rerank = _get_rag_rerank_model()
1608
+ except Exception:
1609
+ rag_default_embed = "BAAI/bge-m3"
1610
+ rag_default_rerank = "BAAI/bge-reranker-v2-m3"
1611
+ get_yes_no = None
1612
+ get_single_line_input = None
1613
+
1614
+ try:
1615
+ if "JARVIS_RAG" not in config_data and get_yes_no:
1616
+ if get_yes_no("是否配置 RAG 检索增强参数?", default=False):
1617
+ rag_conf: Dict[str, Any] = {}
1618
+ emb = get_single_line_input(
1619
+ f"RAG 嵌入模型(留空使用默认: {rag_default_embed}):",
1620
+ default="",
1621
+ ).strip()
1622
+ rerank = get_single_line_input(
1623
+ f"RAG rerank 模型(留空使用默认: {rag_default_rerank}):",
1624
+ default="",
1625
+ ).strip()
1626
+ use_bm25 = get_yes_no("RAG 是否使用 BM25?", default=True)
1627
+ use_rerank = get_yes_no("RAG 是否使用 rerank?", default=True)
1628
+ if emb:
1629
+ rag_conf["embedding_model"] = emb
1630
+ else:
1631
+ rag_conf["embedding_model"] = rag_default_embed
1632
+ if rerank:
1633
+ rag_conf["rerank_model"] = rerank
1634
+ else:
1635
+ rag_conf["rerank_model"] = rag_default_rerank
1636
+ rag_conf["use_bm25"] = bool(use_bm25)
1637
+ rag_conf["use_rerank"] = bool(use_rerank)
1638
+ config_data["JARVIS_RAG"] = rag_conf
1639
+ changed = True
1640
+ except Exception:
1641
+ pass
1642
+ return changed
1643
+
1644
+
1645
+ def _collect_central_repo_config(config_data: dict, ask_all: bool) -> bool:
1646
+ """收集中心仓库配置"""
1647
+ changed = False
1648
+ changed = _ask_config_str(
1649
+ config_data, ask_all,
1650
+ "JARVIS_CENTRAL_METHODOLOGY_REPO",
1651
+ "请输入中心方法论仓库路径或Git地址(可留空跳过):",
1652
+ "",
1653
+ ) or changed
1654
+ changed = _ask_config_str(
1655
+ config_data, ask_all,
1656
+ "JARVIS_CENTRAL_TOOL_REPO",
1657
+ "请输入中心工具仓库路径或Git地址(可留空跳过):",
1658
+ "",
1659
+ ) or changed
1660
+ return changed
1661
+
1662
+
1663
+ def _collect_shell_config(config_data: dict, ask_all: bool) -> bool:
1664
+ """收集SHELL覆盖配置"""
1665
+ changed = False
1666
+ try:
1667
+ import os
1668
+ default_shell = os.getenv("SHELL", "/bin/bash")
1669
+ changed = _ask_config_optional_str(
1670
+ config_data, ask_all,
1671
+ "SHELL",
1672
+ f"覆盖 SHELL 路径(留空使用系统默认: {default_shell}):",
1673
+ default_shell,
1674
+ ) or changed
1675
+ except Exception:
1676
+ pass
1677
+ return changed
1678
+
1679
+
1680
+ def _collect_optional_config_interactively(
1681
+ config_data: dict, ask_all: bool = False
1682
+ ) -> bool:
1683
+ """
1684
+ 复用的交互式配置收集逻辑:
1685
+ - ask_all=False(默认):仅对缺省的新功能开关/可选项逐项询问,已存在项跳过
1686
+ - ask_all=True:对所有项进行询问,默认值取自当前配置文件,可覆盖现有设置
1687
+ - 修改传入的 config_data
1688
+ - 包含更多来自 config.py 的可选项
1689
+ 返回:
1690
+ bool: 是否有变更
1691
+ """
1692
+ changed = False
1693
+
1694
+ # 收集各类配置
1695
+ changed = _collect_basic_switches(config_data, ask_all) or changed
1696
+ changed = _collect_ui_experience_config(config_data, ask_all) or changed
1697
+ changed = _collect_analysis_config(config_data, ask_all) or changed
1698
+ changed = _collect_agent_features_config(config_data, ask_all) or changed
1699
+ changed = _collect_session_config(config_data, ask_all) or changed
1700
+ changed = _collect_safety_config(config_data, ask_all) or changed
1701
+ changed = _collect_data_and_token_config(config_data, ask_all) or changed
1702
+ changed = _collect_planning_config(config_data, ask_all) or changed
1703
+ changed = _collect_advanced_config(config_data, ask_all) or changed
1704
+ changed = _collect_directory_config(config_data, ask_all) or changed
1705
+ changed = _collect_web_search_config(config_data, ask_all) or changed
1706
+ changed = _collect_git_config(config_data, ask_all) or changed
1707
+ changed = _collect_rag_config(config_data, ask_all) or changed
1708
+ changed = _collect_central_repo_config(config_data, ask_all) or changed
1709
+ changed = _collect_shell_config(config_data, ask_all) or changed
1710
+
1711
+ return changed
1712
+
1713
+
194
1714
  def _load_and_process_config(jarvis_dir: str, config_file: str) -> None:
195
1715
  """加载并处理配置文件
196
1716
 
@@ -204,10 +1724,54 @@ def _load_and_process_config(jarvis_dir: str, config_file: str) -> None:
204
1724
  jarvis_dir: Jarvis数据目录路径
205
1725
  config_file: 配置文件路径
206
1726
  """
207
- content, config_data = _load_config_file(config_file)
208
- _ensure_schema_declaration(jarvis_dir, config_file, content, config_data)
209
- set_global_env_data(config_data)
210
- _process_env_variables(config_data)
1727
+ from jarvis.jarvis_utils.input import user_confirm as get_yes_no
1728
+
1729
+ try:
1730
+ content, config_data = _load_config_file(config_file)
1731
+ _ensure_schema_declaration(jarvis_dir, config_file, content, config_data)
1732
+ set_global_env_data(config_data)
1733
+ _process_env_variables(config_data)
1734
+
1735
+ # 加载 schema 默认并剔除等于默认值的项
1736
+ pruned = _prune_defaults_with_schema(config_data)
1737
+
1738
+ if pruned:
1739
+ # 保留schema声明,如无则自动补充
1740
+ header = ""
1741
+ try:
1742
+ with open(config_file, "r", encoding="utf-8") as rf:
1743
+ first_line = rf.readline()
1744
+ if first_line.startswith("# yaml-language-server: $schema="):
1745
+ header = first_line
1746
+ except Exception:
1747
+ header = ""
1748
+ yaml_str = yaml.dump(config_data, allow_unicode=True, sort_keys=False)
1749
+ if not header:
1750
+ schema_path = Path(
1751
+ os.path.relpath(
1752
+ Path(__file__).parent.parent
1753
+ / "jarvis_data"
1754
+ / "config_schema.json",
1755
+ start=jarvis_dir,
1756
+ )
1757
+ )
1758
+ header = f"# yaml-language-server: $schema={schema_path}\n"
1759
+ with open(config_file, "w", encoding="utf-8") as wf:
1760
+ wf.write(header)
1761
+ wf.write(yaml_str)
1762
+ # 更新全局配置
1763
+ set_global_env_data(config_data)
1764
+ except Exception:
1765
+ PrettyOutput.print("加载配置文件失败", OutputType.ERROR)
1766
+ if get_yes_no("配置文件格式错误,是否删除并重新配置?"):
1767
+ try:
1768
+ os.remove(config_file)
1769
+ PrettyOutput.print(
1770
+ "已删除损坏的配置文件,请重启Jarvis以重新配置。", OutputType.SUCCESS
1771
+ )
1772
+ except Exception:
1773
+ PrettyOutput.print("删除配置文件失败", OutputType.ERROR)
1774
+ sys.exit(1)
211
1775
 
212
1776
 
213
1777
  def generate_default_config(schema_path: str, output_path: str) -> None:
@@ -241,20 +1805,76 @@ def generate_default_config(schema_path: str, output_path: str) -> None:
241
1805
 
242
1806
  default_config = _generate_from_schema(schema)
243
1807
 
244
- # 添加schema声明
245
- rel_schema_path = Path(
246
- os.path.relpath(
247
- Path(schema_path),
248
- start=Path(output_path).parent,
249
- )
250
- )
251
- content = f"# yaml-language-server: $schema={rel_schema_path}\n"
1808
+ content = f"# yaml-language-server: $schema={schema_path}\n"
252
1809
  content += yaml.dump(default_config, allow_unicode=True, sort_keys=False)
253
1810
 
254
1811
  with open(output_path, "w", encoding="utf-8") as f:
255
1812
  f.write(content)
256
1813
 
257
1814
 
1815
+ def _load_default_config_from_schema() -> dict:
1816
+ """从 schema 生成默认配置字典,用于对比并剔除等于默认值的键"""
1817
+ try:
1818
+ schema_path = (
1819
+ Path(__file__).parent.parent / "jarvis_data" / "config_schema.json"
1820
+ )
1821
+ if not schema_path.exists():
1822
+ return {}
1823
+ with open(schema_path, "r", encoding="utf-8") as f:
1824
+ schema = json.load(f)
1825
+
1826
+ def _generate_from_schema(schema_dict: Dict[str, Any]) -> Dict[str, Any]:
1827
+ cfg: Dict[str, Any] = {}
1828
+ if isinstance(schema_dict, dict) and "properties" in schema_dict:
1829
+ for key, value in schema_dict["properties"].items():
1830
+ if "default" in value:
1831
+ cfg[key] = value["default"]
1832
+ elif value.get("type") == "array":
1833
+ cfg[key] = []
1834
+ elif "properties" in value:
1835
+ cfg[key] = _generate_from_schema(value)
1836
+ return cfg
1837
+
1838
+ return _generate_from_schema(schema)
1839
+ except Exception:
1840
+ return {}
1841
+
1842
+
1843
+ def _prune_defaults_with_schema(config_data: dict) -> bool:
1844
+ """
1845
+ 删除与 schema 默认值一致的配置项,返回是否发生了变更
1846
+ 仅处理 schema 中定义的键,未在 schema 中的键不会被修改
1847
+ """
1848
+ defaults = _load_default_config_from_schema()
1849
+ if not defaults or not isinstance(config_data, dict):
1850
+ return False
1851
+
1852
+ changed = False
1853
+
1854
+ def _prune_node(node: dict, default_node: dict):
1855
+ nonlocal changed
1856
+ for key in list(node.keys()):
1857
+ if key in default_node:
1858
+ dv = default_node[key]
1859
+ v = node[key]
1860
+ if isinstance(dv, dict) and isinstance(v, dict):
1861
+ _prune_node(v, dv)
1862
+ if not v:
1863
+ del node[key]
1864
+ changed = True
1865
+ elif isinstance(dv, list) and isinstance(v, list):
1866
+ if v == dv:
1867
+ del node[key]
1868
+ changed = True
1869
+ else:
1870
+ if v == dv:
1871
+ del node[key]
1872
+ changed = True
1873
+
1874
+ _prune_node(config_data, defaults)
1875
+ return changed
1876
+
1877
+
258
1878
  def _read_old_config_file(config_file):
259
1879
  """读取并解析旧格式的env配置文件
260
1880
 
@@ -308,38 +1928,47 @@ def _read_old_config_file(config_file):
308
1928
  )
309
1929
  set_global_env_data(config_data)
310
1930
  PrettyOutput.print(
311
- f"检测到旧格式配置文件,旧格式以后将不再支持,请尽快迁移到新格式",
1931
+ "检测到旧格式配置文件,旧格式以后将不再支持,请尽快迁移到新格式",
312
1932
  OutputType.WARNING,
313
1933
  )
314
1934
 
315
1935
 
316
- def while_success(func: Callable[[], Any], sleep_time: float = 0.1) -> Any:
317
- """循环执行函数直到成功
1936
+ def while_success(func: Callable[[], Any], sleep_time: float = 0.1, max_retries: int = 5) -> Any:
1937
+ """循环执行函数直到成功(累计日志后统一打印,避免逐次加框)
318
1938
 
319
1939
  参数:
320
1940
  func -- 要执行的函数
321
1941
  sleep_time -- 每次失败后的等待时间(秒)
1942
+ max_retries -- 最大重试次数,默认5次
322
1943
 
323
1944
  返回:
324
1945
  函数执行结果
325
1946
  """
326
- while True:
1947
+ result: Any = None
1948
+ retry_count = 0
1949
+ while retry_count < max_retries:
327
1950
  try:
328
- return func()
1951
+ result = func()
1952
+ break
329
1953
  except Exception as e:
330
- PrettyOutput.print(
331
- f"执行失败: {str(e)}, 等待 {sleep_time}s...", OutputType.WARNING
332
- )
333
- time.sleep(sleep_time)
1954
+ retry_count += 1
1955
+ if retry_count < max_retries:
1956
+ PrettyOutput.print(
1957
+ f"发生异常:\n{e}\n重试中 ({retry_count}/{max_retries}),等待 {sleep_time}s...",
1958
+ OutputType.WARNING,
1959
+ )
1960
+ time.sleep(sleep_time)
334
1961
  continue
1962
+ return result
335
1963
 
336
1964
 
337
- def while_true(func: Callable[[], bool], sleep_time: float = 0.1) -> Any:
338
- """循环执行函数直到返回True
1965
+ def while_true(func: Callable[[], bool], sleep_time: float = 0.1, max_retries: int = 5) -> Any:
1966
+ """循环执行函数直到返回True(累计日志后统一打印,避免逐次加框)
339
1967
 
340
1968
  参数:
341
1969
  func: 要执行的函数,必须返回布尔值
342
1970
  sleep_time: 每次失败后的等待时间(秒)
1971
+ max_retries: 最大重试次数,默认5次
343
1972
 
344
1973
  返回:
345
1974
  函数最终返回的True值
@@ -348,12 +1977,19 @@ def while_true(func: Callable[[], bool], sleep_time: float = 0.1) -> Any:
348
1977
  与while_success不同,此函数只检查返回是否为True,
349
1978
  不捕获异常,异常会直接抛出
350
1979
  """
351
- while True:
1980
+ ret: bool = False
1981
+ retry_count = 0
1982
+ while retry_count < max_retries:
352
1983
  ret = func()
353
1984
  if ret:
354
1985
  break
355
- PrettyOutput.print(f"执行失败, 等待 {sleep_time}s...", OutputType.WARNING)
356
- time.sleep(sleep_time)
1986
+ retry_count += 1
1987
+ if retry_count < max_retries:
1988
+ PrettyOutput.print(
1989
+ f"返回空值,重试中 ({retry_count}/{max_retries}),等待 {sleep_time}s...",
1990
+ OutputType.WARNING,
1991
+ )
1992
+ time.sleep(sleep_time)
357
1993
  return ret
358
1994
 
359
1995
 
@@ -364,9 +2000,22 @@ def get_file_md5(filepath: str) -> str:
364
2000
  filepath: 要计算哈希的文件路径
365
2001
 
366
2002
  返回:
367
- str: 文件内容的MD5哈希值
2003
+ str: 文件内容的MD5哈希值(为降低内存占用,仅读取前100MB进行计算)
368
2004
  """
369
- return hashlib.md5(open(filepath, "rb").read(100 * 1024 * 1024)).hexdigest()
2005
+ # 采用流式读取,避免一次性加载100MB到内存
2006
+ h = hashlib.md5()
2007
+ max_bytes = 100 * 1024 * 1024 # 与原实现保持一致:仅读取前100MB
2008
+ buf_size = 8 * 1024 * 1024 # 8MB缓冲
2009
+ read_bytes = 0
2010
+ with open(filepath, "rb") as f:
2011
+ while read_bytes < max_bytes:
2012
+ to_read = min(buf_size, max_bytes - read_bytes)
2013
+ chunk = f.read(to_read)
2014
+ if not chunk:
2015
+ break
2016
+ h.update(chunk)
2017
+ read_bytes += len(chunk)
2018
+ return h.hexdigest()
370
2019
 
371
2020
 
372
2021
  def get_file_line_count(filename: str) -> int:
@@ -379,45 +2028,40 @@ def get_file_line_count(filename: str) -> int:
379
2028
  int: 文件中的行数,如果文件无法读取则返回0
380
2029
  """
381
2030
  try:
382
- return len(open(filename, "r", encoding="utf-8", errors="ignore").readlines())
383
- except Exception as e:
2031
+ # 使用流式逐行计数,避免将整个文件读入内存
2032
+ with open(filename, "r", encoding="utf-8", errors="ignore") as f:
2033
+ return sum(1 for _ in f)
2034
+ except Exception:
384
2035
  return 0
385
2036
 
386
2037
 
387
- def _get_cmd_stats() -> Dict[str, int]:
388
- """从数据目录获取命令调用统计"""
389
- stats_file = Path(get_data_dir()) / "cmd_stat.yaml"
390
- if stats_file.exists():
391
- try:
392
- with open(stats_file, "r", encoding="utf-8") as f:
393
- return yaml.safe_load(f) or {}
394
- except Exception as e:
395
- PrettyOutput.print(f"加载命令调用统计失败: {str(e)}", OutputType.WARNING)
396
- return {}
397
-
398
-
399
- def _update_cmd_stats(cmd_name: str) -> None:
400
- """更新命令调用统计"""
401
- stats = _get_cmd_stats()
402
- stats[cmd_name] = stats.get(cmd_name, 0) + 1
403
- stats_file = Path(get_data_dir()) / "cmd_stat.yaml"
404
- try:
405
- with open(stats_file, "w", encoding="utf-8") as f:
406
- yaml.safe_dump(stats, f, allow_unicode=True)
407
- except Exception as e:
408
- PrettyOutput.print(f"保存命令调用统计失败: {str(e)}", OutputType.WARNING)
409
-
410
-
411
2038
  def count_cmd_usage() -> None:
412
2039
  """统计当前命令的使用次数"""
413
2040
  import sys
2041
+ import os
2042
+ from jarvis.jarvis_stats.stats import StatsManager
414
2043
 
415
- _update_cmd_stats(sys.argv[0])
2044
+ # 从完整路径中提取命令名称
2045
+ cmd_path = sys.argv[0]
2046
+ cmd_name = os.path.basename(cmd_path)
2047
+
2048
+ # 如果是短命令,映射到长命令
2049
+ if cmd_name in COMMAND_MAPPING:
2050
+ metric_name = COMMAND_MAPPING[cmd_name]
2051
+ else:
2052
+ metric_name = cmd_name
416
2053
 
2054
+ # 使用 StatsManager 记录命令使用统计
2055
+ StatsManager.increment(metric_name, group="command")
417
2056
 
418
- def is_context_overflow(content: str) -> bool:
2057
+
2058
+ def is_context_overflow(
2059
+ content: str, model_group_override: Optional[str] = None
2060
+ ) -> bool:
419
2061
  """判断文件内容是否超出上下文限制"""
420
- return get_context_token_count(content) > get_max_big_content_size()
2062
+ return get_context_token_count(content) > get_max_big_content_size(
2063
+ model_group_override
2064
+ )
421
2065
 
422
2066
 
423
2067
  def get_loc_stats() -> str:
@@ -427,50 +2071,177 @@ def get_loc_stats() -> str:
427
2071
  str: loc命令输出的原始字符串,失败时返回空字符串
428
2072
  """
429
2073
  try:
430
- result = subprocess.run(["loc"], capture_output=True, text=True)
2074
+ result = subprocess.run(
2075
+ ["loc"], capture_output=True, text=True, encoding="utf-8", errors="replace"
2076
+ )
431
2077
  return result.stdout if result.returncode == 0 else ""
432
2078
  except FileNotFoundError:
433
2079
  return ""
434
2080
 
435
2081
 
436
- def copy_to_clipboard(text: str) -> None:
437
- """将文本复制到剪贴板,依次尝试xsel和xclip (非阻塞)
2082
+ def _pull_git_repo(repo_path: Path, repo_type: str):
2083
+ """对指定的git仓库执行git pull操作,并根据commit hash判断是否有更新。"""
2084
+ git_dir = repo_path / ".git"
2085
+ if not git_dir.is_dir():
2086
+ return
438
2087
 
439
- 参数:
440
- text: 要复制的文本
441
- """
442
- # 尝试使用 xsel
443
2088
  try:
444
- process = subprocess.Popen(
445
- ["xsel", "-b", "-i"],
446
- stdin=subprocess.PIPE,
447
- stdout=subprocess.DEVNULL,
448
- stderr=subprocess.DEVNULL,
2089
+ # 检查是否有远程仓库
2090
+ remote_result = subprocess.run(
2091
+ ["git", "remote"],
2092
+ cwd=repo_path,
2093
+ capture_output=True,
2094
+ text=True,
2095
+ encoding="utf-8",
2096
+ errors="replace",
2097
+ check=True,
2098
+ timeout=10,
2099
+ )
2100
+ if not remote_result.stdout.strip():
2101
+ return
2102
+
2103
+ # 检查git仓库状态
2104
+ status_result = subprocess.run(
2105
+ ["git", "status", "--porcelain"],
2106
+ cwd=repo_path,
2107
+ capture_output=True,
2108
+ text=True,
2109
+ encoding="utf-8",
2110
+ errors="replace",
2111
+ check=True,
2112
+ timeout=10,
2113
+ )
2114
+ if status_result.stdout:
2115
+ if user_confirm(
2116
+ f"检测到 '{repo_path.name}' 存在未提交的更改,是否放弃这些更改并更新?"
2117
+ ):
2118
+ try:
2119
+ subprocess.run(
2120
+ ["git", "checkout", "."],
2121
+ cwd=repo_path,
2122
+ capture_output=True,
2123
+ text=True,
2124
+ encoding="utf-8",
2125
+ errors="replace",
2126
+ check=True,
2127
+ timeout=10,
2128
+ )
2129
+ except (
2130
+ subprocess.CalledProcessError,
2131
+ subprocess.TimeoutExpired,
2132
+ FileNotFoundError,
2133
+ ) as e:
2134
+ PrettyOutput.print(
2135
+ f"放弃 '{repo_path.name}' 的更改失败: {str(e)}",
2136
+ OutputType.ERROR,
2137
+ )
2138
+ return
2139
+ else:
2140
+ PrettyOutput.print(
2141
+ f"跳过更新 '{repo_path.name}' 以保留未提交的更改。",
2142
+ OutputType.INFO,
2143
+ )
2144
+ return
2145
+
2146
+ # 获取更新前的commit hash
2147
+ before_hash_result = subprocess.run(
2148
+ ["git", "rev-parse", "HEAD"],
2149
+ cwd=repo_path,
2150
+ capture_output=True,
2151
+ text=True,
2152
+ encoding="utf-8",
2153
+ errors="replace",
2154
+ check=True,
2155
+ timeout=10,
2156
+ )
2157
+ before_hash = before_hash_result.stdout.strip()
2158
+
2159
+ # 检查是否是空仓库
2160
+ ls_remote_result = subprocess.run(
2161
+ ["git", "ls-remote", "--heads", "origin"],
2162
+ cwd=repo_path,
2163
+ capture_output=True,
2164
+ text=True,
2165
+ encoding="utf-8",
2166
+ errors="replace",
2167
+ check=True,
2168
+ timeout=10,
449
2169
  )
450
- if process.stdin:
451
- process.stdin.write(text.encode("utf-8"))
452
- process.stdin.close()
453
- return
454
- except FileNotFoundError:
455
- pass # xsel 未安装,继续尝试下一个
456
- except Exception as e:
457
- PrettyOutput.print(f"使用xsel时出错: {e}", OutputType.WARNING)
458
2170
 
459
- # 尝试使用 xclip
460
- try:
461
- process = subprocess.Popen(
462
- ["xclip", "-selection", "clipboard"],
463
- stdin=subprocess.PIPE,
464
- stdout=subprocess.DEVNULL,
465
- stderr=subprocess.DEVNULL,
2171
+ if not ls_remote_result.stdout.strip():
2172
+ return
2173
+
2174
+ # 执行 git pull
2175
+ subprocess.run(
2176
+ ["git", "pull"],
2177
+ cwd=repo_path,
2178
+ capture_output=True,
2179
+ text=True,
2180
+ check=True,
2181
+ timeout=60,
466
2182
  )
467
- if process.stdin:
468
- process.stdin.write(text.encode("utf-8"))
469
- process.stdin.close()
470
- return
2183
+
2184
+ # 获取更新后的commit hash
2185
+ after_hash_result = subprocess.run(
2186
+ ["git", "rev-parse", "HEAD"],
2187
+ cwd=repo_path,
2188
+ capture_output=True,
2189
+ text=True,
2190
+ check=True,
2191
+ timeout=10,
2192
+ )
2193
+ after_hash = after_hash_result.stdout.strip()
2194
+
2195
+ if before_hash != after_hash:
2196
+ PrettyOutput.print(
2197
+ f"{repo_type}库 '{repo_path.name}' 已更新。", OutputType.SUCCESS
2198
+ )
2199
+
471
2200
  except FileNotFoundError:
472
2201
  PrettyOutput.print(
473
- "xsel xclip 均未安装, 无法复制到剪贴板", OutputType.WARNING
2202
+ f"git 命令未找到,跳过更新 '{repo_path.name}'。", OutputType.WARNING
2203
+ )
2204
+ except subprocess.TimeoutExpired:
2205
+ PrettyOutput.print(f"更新 '{repo_path.name}' 超时。", OutputType.ERROR)
2206
+ except subprocess.CalledProcessError as e:
2207
+ error_message = e.stderr.strip() if e.stderr else str(e)
2208
+ PrettyOutput.print(
2209
+ f"更新 '{repo_path.name}' 失败: {error_message}", OutputType.ERROR
474
2210
  )
475
2211
  except Exception as e:
476
- PrettyOutput.print(f"使用xclip时出错: {e}", OutputType.WARNING)
2212
+ PrettyOutput.print(
2213
+ f"更新 '{repo_path.name}' 时发生未知错误: {str(e)}", OutputType.ERROR
2214
+ )
2215
+
2216
+
2217
+ def daily_check_git_updates(repo_dirs: List[str], repo_type: str):
2218
+ """
2219
+ 对指定的目录列表执行每日一次的git更新检查。
2220
+
2221
+ Args:
2222
+ repo_dirs (List[str]): 需要检查的git仓库目录列表。
2223
+ repo_type (str): 仓库的类型名称,例如 "工具" 或 "方法论",用于日志输出。
2224
+ """
2225
+ data_dir = Path(str(get_data_dir()))
2226
+ last_check_file = data_dir / f"{repo_type}_updates_last_check.txt"
2227
+ should_check_for_updates = True
2228
+
2229
+ if last_check_file.exists():
2230
+ try:
2231
+ last_check_timestamp = float(last_check_file.read_text())
2232
+ last_check_date = datetime.fromtimestamp(last_check_timestamp).date()
2233
+ if last_check_date == datetime.now().date():
2234
+ should_check_for_updates = False
2235
+ except (ValueError, IOError):
2236
+ pass
2237
+
2238
+ if should_check_for_updates:
2239
+
2240
+ for repo_dir in repo_dirs:
2241
+ p_repo_dir = Path(repo_dir)
2242
+ if p_repo_dir.exists() and p_repo_dir.is_dir():
2243
+ _pull_git_repo(p_repo_dir, repo_type)
2244
+ try:
2245
+ last_check_file.write_text(str(time.time()))
2246
+ except IOError as e:
2247
+ PrettyOutput.print(f"无法写入git更新检查时间戳: {e}", OutputType.WARNING)