jarvis-ai-assistant 0.3.30__py3-none-any.whl → 0.3.32__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.
jarvis/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
1
  # -*- coding: utf-8 -*-
2
2
  """Jarvis AI Assistant"""
3
3
 
4
- __version__ = "0.3.30"
4
+ __version__ = "0.3.32"
@@ -729,7 +729,14 @@ class Agent:
729
729
  pass
730
730
 
731
731
  response = self.model.chat_until_success(message) # type: ignore
732
-
732
+ # 防御: 模型可能返回空响应(None或空字符串),统一为空字符串并告警
733
+ if not response:
734
+ try:
735
+ PrettyOutput.print("模型返回空响应,已使用空字符串回退。", OutputType.WARNING)
736
+ except Exception:
737
+ pass
738
+ response = ""
739
+
733
740
  # 事件:模型调用后
734
741
  try:
735
742
  self.event_bus.emit(
@@ -761,7 +768,13 @@ class Agent:
761
768
  summary = self.model.chat_until_success(
762
769
  self.session.prompt + "\n" + SUMMARY_REQUEST_PROMPT
763
770
  ) # type: ignore
764
-
771
+ # 防御: 可能返回空响应(None或空字符串),统一为空字符串并告警
772
+ if not summary:
773
+ try:
774
+ PrettyOutput.print("总结模型返回空响应,已使用空字符串回退。", OutputType.WARNING)
775
+ except Exception:
776
+ pass
777
+ summary = ""
765
778
  return summary
766
779
  except Exception:
767
780
  PrettyOutput.print("总结对话历史失败", OutputType.ERROR)
@@ -898,6 +911,13 @@ class Agent:
898
911
  if not self.model:
899
912
  raise RuntimeError("Model not initialized")
900
913
  ret = self.model.chat_until_success(self.session.prompt) # type: ignore
914
+ # 防御: 总结阶段模型可能返回空响应(None或空字符串),统一为空字符串并告警
915
+ if not ret:
916
+ try:
917
+ PrettyOutput.print("总结阶段模型返回空响应,已使用空字符串回退。", OutputType.WARNING)
918
+ except Exception:
919
+ pass
920
+ ret = ""
901
921
  result = ret
902
922
 
903
923
  # 广播完成总结事件
@@ -13,7 +13,11 @@ class EditFileHandler(OutputHandler):
13
13
  ot("PATCH file=(?:'([^']+)'|\"([^\"]+)\"|([^>]+))") + r"\s*"
14
14
  r"(?:"
15
15
  + ot("DIFF")
16
- + r"\s*"
16
+ + r"\s*(?:"
17
+ # 可选的RANGE标签,限制替换行号范围
18
+ + r"(?:" + ot("RANGE") + r"(.*?)" + ct("RANGE") + r"\s*)?"
19
+ + r"(?:"
20
+ # 单点替换(SEARCH/REPLACE)
17
21
  + ot("SEARCH")
18
22
  + r"(.*?)"
19
23
  + ct("SEARCH")
@@ -21,15 +25,29 @@ class EditFileHandler(OutputHandler):
21
25
  + ot("REPLACE")
22
26
  + r"(.*?)"
23
27
  + ct("REPLACE")
28
+ + r"|"
29
+ # 区间替换(SEARCH_START/SEARCH_END/REPLACE)
30
+ + ot("SEARCH_START")
31
+ + r"(.*?)"
32
+ + ct("SEARCH_START")
33
+ + r"\s*"
34
+ + ot("SEARCH_END")
35
+ + r"(.*?)"
36
+ + ct("SEARCH_END")
24
37
  + r"\s*"
38
+ + ot("REPLACE")
39
+ + r"(.*?)"
40
+ + ct("REPLACE")
41
+ + r")"
42
+ + r")\s*"
25
43
  + ct("DIFF")
26
44
  + r"\s*)+"
27
- + ct("PATCH"),
28
- re.DOTALL,
45
+ + r"^" + ct("PATCH"),
46
+ re.DOTALL | re.MULTILINE,
29
47
  )
30
48
  self.diff_pattern = re.compile(
31
49
  ot("DIFF")
32
- + r"\s*"
50
+ + r"\s*(?:" + ot("RANGE") + r"(.*?)" + ct("RANGE") + r"\s*)?"
33
51
  + ot("SEARCH")
34
52
  + r"(.*?)"
35
53
  + ct("SEARCH")
@@ -41,6 +59,24 @@ class EditFileHandler(OutputHandler):
41
59
  + ct("DIFF"),
42
60
  re.DOTALL,
43
61
  )
62
+ self.diff_range_pattern = re.compile(
63
+ ot("DIFF")
64
+ + r"\s*(?:" + ot("RANGE") + r"(.*?)" + ct("RANGE") + r"\s*)?"
65
+ + ot("SEARCH_START")
66
+ + r"(.*?)"
67
+ + ct("SEARCH_START")
68
+ + r"\s*"
69
+ + ot("SEARCH_END")
70
+ + r"(.*?)"
71
+ + ct("SEARCH_END")
72
+ + r"\s*"
73
+ + ot("REPLACE")
74
+ + r"(.*?)"
75
+ + ct("REPLACE")
76
+ + r"\s*"
77
+ + ct("DIFF"),
78
+ re.DOTALL,
79
+ )
44
80
 
45
81
  def handle(self, response: str, agent: Any) -> Tuple[bool, str]:
46
82
  """处理文件编辑响应
@@ -65,9 +101,7 @@ class EditFileHandler(OutputHandler):
65
101
 
66
102
  for file_path, diffs in patches.items():
67
103
  file_path = os.path.abspath(file_path)
68
- file_patches = [
69
- {"SEARCH": diff["SEARCH"], "REPLACE": diff["REPLACE"]} for diff in diffs
70
- ]
104
+ file_patches = diffs
71
105
 
72
106
  success, result = self._fast_edit(file_path, file_patches)
73
107
 
@@ -96,17 +130,44 @@ class EditFileHandler(OutputHandler):
96
130
  Returns:
97
131
  str: 返回处理器的提示字符串
98
132
  """
99
- return f"""文件编辑指令格式:
100
- {ot("PATCH file=文件路径")}
101
- {ot("DIFF")}
133
+ from jarvis.jarvis_utils.config import get_patch_format
134
+
135
+ patch_format = get_patch_format()
136
+
137
+ search_prompt = f"""{ot("DIFF")}
102
138
  {ot("SEARCH")}原始代码{ct("SEARCH")}
103
139
  {ot("REPLACE")}新代码{ct("REPLACE")}
104
- {ct("DIFF")}
140
+ {ct("DIFF")}"""
141
+
142
+ search_range_prompt = f"""{ot("DIFF")}
143
+ {ot("RANGE")}起止行号(如: 10-50),可选{ct("RANGE")}
144
+ {ot("SEARCH_START")}起始标记{ct("SEARCH_START")}
145
+ {ot("SEARCH_END")}结束标记{ct("SEARCH_END")}
146
+ {ot("REPLACE")}替换内容{ct("REPLACE")}
147
+ {ct("DIFF")}"""
148
+
149
+ if patch_format == "search":
150
+ formats = search_prompt
151
+ supported_formats = "仅支持单点替换(SEARCH/REPLACE)"
152
+ elif patch_format == "search_range":
153
+ formats = search_range_prompt
154
+ supported_formats = "仅支持区间替换(SEARCH_START/SEARCH_END/REPLACE),可选RANGE限定行号范围"
155
+ else: # all
156
+ formats = f"{search_prompt}\n或\n{search_range_prompt}"
157
+ supported_formats = "支持两种DIFF块:单点替换(SEARCH/REPLACE)与区间替换(SEARCH_START/SEARCH_END/REPLACE)"
158
+
159
+ return f"""文件编辑指令格式:
160
+ {ot("PATCH file=文件路径")}
161
+ {formats}
105
162
  {ct("PATCH")}
106
163
 
107
- 可以返回多个PATCH块用于同时修改多个文件
108
- 每个PATCH块可以包含多个DIFF块,每个DIFF块包含一组搜索和替换内容。
109
- 搜索文本必须能在文件中唯一匹配,否则编辑将失败。"""
164
+ 注意:
165
+ - {ot("PATCH")} 和 {ct("PATCH")} 必须出现在行首,否则不生效(会被忽略)
166
+ - {supported_formats}
167
+ - {ot("RANGE")}start-end{ct("RANGE")} 仅用于区间替换模式(SEARCH_START/SEARCH_END),表示只在指定行号范围内进行匹配与替换(1-based,闭区间);省略则在整个文件范围内处理
168
+ - 单点替换要求 SEARCH 在有效范围内唯一匹配(仅替换第一个匹配)
169
+ - 区间替换会从包含 {ot("SEARCH_START")} 的行首开始,到包含 {ot("SEARCH_END")} 的行尾结束,替换整个区域
170
+ 否则编辑将失败。"""
110
171
 
111
172
  def name(self) -> str:
112
173
  """获取处理器的名称
@@ -139,18 +200,75 @@ class EditFileHandler(OutputHandler):
139
200
  }
140
201
  """
141
202
  patches: Dict[str, List[Dict[str, str]]] = {}
203
+
142
204
  for match in self.patch_pattern.finditer(response):
143
205
  # Get the file path from the appropriate capture group
144
206
  file_path = match.group(1) or match.group(2) or match.group(3)
145
- diffs = []
146
- for diff_match in self.diff_pattern.finditer(match.group(0)):
147
- # 完全保留原始格式(包括所有空白和换行)
148
- diffs.append(
149
- {
150
- "SEARCH": diff_match.group(1), # 原始SEARCH内容
151
- "REPLACE": diff_match.group(2), # 原始REPLACE内容
207
+ diffs: List[Dict[str, str]] = []
208
+
209
+ # 逐块解析,保持 DIFF 顺序
210
+ diff_block_pattern = re.compile(ot("DIFF") + r"(.*?)" + ct("DIFF"), re.DOTALL)
211
+ for block_match in diff_block_pattern.finditer(match.group(0)):
212
+ block_text = block_match.group(1)
213
+
214
+ # 提取可选的行号范围
215
+ range_scope = None
216
+ range_scope_match = re.match(
217
+ r"^\s*" + ot("RANGE") + r"(.*?)" + ct("RANGE") + r"\s*",
218
+ block_text,
219
+ re.DOTALL,
220
+ )
221
+ if range_scope_match:
222
+ range_scope = range_scope_match.group(1).strip()
223
+ # 仅移除块首部的RANGE标签,避免误删内容中的同名标记
224
+ block_text = block_text[range_scope_match.end():]
225
+ # 统一按 all 解析:无视配置,始终尝试区间替换
226
+ range_match = re.search(
227
+ ot("SEARCH_START")
228
+ + r"(.*?)"
229
+ + ct("SEARCH_START")
230
+ + r"\s*"
231
+ + ot("SEARCH_END")
232
+ + r"(.*?)"
233
+ + ct("SEARCH_END")
234
+ + r"\s*"
235
+ + ot("REPLACE")
236
+ + r"(.*?)"
237
+ + ct("REPLACE"),
238
+ block_text,
239
+ re.DOTALL,
240
+ )
241
+ if range_match:
242
+ diff_item: Dict[str, str] = {
243
+ "SEARCH_START": range_match.group(1), # 原始SEARCH_START内容
244
+ "SEARCH_END": range_match.group(2), # 原始SEARCH_END内容
245
+ "REPLACE": range_match.group(3), # 原始REPLACE内容
152
246
  }
247
+ if range_scope:
248
+ diff_item["RANGE"] = range_scope
249
+ diffs.append(diff_item)
250
+ continue
251
+
252
+ # 解析单点替换(统一按 all 解析:无视配置,始终尝试单点替换)
253
+ single_match = re.search(
254
+ ot("SEARCH")
255
+ + r"(.*?)"
256
+ + ct("SEARCH")
257
+ + r"\s*"
258
+ + ot("REPLACE")
259
+ + r"(.*?)"
260
+ + ct("REPLACE"),
261
+ block_text,
262
+ re.DOTALL,
153
263
  )
264
+ if single_match:
265
+ diff_item = {
266
+ "SEARCH": single_match.group(1), # 原始SEARCH内容
267
+ "REPLACE": single_match.group(2), # 原始REPLACE内容
268
+ }
269
+ # SEARCH 模式不支持 RANGE,直接忽略
270
+ diffs.append(diff_item)
271
+
154
272
  if diffs:
155
273
  if file_path in patches:
156
274
  patches[file_path].extend(diffs)
@@ -194,85 +312,232 @@ class EditFileHandler(OutputHandler):
194
312
  failed_patches: List[Dict[str, Any]] = []
195
313
  successful_patches = 0
196
314
 
197
- for patch in patches:
198
- patch_count += 1
199
- search_text = patch["SEARCH"]
200
- replace_text = patch["REPLACE"]
201
-
202
- # 精确匹配搜索文本(保留原始换行和空格)
203
- exact_search = search_text
315
+ # 当存在RANGE时,确保按行号从后往前应用补丁,避免前面补丁影响后续RANGE的行号
316
+ ordered_patches: List[Dict[str, str]] = []
317
+ range_items: List[Tuple[int, int, int, Dict[str, str]]] = []
318
+ non_range_items: List[Tuple[int, Dict[str, str]]] = []
319
+ for idx, p in enumerate(patches):
320
+ r = p.get("RANGE")
321
+ if r and str(r).strip():
322
+ m = re.match(r"\s*(\d+)\s*-\s*(\d+)\s*$", str(r))
323
+ if m:
324
+ start_line = int(m.group(1))
325
+ end_line = int(m.group(2))
326
+ range_items.append((start_line, end_line, idx, p))
327
+ else:
328
+ # RANGE格式无效的补丁保持原有顺序
329
+ non_range_items.append((idx, p))
330
+ else:
331
+ # 无RANGE的补丁保持原有顺序
332
+ non_range_items.append((idx, p))
333
+ # 先应用RANGE补丁:按start_line、end_line、原始索引逆序
334
+ range_items.sort(key=lambda x: (x[0], x[1], x[2]), reverse=True)
335
+ ordered_patches = [item[3] for item in range_items] + [item[1] for item in non_range_items]
336
+
337
+ patch_count = len(ordered_patches)
338
+ for patch in ordered_patches:
204
339
  found = False
205
340
 
206
- if exact_search in modified_content:
207
- # 直接执行替换(保留所有原始格式),只替换第一个匹配
208
- modified_content = modified_content.replace(
209
- exact_search, replace_text, 1
210
- )
211
-
212
- found = True
213
- else:
214
- # 如果匹配不到,并且search与replace块的首尾都是换行,尝试去掉第一个和最后一个换行
341
+ # 处理可选的RANGE范围:格式 "start-end"(1-based, 闭区间)
342
+ scoped = False
343
+ prefix = suffix = ""
344
+ base_content = modified_content
345
+ if "RANGE" in patch and str(patch["RANGE"]).strip():
346
+ m = re.match(r"\s*(\d+)\s*-\s*(\d+)\s*$", str(patch["RANGE"]))
347
+ if not m:
348
+ error_msg = "RANGE格式无效,应为 'start-end' 的行号范围(1-based, 闭区间)"
349
+ failed_patches.append({"patch": patch, "error": error_msg})
350
+ # 不进行本补丁其它处理
351
+ continue
352
+ start_line = int(m.group(1))
353
+ end_line = int(m.group(2))
354
+
355
+ # 拆分为三段
356
+ lines = modified_content.splitlines(keepends=True)
357
+ total_lines = len(lines)
215
358
  if (
216
- search_text.startswith("\n")
217
- and search_text.endswith("\n")
218
- and replace_text.startswith("\n")
219
- and replace_text.endswith("\n")
359
+ start_line < 1
360
+ or end_line < 1
361
+ or start_line > end_line
362
+ or start_line > total_lines
220
363
  ):
221
- stripped_search = search_text[1:-1]
222
- stripped_replace = replace_text[1:-1]
223
- if stripped_search in modified_content:
224
- modified_content = modified_content.replace(
225
- stripped_search, stripped_replace, 1
226
- )
227
-
228
- found = True
229
-
230
- if not found:
231
- # 尝试增加缩进重试
232
- current_search = search_text
233
- current_replace = replace_text
364
+ error_msg = f"RANGE行号无效(文件共有{total_lines}行)"
365
+ failed_patches.append({"patch": patch, "error": error_msg})
366
+ continue
367
+ # 截断end_line不超过总行数
368
+ end_line = min(end_line, total_lines)
369
+
370
+ prefix = "".join(lines[: start_line - 1])
371
+ base_content = "".join(lines[start_line - 1 : end_line])
372
+ suffix = "".join(lines[end_line:])
373
+ scoped = True
374
+
375
+ # 单点替换
376
+ if "SEARCH" in patch:
377
+ search_text = patch["SEARCH"]
378
+ replace_text = patch["REPLACE"]
379
+
380
+ # 精确匹配搜索文本(保留原始换行和空格)
381
+ exact_search = search_text
382
+
383
+ def _count_occurrences(haystack: str, needle: str) -> int:
384
+ if not needle:
385
+ return 0
386
+ return haystack.count(needle)
387
+
388
+ # 1) 精确匹配,要求唯一
389
+ cnt = _count_occurrences(base_content, exact_search)
390
+ if cnt == 1:
391
+ base_content = base_content.replace(exact_search, replace_text, 1)
392
+ found = True
393
+ elif cnt > 1:
394
+ error_msg = "SEARCH 在指定范围内出现多次,要求唯一匹配"
395
+ failed_patches.append({"patch": patch, "error": error_msg})
396
+ # 不继续尝试其它变体
397
+ continue
398
+ else:
399
+ # 2) 若首尾均为换行,尝试去掉首尾换行后匹配,要求唯一
234
400
  if (
235
- current_search.startswith("\n")
236
- and current_search.endswith("\n")
237
- and current_replace.startswith("\n")
238
- and current_replace.endswith("\n")
401
+ search_text.startswith("\n")
402
+ and search_text.endswith("\n")
403
+ and replace_text.startswith("\n")
404
+ and replace_text.endswith("\n")
239
405
  ):
240
- current_search = current_search[1:-1]
241
- current_replace = current_replace[1:-1]
242
-
243
- for space_count in range(1, 17):
244
- indented_search = "\n".join(
245
- " " * space_count + line if line.strip() else line
246
- for line in current_search.split("\n")
247
- )
248
- indented_replace = "\n".join(
249
- " " * space_count + line if line.strip() else line
250
- for line in current_replace.split("\n")
251
- )
252
- if indented_search in modified_content:
253
- modified_content = modified_content.replace(
254
- indented_search, indented_replace, 1
406
+ stripped_search = search_text[1:-1]
407
+ stripped_replace = replace_text[1:-1]
408
+ cnt2 = _count_occurrences(base_content, stripped_search)
409
+ if cnt2 == 1:
410
+ base_content = base_content.replace(
411
+ stripped_search, stripped_replace, 1
255
412
  )
256
-
257
413
  found = True
258
- break
414
+ elif cnt2 > 1:
415
+ error_msg = "SEARCH 在指定范围内出现多次(去掉首尾换行后),要求唯一匹配"
416
+ failed_patches.append({"patch": patch, "error": error_msg})
417
+ continue
418
+
419
+ # 3) 尝试缩进适配(1..16个空格),要求唯一
420
+ if not found:
421
+ current_search = search_text
422
+ current_replace = replace_text
423
+ if (
424
+ current_search.startswith("\n")
425
+ and current_search.endswith("\n")
426
+ and current_replace.startswith("\n")
427
+ and current_replace.endswith("\n")
428
+ ):
429
+ current_search = current_search[1:-1]
430
+ current_replace = current_replace[1:-1]
431
+
432
+ for space_count in range(1, 17):
433
+ indented_search = "\n".join(
434
+ " " * space_count + line if line.strip() else line
435
+ for line in current_search.split("\n")
436
+ )
437
+ indented_replace = "\n".join(
438
+ " " * space_count + line if line.strip() else line
439
+ for line in current_replace.split("\n")
440
+ )
441
+ cnt3 = _count_occurrences(base_content, indented_search)
442
+ if cnt3 == 1:
443
+ base_content = base_content.replace(
444
+ indented_search, indented_replace, 1
445
+ )
446
+ found = True
447
+ break
448
+ elif cnt3 > 1:
449
+ error_msg = "SEARCH 在指定范围内出现多次(缩进适配后),要求唯一匹配"
450
+ failed_patches.append({"patch": patch, "error": error_msg})
451
+ # 多匹配直接失败,不再继续尝试其它缩进
452
+ found = False
453
+ break
454
+
455
+ if not found:
456
+ # 未找到任何可用的唯一匹配
457
+ failed_patches.append({"patch": patch, "error": "未找到唯一匹配的SEARCH"})
458
+
459
+ # 区间替换
460
+ elif "SEARCH_START" in patch and "SEARCH_END" in patch:
461
+ search_start = patch["SEARCH_START"]
462
+ search_end = patch["SEARCH_END"]
463
+ replace_text = patch["REPLACE"]
464
+
465
+ # 范围替换(包含边界),命中第一个起始标记及其后的第一个结束标记
466
+ start_idx = base_content.find(search_start)
467
+ if start_idx == -1:
468
+ error_msg = "未找到SEARCH_START"
469
+ failed_patches.append({"patch": patch, "error": error_msg})
470
+ else:
471
+ # 从 search_start 之后开始查找 search_end
472
+ end_idx = base_content.find(search_end, start_idx + len(search_start))
473
+ if end_idx == -1:
474
+ error_msg = "在SEARCH_START之后未找到SEARCH_END"
475
+ failed_patches.append({"patch": patch, "error": error_msg})
476
+ else:
477
+ # 将替换范围扩展到整行
478
+ # 找到 start_idx 所在行的行首
479
+ line_start_idx = base_content.rfind("\n", 0, start_idx) + 1
480
+
481
+ # 找到 end_idx 所在行的行尾
482
+ match_end_pos = end_idx + len(search_end)
483
+ line_end_idx = base_content.find("\n", match_end_pos)
484
+
485
+ if line_end_idx == -1:
486
+ # 如果没有找到换行符,说明是最后一行
487
+ end_of_range = len(base_content)
488
+ else:
489
+ # 包含换行符
490
+ end_of_range = line_end_idx + 1
491
+
492
+ final_replace_text = replace_text
493
+ original_slice = base_content[line_start_idx:end_of_range]
494
+
495
+ # 如果原始片段以换行符结尾,且替换内容不为空且不以换行符结尾,
496
+ # 则为替换内容添加换行符以保持格式
497
+ if (
498
+ final_replace_text
499
+ and original_slice.endswith("\n")
500
+ and not final_replace_text.endswith("\n")
501
+ ):
502
+ final_replace_text += "\n"
503
+
504
+ base_content = (
505
+ base_content[:line_start_idx]
506
+ + final_replace_text
507
+ + base_content[end_of_range:]
508
+ )
509
+ found = True
259
510
 
260
- if found:
261
- successful_patches += 1
262
511
  else:
263
- error_msg = "搜索文本在文件中不存在"
264
-
512
+ error_msg = "不支持的补丁格式"
265
513
  failed_patches.append({"patch": patch, "error": error_msg})
266
514
 
515
+ # 若使用了RANGE,则将局部修改写回整体内容
516
+ if found:
517
+ if scoped:
518
+ modified_content = prefix + base_content + suffix
519
+ else:
520
+ modified_content = base_content
521
+ successful_patches += 1
522
+
267
523
  # 写入修改后的内容
268
524
  with open(file_path, "w", encoding="utf-8") as f:
269
525
  f.write(modified_content)
270
526
 
271
527
  if failed_patches:
272
- error_details = [
273
- f" - 失败的补丁: \n{p['patch']['SEARCH']}\n 错误: {p['error']}"
274
- for p in failed_patches
275
- ]
528
+ error_details = []
529
+ for p in failed_patches:
530
+ patch = p["patch"]
531
+ if "SEARCH" in patch:
532
+ patch_desc = patch["SEARCH"]
533
+ else:
534
+ patch_desc = (
535
+ "SEARCH_START:\n"
536
+ + (patch.get("SEARCH_START", ""))
537
+ + "\nSEARCH_END:\n"
538
+ + (patch.get("SEARCH_END", ""))
539
+ )
540
+ error_details.append(f" - 失败的补丁: \n{patch_desc}\n 错误: {p['error']}")
276
541
  if successful_patches == 0:
277
542
  summary = (
278
543
  f"文件 {file_path} 修改失败(全部失败)。\n"