abelworkflow 1.1.3 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +30 -29
- package/extensions/gpt-responses-compat.ts +213 -0
- package/extensions/k2think-compat.ts +40 -0
- package/lib/cli/args.mjs +5 -19
- package/lib/cli/main.mjs +105 -72
- package/lib/cli/prompts.mjs +9 -2
- package/lib/config/dotenv.mjs +1 -1
- package/lib/config/jsonc.mjs +1 -1
- package/lib/config/store.mjs +197 -45
- package/lib/config/toml.mjs +147 -453
- package/lib/installer/assets.mjs +67 -45
- package/lib/installer/install.mjs +202 -45
- package/lib/installer/links.mjs +124 -101
- package/lib/installer/lock.mjs +82 -0
- package/lib/installer/state.mjs +14 -21
- package/lib/paths.mjs +9 -9
- package/lib/providers/claude.mjs +58 -114
- package/lib/providers/codex.mjs +101 -198
- package/lib/providers/pi.mjs +331 -497
- package/lib/providers/skills.mjs +88 -68
- package/lib/providers/url.mjs +35 -0
- package/lib/templates/workflow/commands/abel-design.md +23 -161
- package/lib/templates/workflow/commands/abel-diagnose.md +30 -56
- package/lib/templates/workflow/commands/abel-implement.md +30 -150
- package/lib/templates/workflow/commands/abel-init.md +1 -1
- package/lib/tools/cli-installer.mjs +29 -190
- package/package.json +7 -9
- package/skills/context7-auto-research/context7-api.cjs +75 -17
- package/skills/dev-browser/SKILL.md +15 -1
- package/skills/dev-browser/dist/scripts/start.d.ts +0 -1
- package/skills/dev-browser/dist/scripts/start.js +8 -36
- package/skills/dev-browser/dist/src/client.d.ts +3 -4
- package/skills/dev-browser/dist/src/client.js +117 -117
- package/skills/dev-browser/dist/src/entrypoint.d.ts +1 -1
- package/skills/dev-browser/dist/src/entrypoint.js +38 -31
- package/skills/dev-browser/dist/src/index.d.ts +0 -1
- package/skills/dev-browser/dist/src/index.js +0 -1
- package/skills/dev-browser/dist/src/page-api.d.ts +0 -1
- package/skills/dev-browser/dist/src/page-api.js +0 -1
- package/skills/dev-browser/dist/src/relay.d.ts +0 -1
- package/skills/dev-browser/dist/src/relay.js +53 -7
- package/skills/dev-browser/dist/src/runtime.d.ts +3 -14
- package/skills/dev-browser/dist/src/runtime.js +5 -15
- package/skills/dev-browser/dist/src/snapshot/browser-script.d.ts +0 -1
- package/skills/dev-browser/dist/src/snapshot/browser-script.js +0 -1
- package/skills/dev-browser/dist/src/snapshot/index.d.ts +0 -1
- package/skills/dev-browser/dist/src/snapshot/index.js +0 -1
- package/skills/dev-browser/dist/src/snapshot/inject.d.ts +0 -1
- package/skills/dev-browser/dist/src/snapshot/inject.js +0 -1
- package/skills/dev-browser/dist/src/standalone.d.ts +0 -1
- package/skills/dev-browser/dist/src/standalone.js +2 -3
- package/skills/dev-browser/dist/src/startup.d.ts +3 -12
- package/skills/dev-browser/dist/src/startup.js +6 -10
- package/skills/dev-browser/dist/src/target-registry.d.ts +0 -1
- package/skills/dev-browser/dist/src/target-registry.js +0 -1
- package/skills/dev-browser/dist/src/types.d.ts +0 -1
- package/skills/dev-browser/dist/src/types.js +0 -1
- package/skills/dev-browser/package-lock.json +1 -1
- package/skills/dev-browser/package.json +2 -2
- package/skills/grok-search/.env.example +1 -1
- package/skills/grok-search/SKILL.md +13 -16
- package/skills/grok-search/scripts/_dotenv.py +1 -1
- package/skills/grok-search/scripts/groksearch_cli.py +271 -195
- package/skills/time/SKILL.md +5 -2
- package/skills/time/scripts/time_cli.py +61 -22
- package/extensions/pi-gpt-responses-compat/index.ts +0 -25
- package/lib/templates/codex/config-base.toml +0 -103
- package/skills/dev-browser/dist/scripts/start.d.ts.map +0 -1
- package/skills/dev-browser/dist/scripts/start.js.map +0 -1
- package/skills/dev-browser/dist/src/client.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/client.js.map +0 -1
- package/skills/dev-browser/dist/src/entrypoint.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/entrypoint.js.map +0 -1
- package/skills/dev-browser/dist/src/index.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/index.js.map +0 -1
- package/skills/dev-browser/dist/src/page-api.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/page-api.js.map +0 -1
- package/skills/dev-browser/dist/src/relay.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/relay.js.map +0 -1
- package/skills/dev-browser/dist/src/runtime.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/runtime.js.map +0 -1
- package/skills/dev-browser/dist/src/snapshot/browser-script.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/snapshot/browser-script.js.map +0 -1
- package/skills/dev-browser/dist/src/snapshot/index.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/snapshot/index.js.map +0 -1
- package/skills/dev-browser/dist/src/snapshot/inject.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/snapshot/inject.js.map +0 -1
- package/skills/dev-browser/dist/src/standalone.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/standalone.js.map +0 -1
- package/skills/dev-browser/dist/src/startup.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/startup.js.map +0 -1
- package/skills/dev-browser/dist/src/target-registry.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/target-registry.js.map +0 -1
- package/skills/dev-browser/dist/src/types.d.ts.map +0 -1
- package/skills/dev-browser/dist/src/types.js.map +0 -1
- package/skills/grok-search/gitignore.template +0 -4
- package/skills/grok-search/scripts/groksearch_entry.py +0 -131
|
@@ -15,6 +15,8 @@ from pathlib import Path
|
|
|
15
15
|
from typing import Optional
|
|
16
16
|
from urllib.parse import urlsplit, urlunsplit
|
|
17
17
|
|
|
18
|
+
sys.dont_write_bytecode = True
|
|
19
|
+
|
|
18
20
|
from _dotenv import load_dotenv
|
|
19
21
|
|
|
20
22
|
try:
|
|
@@ -22,13 +24,10 @@ try:
|
|
|
22
24
|
from tenacity import AsyncRetrying, retry_if_exception, stop_after_attempt, wait_random_exponential
|
|
23
25
|
from tenacity.wait import wait_base
|
|
24
26
|
except ImportError:
|
|
25
|
-
print("Error: 所需包未安装。请运行: python
|
|
27
|
+
print("Error: 所需包未安装。请运行: python -m pip install -r requirements.txt", file=sys.stderr)
|
|
26
28
|
sys.exit(1)
|
|
27
29
|
|
|
28
30
|
|
|
29
|
-
load_dotenv()
|
|
30
|
-
|
|
31
|
-
|
|
32
31
|
# ============================================================================
|
|
33
32
|
# 配置
|
|
34
33
|
# ============================================================================
|
|
@@ -119,19 +118,9 @@ class Config:
|
|
|
119
118
|
def tavily_api_key(self) -> Optional[str]:
|
|
120
119
|
return os.getenv("TAVILY_API_KEY") or None
|
|
121
120
|
|
|
122
|
-
def _apply_model_suffix(self, model: str) -> str:
|
|
123
|
-
try:
|
|
124
|
-
url = self.grok_api_url
|
|
125
|
-
except ValueError:
|
|
126
|
-
return model
|
|
127
|
-
if "openrouter" in url and ":online" not in model:
|
|
128
|
-
return f"{model}:online"
|
|
129
|
-
return model
|
|
130
|
-
|
|
131
121
|
@property
|
|
132
122
|
def grok_model(self) -> str:
|
|
133
|
-
|
|
134
|
-
return self._apply_model_suffix(model)
|
|
123
|
+
return os.getenv("GROK_MODEL") or self._DEFAULT_MODEL
|
|
135
124
|
|
|
136
125
|
@staticmethod
|
|
137
126
|
def _mask_api_key(key: str) -> str:
|
|
@@ -214,6 +203,7 @@ def _emit_tavily_warning(message: str) -> None:
|
|
|
214
203
|
# ============================================================================
|
|
215
204
|
|
|
216
205
|
RETRYABLE_STATUS_CODES = {408, 429, 500, 502, 503, 504}
|
|
206
|
+
MAX_RESPONSE_BYTES = 10 * 1024 * 1024
|
|
217
207
|
|
|
218
208
|
|
|
219
209
|
class StreamEmbeddedError(Exception):
|
|
@@ -224,6 +214,44 @@ class EmptyStreamError(Exception):
|
|
|
224
214
|
"""流式响应解析完成但内容为空。"""
|
|
225
215
|
|
|
226
216
|
|
|
217
|
+
class ResponseTooLargeError(ValueError):
|
|
218
|
+
"""Response exceeded the configured in-memory byte limit."""
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def _iter_response_bytes(response, max_bytes: Optional[int] = None):
|
|
222
|
+
limit = MAX_RESPONSE_BYTES if max_bytes is None else max_bytes
|
|
223
|
+
total = 0
|
|
224
|
+
async for chunk in response.aiter_bytes():
|
|
225
|
+
total += len(chunk)
|
|
226
|
+
if total > limit:
|
|
227
|
+
raise ResponseTooLargeError(f"API 响应超过 {limit} 字节限制")
|
|
228
|
+
yield chunk
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def _read_response_bytes(response, max_bytes: Optional[int] = None) -> bytes:
|
|
232
|
+
body = bytearray()
|
|
233
|
+
async for chunk in _iter_response_bytes(response, max_bytes):
|
|
234
|
+
body.extend(chunk)
|
|
235
|
+
return bytes(body)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def _read_json_response(response):
|
|
239
|
+
return json.loads(await _read_response_bytes(response))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _extract_chat_content(data, container: str) -> str:
|
|
243
|
+
if not isinstance(data, dict):
|
|
244
|
+
return ""
|
|
245
|
+
choices = data.get("choices")
|
|
246
|
+
if not isinstance(choices, list) or not choices or not isinstance(choices[0], dict):
|
|
247
|
+
return ""
|
|
248
|
+
payload = choices[0].get(container)
|
|
249
|
+
if not isinstance(payload, dict):
|
|
250
|
+
return ""
|
|
251
|
+
content = payload.get("content")
|
|
252
|
+
return content if isinstance(content, str) else ""
|
|
253
|
+
|
|
254
|
+
|
|
227
255
|
def _is_retryable_exception(exc) -> bool:
|
|
228
256
|
if isinstance(exc, (StreamEmbeddedError, EmptyStreamError)):
|
|
229
257
|
return True
|
|
@@ -237,6 +265,7 @@ def _is_retryable_exception(exc) -> bool:
|
|
|
237
265
|
class _WaitWithRetryAfter(wait_base):
|
|
238
266
|
def __init__(self, multiplier: float, max_wait: int):
|
|
239
267
|
self._base_wait = wait_random_exponential(multiplier=multiplier, max=max_wait)
|
|
268
|
+
self._max_wait = max_wait
|
|
240
269
|
self._protocol_error_base = 3.0
|
|
241
270
|
|
|
242
271
|
def __call__(self, retry_state):
|
|
@@ -256,13 +285,13 @@ class _WaitWithRetryAfter(wait_base):
|
|
|
256
285
|
return None
|
|
257
286
|
header = header.strip()
|
|
258
287
|
if header.isdigit():
|
|
259
|
-
return float(header)
|
|
288
|
+
return min(float(header), self._max_wait)
|
|
260
289
|
try:
|
|
261
290
|
retry_dt = parsedate_to_datetime(header)
|
|
262
291
|
if retry_dt.tzinfo is None:
|
|
263
292
|
retry_dt = retry_dt.replace(tzinfo=timezone.utc)
|
|
264
293
|
delay = (retry_dt - datetime.now(timezone.utc)).total_seconds()
|
|
265
|
-
return max(0.0, delay)
|
|
294
|
+
return min(max(0.0, delay), self._max_wait)
|
|
266
295
|
except (TypeError, ValueError):
|
|
267
296
|
return None
|
|
268
297
|
|
|
@@ -404,6 +433,23 @@ def _bracket_ipv6_authority(authority: str) -> str:
|
|
|
404
433
|
return f"{prefix}[{target}]"
|
|
405
434
|
|
|
406
435
|
|
|
436
|
+
def _validate_http_url(url: str) -> str:
|
|
437
|
+
parts = urlsplit(url)
|
|
438
|
+
try:
|
|
439
|
+
hostname = parts.hostname
|
|
440
|
+
parts.port
|
|
441
|
+
except ValueError as error:
|
|
442
|
+
raise ValueError(f"URL 端口无效: {url!r}") from error
|
|
443
|
+
if not parts.netloc or not hostname:
|
|
444
|
+
raise ValueError(f"URL 缺少主机名: {url!r}")
|
|
445
|
+
hostport = parts.netloc.rsplit("@", 1)[-1]
|
|
446
|
+
if hostport.endswith(":"):
|
|
447
|
+
raise ValueError(f"URL 端口无效: {url!r}")
|
|
448
|
+
if parts.username is not None or parts.password is not None:
|
|
449
|
+
raise ValueError("URL 不允许包含用户信息")
|
|
450
|
+
return url
|
|
451
|
+
|
|
452
|
+
|
|
407
453
|
def normalize_url(url: str) -> str:
|
|
408
454
|
s = (url or "").strip()
|
|
409
455
|
while True:
|
|
@@ -431,7 +477,9 @@ def normalize_url(url: str) -> str:
|
|
|
431
477
|
if parts.netloc:
|
|
432
478
|
fixed_netloc = _bracket_ipv6_authority(parts.netloc)
|
|
433
479
|
if fixed_netloc != parts.netloc:
|
|
434
|
-
return
|
|
480
|
+
return _validate_http_url(
|
|
481
|
+
urlunsplit((parts.scheme, fixed_netloc, parts.path, parts.query, parts.fragment))
|
|
482
|
+
)
|
|
435
483
|
# Fix common invalid form like: http://::1 (IPv6 without brackets)
|
|
436
484
|
if not parts.netloc and parts.path and ":" in parts.path and not parts.path.startswith("/"):
|
|
437
485
|
rest = s[len(scheme_match.group(0)) :]
|
|
@@ -439,30 +487,22 @@ def normalize_url(url: str) -> str:
|
|
|
439
487
|
if authority:
|
|
440
488
|
fixed_authority = _bracket_ipv6_authority(authority)
|
|
441
489
|
if fixed_authority != authority:
|
|
442
|
-
return f"{scheme}://{fixed_authority}{remainder}"
|
|
443
|
-
return s
|
|
490
|
+
return _validate_http_url(f"{scheme}://{fixed_authority}{remainder}")
|
|
491
|
+
return _validate_http_url(s)
|
|
444
492
|
|
|
445
493
|
# Protocol-relative URL.
|
|
446
494
|
if s.startswith("//"):
|
|
447
495
|
authority, remainder = _split_authority_and_remainder(s)
|
|
448
|
-
if not authority:
|
|
449
|
-
raise ValueError(f"URL 缺少主机名: {url!r}")
|
|
450
496
|
host = _extract_host_from_authority(authority)
|
|
451
|
-
if not host:
|
|
452
|
-
raise ValueError(f"URL 缺少主机名: {url!r}")
|
|
453
497
|
scheme = "http" if _is_local_host(host) else "https"
|
|
454
498
|
authority = _bracket_ipv6_authority(authority)
|
|
455
|
-
return f"{scheme}://{authority}{remainder}"
|
|
499
|
+
return _validate_http_url(f"{scheme}://{authority}{remainder}")
|
|
456
500
|
|
|
457
501
|
authority, remainder = _split_authority_and_remainder(s)
|
|
458
|
-
if not authority:
|
|
459
|
-
raise ValueError(f"URL 缺少主机名: {url!r}")
|
|
460
502
|
host = _extract_host_from_authority(authority)
|
|
461
|
-
if not host:
|
|
462
|
-
raise ValueError(f"URL 缺少主机名: {url!r}")
|
|
463
503
|
scheme = "http" if _is_local_host(host) else "https"
|
|
464
504
|
authority = _bracket_ipv6_authority(authority)
|
|
465
|
-
return f"{scheme}://{authority}{remainder}"
|
|
505
|
+
return _validate_http_url(f"{scheme}://{authority}{remainder}")
|
|
466
506
|
|
|
467
507
|
|
|
468
508
|
def _materialize_docsify_markdown_url(url: str) -> Optional[str]:
|
|
@@ -624,18 +664,16 @@ class GrokSearchProvider:
|
|
|
624
664
|
reraise=True,
|
|
625
665
|
):
|
|
626
666
|
with attempt:
|
|
627
|
-
|
|
667
|
+
async with client.stream(
|
|
668
|
+
"POST",
|
|
628
669
|
f"{self.api_url}/chat/completions",
|
|
629
670
|
headers=self._headers,
|
|
630
671
|
json=payload_copy,
|
|
631
672
|
timeout=_NON_STREAM_TIMEOUT,
|
|
632
|
-
)
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
if choices:
|
|
637
|
-
return choices[0].get("message", {}).get("content", "")
|
|
638
|
-
return ""
|
|
673
|
+
) as response:
|
|
674
|
+
response.raise_for_status()
|
|
675
|
+
data = await _read_json_response(response)
|
|
676
|
+
return _extract_chat_content(data, "message")
|
|
639
677
|
|
|
640
678
|
async def _execute_stream(self, payload: dict) -> str:
|
|
641
679
|
"""流式请求(首选,chunk 保活避免网关超时)。"""
|
|
@@ -659,46 +697,75 @@ class GrokSearchProvider:
|
|
|
659
697
|
return await self._parse_streaming_response(response)
|
|
660
698
|
|
|
661
699
|
async def _parse_streaming_response(self, response) -> str:
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
700
|
+
content_parts = []
|
|
701
|
+
fallback_body = bytearray()
|
|
702
|
+
pending = bytearray()
|
|
703
|
+
scan_index = 0
|
|
704
|
+
saw_sse = False
|
|
705
|
+
cr, lf = b"\r\n"
|
|
706
|
+
|
|
707
|
+
def process_line(raw_line: bytes) -> None:
|
|
708
|
+
nonlocal saw_sse
|
|
709
|
+
line = raw_line.strip()
|
|
667
710
|
if not line:
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
711
|
+
return
|
|
712
|
+
if not line.startswith(b"data:"):
|
|
713
|
+
if not saw_sse:
|
|
714
|
+
fallback_body.extend(raw_line)
|
|
715
|
+
fallback_body.extend(b"\n")
|
|
716
|
+
return
|
|
717
|
+
if not saw_sse:
|
|
718
|
+
saw_sse = True
|
|
719
|
+
fallback_body.clear()
|
|
720
|
+
if line in (b"data: [DONE]", b"data:[DONE]"):
|
|
721
|
+
return
|
|
722
|
+
try:
|
|
723
|
+
data = json.loads(line[5:].lstrip())
|
|
724
|
+
except (json.JSONDecodeError, IndexError):
|
|
725
|
+
return
|
|
726
|
+
if isinstance(data, dict) and "error" in data:
|
|
727
|
+
raise StreamEmbeddedError(json.dumps(data["error"], ensure_ascii=False)[:300])
|
|
728
|
+
value = _extract_chat_content(data, "delta")
|
|
729
|
+
if value:
|
|
730
|
+
content_parts.append(value)
|
|
731
|
+
|
|
732
|
+
async for chunk in _iter_response_bytes(response):
|
|
733
|
+
pending.extend(chunk)
|
|
734
|
+
line_start = 0
|
|
735
|
+
while scan_index < len(pending):
|
|
736
|
+
separator = pending[scan_index]
|
|
737
|
+
if separator not in (cr, lf):
|
|
738
|
+
scan_index += 1
|
|
678
739
|
continue
|
|
679
|
-
if
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
740
|
+
if separator == cr and scan_index + 1 == len(pending):
|
|
741
|
+
break
|
|
742
|
+
process_line(bytes(pending[line_start:scan_index]))
|
|
743
|
+
scan_index += 1
|
|
744
|
+
if separator == cr and scan_index < len(pending) and pending[scan_index] == lf:
|
|
745
|
+
scan_index += 1
|
|
746
|
+
line_start = scan_index
|
|
747
|
+
if line_start:
|
|
748
|
+
del pending[:line_start]
|
|
749
|
+
scan_index -= line_start
|
|
750
|
+
|
|
751
|
+
if pending:
|
|
752
|
+
process_line(bytes(pending[:-1] if pending[-1] == cr else pending))
|
|
753
|
+
|
|
754
|
+
if content_parts:
|
|
755
|
+
return "".join(content_parts)
|
|
756
|
+
|
|
757
|
+
if fallback_body:
|
|
688
758
|
try:
|
|
689
|
-
|
|
690
|
-
data = json.loads(full_text)
|
|
759
|
+
data = json.loads(fallback_body)
|
|
691
760
|
if isinstance(data, dict) and "error" in data:
|
|
692
761
|
raise StreamEmbeddedError(json.dumps(data["error"], ensure_ascii=False)[:300])
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
762
|
+
content = _extract_chat_content(data, "message")
|
|
763
|
+
if content.strip():
|
|
764
|
+
return content
|
|
696
765
|
except json.JSONDecodeError:
|
|
697
766
|
pass
|
|
698
767
|
|
|
699
|
-
|
|
700
|
-
raise EmptyStreamError("流式响应内容为空")
|
|
701
|
-
return content
|
|
768
|
+
raise EmptyStreamError("流式响应内容为空")
|
|
702
769
|
|
|
703
770
|
|
|
704
771
|
# ============================================================================
|
|
@@ -716,9 +783,9 @@ async def _call_tavily_extract(url: str) -> tuple[Optional[str], Optional[str]]:
|
|
|
716
783
|
body = {"urls": [url], "format": "markdown"}
|
|
717
784
|
try:
|
|
718
785
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
786
|
+
async with client.stream("POST", endpoint, headers=headers, json=body) as response:
|
|
787
|
+
response.raise_for_status()
|
|
788
|
+
data = await _read_json_response(response)
|
|
722
789
|
except httpx.TimeoutException:
|
|
723
790
|
return None, "Tavily extract 超时"
|
|
724
791
|
except httpx.HTTPStatusError as e:
|
|
@@ -792,9 +859,9 @@ async def _call_tavily_search(query: str, max_results: int = 6) -> tuple[list[di
|
|
|
792
859
|
}
|
|
793
860
|
try:
|
|
794
861
|
async with httpx.AsyncClient(timeout=90.0) as client:
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
862
|
+
async with client.stream("POST", endpoint, headers=headers, json=body) as response:
|
|
863
|
+
response.raise_for_status()
|
|
864
|
+
data = await _read_json_response(response)
|
|
798
865
|
except httpx.TimeoutException:
|
|
799
866
|
return [], "Tavily search 超时"
|
|
800
867
|
except httpx.HTTPStatusError as e:
|
|
@@ -802,17 +869,29 @@ async def _call_tavily_search(query: str, max_results: int = 6) -> tuple[list[di
|
|
|
802
869
|
except Exception as e:
|
|
803
870
|
return [], f"Tavily search 错误: {str(e)}"
|
|
804
871
|
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
872
|
+
if not isinstance(data, dict):
|
|
873
|
+
return [], "Tavily search 返回无效响应"
|
|
874
|
+
results = data.get("results") or []
|
|
875
|
+
if not isinstance(results, list):
|
|
876
|
+
return [], "Tavily search 返回无效结果"
|
|
877
|
+
|
|
878
|
+
normalized = []
|
|
879
|
+
ignored = 0
|
|
880
|
+
for result in results:
|
|
881
|
+
if not isinstance(result, dict):
|
|
882
|
+
ignored += 1
|
|
883
|
+
continue
|
|
884
|
+
item = {
|
|
885
|
+
"title": result.get("title", "") or "",
|
|
886
|
+
"url": result.get("url", "") or "",
|
|
887
|
+
"description": result.get("content", "") or "",
|
|
813
888
|
}
|
|
814
|
-
|
|
815
|
-
|
|
889
|
+
if _is_valid_search_result(item):
|
|
890
|
+
normalized.append(item)
|
|
891
|
+
else:
|
|
892
|
+
ignored += 1
|
|
893
|
+
warning = f"Tavily search 忽略 {ignored} 条无效结果" if ignored else None
|
|
894
|
+
return normalized, warning
|
|
816
895
|
|
|
817
896
|
|
|
818
897
|
async def _call_tavily_map(
|
|
@@ -841,18 +920,33 @@ async def _call_tavily_map(
|
|
|
841
920
|
|
|
842
921
|
try:
|
|
843
922
|
async with httpx.AsyncClient(timeout=float(timeout + 10)) as client:
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
923
|
+
async with client.stream("POST", endpoint, headers=headers, json=body) as response:
|
|
924
|
+
response.raise_for_status()
|
|
925
|
+
data = await _read_json_response(response)
|
|
926
|
+
if not isinstance(data, dict):
|
|
927
|
+
return {"error": "Tavily map 返回无效响应"}
|
|
928
|
+
if "error" in data:
|
|
929
|
+
error = data["error"]
|
|
930
|
+
return {"error": error if error else "Tavily map 返回错误响应"}
|
|
931
|
+
|
|
932
|
+
base_url = data.get("base_url")
|
|
933
|
+
results = data.get("results")
|
|
934
|
+
response_time = data.get("response_time")
|
|
935
|
+
if not isinstance(base_url, str) or not base_url.strip():
|
|
936
|
+
return {"error": "Tavily map 返回无效 base_url"}
|
|
937
|
+
if not isinstance(results, list) or any(not isinstance(item, str) for item in results):
|
|
938
|
+
return {"error": "Tavily map 返回无效 results"}
|
|
939
|
+
if isinstance(response_time, bool) or not isinstance(response_time, (int, float)):
|
|
940
|
+
return {"error": "Tavily map 返回无效 response_time"}
|
|
847
941
|
return {
|
|
848
|
-
"base_url":
|
|
849
|
-
"results":
|
|
850
|
-
"response_time":
|
|
942
|
+
"base_url": base_url,
|
|
943
|
+
"results": results,
|
|
944
|
+
"response_time": response_time,
|
|
851
945
|
}
|
|
852
946
|
except httpx.TimeoutException:
|
|
853
947
|
return {"error": f"映射超时: 请求超过{timeout}秒"}
|
|
854
948
|
except httpx.HTTPStatusError as e:
|
|
855
|
-
return {"error": f"HTTP错误: {e.response.status_code}", "status_code": e.response.status_code
|
|
949
|
+
return {"error": f"HTTP错误: {e.response.status_code}", "status_code": e.response.status_code}
|
|
856
950
|
except Exception as e:
|
|
857
951
|
return {"error": f"映射错误: {str(e)}"}
|
|
858
952
|
|
|
@@ -882,20 +976,46 @@ def extract_json(text: str) -> str:
|
|
|
882
976
|
# 尝试解析为 JSON
|
|
883
977
|
try:
|
|
884
978
|
data = json.loads(text)
|
|
885
|
-
# 标准化字段名
|
|
886
|
-
if isinstance(data, list):
|
|
887
|
-
standardized = []
|
|
888
|
-
for item in data:
|
|
889
|
-
if isinstance(item, dict):
|
|
890
|
-
standardized.append({
|
|
891
|
-
"title": item.get("title", ""),
|
|
892
|
-
"url": item.get("url", item.get("link", "")),
|
|
893
|
-
"description": item.get("description", item.get("content", item.get("snippet", item.get("summary", ""))))
|
|
894
|
-
})
|
|
895
|
-
return json.dumps(standardized, ensure_ascii=False, indent=2)
|
|
896
979
|
return json.dumps(data, ensure_ascii=False, indent=2)
|
|
897
|
-
except json.JSONDecodeError:
|
|
898
|
-
|
|
980
|
+
except json.JSONDecodeError as error:
|
|
981
|
+
raise ValueError("解析 JSON 失败") from error
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def _validate_search_results(results: list) -> None:
|
|
985
|
+
for index, item in enumerate(results):
|
|
986
|
+
_validate_search_result(item, index + 1)
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _validate_search_result(item, index: int = 1) -> None:
|
|
990
|
+
if not isinstance(item, dict):
|
|
991
|
+
raise ValueError(f"搜索结果 {index} 必须是对象")
|
|
992
|
+
for field in ("title", "url", "description"):
|
|
993
|
+
value = item.get(field)
|
|
994
|
+
if not isinstance(value, str) or not value.strip():
|
|
995
|
+
raise ValueError(f"搜索结果 {index} 缺少有效 {field}")
|
|
996
|
+
url = item["url"]
|
|
997
|
+
try:
|
|
998
|
+
parts = urlsplit(url)
|
|
999
|
+
valid_url = (
|
|
1000
|
+
not re.search(r"\s", url)
|
|
1001
|
+
and parts.scheme.lower() in ("http", "https")
|
|
1002
|
+
and bool(parts.netloc and parts.hostname)
|
|
1003
|
+
and parts.username is None
|
|
1004
|
+
and parts.password is None
|
|
1005
|
+
)
|
|
1006
|
+
parts.port
|
|
1007
|
+
except ValueError:
|
|
1008
|
+
valid_url = False
|
|
1009
|
+
if not valid_url:
|
|
1010
|
+
raise ValueError(f"搜索结果 {index} 包含无效 URL")
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _is_valid_search_result(item) -> bool:
|
|
1014
|
+
try:
|
|
1015
|
+
_validate_search_result(item)
|
|
1016
|
+
return True
|
|
1017
|
+
except ValueError:
|
|
1018
|
+
return False
|
|
899
1019
|
|
|
900
1020
|
|
|
901
1021
|
# ============================================================================
|
|
@@ -904,7 +1024,7 @@ def extract_json(text: str) -> str:
|
|
|
904
1024
|
|
|
905
1025
|
async def cmd_web_search(args):
|
|
906
1026
|
try:
|
|
907
|
-
effective_model =
|
|
1027
|
+
effective_model = args.model if args.model else config.grok_model
|
|
908
1028
|
provider = GrokSearchProvider(config.grok_api_url, config.grok_api_key, effective_model)
|
|
909
1029
|
result = await provider.search(args.query, args.platform, args.min_results, args.max_results)
|
|
910
1030
|
if args.raw:
|
|
@@ -912,8 +1032,8 @@ async def cmd_web_search(args):
|
|
|
912
1032
|
else:
|
|
913
1033
|
parsed = json.loads(extract_json(result))
|
|
914
1034
|
if not isinstance(parsed, list):
|
|
915
|
-
|
|
916
|
-
|
|
1035
|
+
raise ValueError("搜索响应必须是 JSON 数组")
|
|
1036
|
+
_validate_search_results(parsed)
|
|
917
1037
|
|
|
918
1038
|
merged: list[dict] = parsed
|
|
919
1039
|
|
|
@@ -929,10 +1049,11 @@ async def cmd_web_search(args):
|
|
|
929
1049
|
else:
|
|
930
1050
|
extras, tavily_warning = await _call_tavily_search(args.query, extra_sources)
|
|
931
1051
|
if tavily_warning:
|
|
932
|
-
_emit_tavily_warning(f"{tavily_warning}
|
|
1052
|
+
_emit_tavily_warning(f"{tavily_warning};保留有效搜索结果")
|
|
933
1053
|
|
|
934
1054
|
seen: set[str] = set()
|
|
935
1055
|
out: list[dict] = []
|
|
1056
|
+
ignored_tavily = 0
|
|
936
1057
|
for item in merged:
|
|
937
1058
|
url = (item or {}).get("url", "")
|
|
938
1059
|
if isinstance(url, str) and url:
|
|
@@ -940,9 +1061,10 @@ async def cmd_web_search(args):
|
|
|
940
1061
|
out.append(item)
|
|
941
1062
|
|
|
942
1063
|
for item in extras:
|
|
943
|
-
|
|
944
|
-
|
|
1064
|
+
if not _is_valid_search_result(item):
|
|
1065
|
+
ignored_tavily += 1
|
|
945
1066
|
continue
|
|
1067
|
+
url = item["url"]
|
|
946
1068
|
if url in seen:
|
|
947
1069
|
continue
|
|
948
1070
|
seen.add(url)
|
|
@@ -954,8 +1076,12 @@ async def cmd_web_search(args):
|
|
|
954
1076
|
}
|
|
955
1077
|
)
|
|
956
1078
|
|
|
1079
|
+
if ignored_tavily:
|
|
1080
|
+
_emit_tavily_warning(f"忽略 {ignored_tavily} 条无效 Tavily 结果")
|
|
1081
|
+
|
|
957
1082
|
merged = out
|
|
958
1083
|
|
|
1084
|
+
_validate_search_results(merged)
|
|
959
1085
|
print(json.dumps(merged, ensure_ascii=False, indent=2))
|
|
960
1086
|
except ValueError as e:
|
|
961
1087
|
print(json.dumps({"error": str(e)}, ensure_ascii=False), file=sys.stderr)
|
|
@@ -1037,43 +1163,54 @@ async def cmd_web_map(args):
|
|
|
1037
1163
|
args.limit,
|
|
1038
1164
|
args.timeout,
|
|
1039
1165
|
)
|
|
1040
|
-
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
1041
1166
|
if (result or {}).get("error"):
|
|
1167
|
+
print(json.dumps(result, ensure_ascii=False, indent=2), file=sys.stderr)
|
|
1042
1168
|
sys.exit(1)
|
|
1169
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
1043
1170
|
|
|
1044
1171
|
|
|
1045
1172
|
async def cmd_get_config_info(args):
|
|
1046
1173
|
config_info = config.get_config_info()
|
|
1047
1174
|
|
|
1175
|
+
try:
|
|
1176
|
+
api_url = config.grok_api_url
|
|
1177
|
+
api_key = config.grok_api_key
|
|
1178
|
+
except ValueError:
|
|
1179
|
+
print(json.dumps(config_info, ensure_ascii=False, indent=2), file=sys.stderr)
|
|
1180
|
+
sys.exit(1)
|
|
1181
|
+
|
|
1048
1182
|
if not args.no_test:
|
|
1049
1183
|
test_result = {"status": "未测试", "message": "", "response_time_ms": 0}
|
|
1184
|
+
connection_succeeded = False
|
|
1050
1185
|
try:
|
|
1051
|
-
api_url = config.grok_api_url
|
|
1052
|
-
api_key = config.grok_api_key
|
|
1053
1186
|
models_url = f"{api_url}/models"
|
|
1054
1187
|
|
|
1055
1188
|
start_time = time.time()
|
|
1056
1189
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
1057
|
-
|
|
1190
|
+
async with client.stream(
|
|
1191
|
+
"GET",
|
|
1058
1192
|
models_url,
|
|
1059
1193
|
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
|
|
1060
|
-
)
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1194
|
+
) as response:
|
|
1195
|
+
response_time = (time.time() - start_time) * 1000
|
|
1196
|
+
|
|
1197
|
+
if response.status_code == 200:
|
|
1198
|
+
try:
|
|
1199
|
+
models_data = await _read_json_response(response)
|
|
1200
|
+
except ResponseTooLargeError:
|
|
1201
|
+
raise
|
|
1202
|
+
except (json.JSONDecodeError, TypeError):
|
|
1203
|
+
models_data = None
|
|
1204
|
+
connection_succeeded = True
|
|
1205
|
+
test_result["status"] = "✅ 连接成功"
|
|
1206
|
+
test_result["response_time_ms"] = round(response_time, 2)
|
|
1207
|
+
if isinstance(models_data, dict) and isinstance(models_data.get("data"), list):
|
|
1069
1208
|
model_count = len(models_data["data"])
|
|
1070
1209
|
test_result["message"] = f"已获取 {model_count} 个模型"
|
|
1071
1210
|
test_result["available_models"] = [m.get("id") for m in models_data["data"] if isinstance(m, dict)]
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
test_result["status"] = "⚠️ 连接问题"
|
|
1076
|
-
test_result["message"] = f"HTTP {response.status_code}"
|
|
1211
|
+
else:
|
|
1212
|
+
test_result["status"] = "⚠️ 连接问题"
|
|
1213
|
+
test_result["message"] = f"HTTP {response.status_code}"
|
|
1077
1214
|
|
|
1078
1215
|
except httpx.TimeoutException:
|
|
1079
1216
|
test_result["status"] = "❌ 连接超时"
|
|
@@ -1084,67 +1221,11 @@ async def cmd_get_config_info(args):
|
|
|
1084
1221
|
|
|
1085
1222
|
config_info["connection_test"] = test_result
|
|
1086
1223
|
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
async def cmd_toggle_builtin_tools(args):
|
|
1091
|
-
# 查找项目根目录
|
|
1092
|
-
if args.root:
|
|
1093
|
-
root = Path(args.root)
|
|
1094
|
-
if not root.exists():
|
|
1095
|
-
print(json.dumps({"error": f"指定的根目录不存在: {args.root}"}, ensure_ascii=False), file=sys.stderr)
|
|
1224
|
+
if not connection_succeeded:
|
|
1225
|
+
print(json.dumps(config_info, ensure_ascii=False, indent=2), file=sys.stderr)
|
|
1096
1226
|
sys.exit(1)
|
|
1097
|
-
else:
|
|
1098
|
-
root = Path.cwd()
|
|
1099
|
-
while root != root.parent and not (root / ".git").exists():
|
|
1100
|
-
root = root.parent
|
|
1101
|
-
if not (root / ".git").exists():
|
|
1102
|
-
print(json.dumps({
|
|
1103
|
-
"error": "未找到 .git 目录。使用 --root 指定项目根目录。",
|
|
1104
|
-
"hint": "从 git 仓库中运行此命令,或指定 --root PATH"
|
|
1105
|
-
}, ensure_ascii=False), file=sys.stderr)
|
|
1106
|
-
sys.exit(1)
|
|
1107
|
-
|
|
1108
|
-
settings_path = root / ".agent" / "settings.json"
|
|
1109
|
-
tools = ["WebFetch", "WebSearch"]
|
|
1110
1227
|
|
|
1111
|
-
|
|
1112
|
-
if settings_path.exists():
|
|
1113
|
-
with open(settings_path, 'r', encoding='utf-8') as f:
|
|
1114
|
-
settings = json.load(f)
|
|
1115
|
-
else:
|
|
1116
|
-
settings = {"permissions": {"deny": []}}
|
|
1117
|
-
|
|
1118
|
-
deny = settings.setdefault("permissions", {}).setdefault("deny", [])
|
|
1119
|
-
blocked = all(t in deny for t in tools)
|
|
1120
|
-
|
|
1121
|
-
# 执行操作
|
|
1122
|
-
action = args.action.lower()
|
|
1123
|
-
if action in ["on", "enable"]:
|
|
1124
|
-
for t in tools:
|
|
1125
|
-
if t not in deny:
|
|
1126
|
-
deny.append(t)
|
|
1127
|
-
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1128
|
-
with open(settings_path, 'w', encoding='utf-8') as f:
|
|
1129
|
-
json.dump(settings, f, ensure_ascii=False, indent=2)
|
|
1130
|
-
msg = "内置工具已禁用"
|
|
1131
|
-
blocked = True
|
|
1132
|
-
elif action in ["off", "disable"]:
|
|
1133
|
-
deny[:] = [t for t in deny if t not in tools]
|
|
1134
|
-
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1135
|
-
with open(settings_path, 'w', encoding='utf-8') as f:
|
|
1136
|
-
json.dump(settings, f, ensure_ascii=False, indent=2)
|
|
1137
|
-
msg = "内置工具已启用"
|
|
1138
|
-
blocked = False
|
|
1139
|
-
else:
|
|
1140
|
-
msg = f"内置工具当前{'已禁用' if blocked else '已启用'}"
|
|
1141
|
-
|
|
1142
|
-
print(json.dumps({
|
|
1143
|
-
"blocked": blocked,
|
|
1144
|
-
"deny_list": deny,
|
|
1145
|
-
"file": str(settings_path),
|
|
1146
|
-
"message": msg
|
|
1147
|
-
}, ensure_ascii=False, indent=2))
|
|
1228
|
+
print(json.dumps(config_info, ensure_ascii=False, indent=2))
|
|
1148
1229
|
|
|
1149
1230
|
|
|
1150
1231
|
# ============================================================================
|
|
@@ -1158,7 +1239,6 @@ async def _run_command(args):
|
|
|
1158
1239
|
"web_fetch": cmd_web_fetch,
|
|
1159
1240
|
"web_map": cmd_web_map,
|
|
1160
1241
|
"get_config_info": cmd_get_config_info,
|
|
1161
|
-
"toggle_builtin_tools": cmd_toggle_builtin_tools,
|
|
1162
1242
|
}
|
|
1163
1243
|
try:
|
|
1164
1244
|
await commands[args.command](args)
|
|
@@ -1167,6 +1247,7 @@ async def _run_command(args):
|
|
|
1167
1247
|
|
|
1168
1248
|
|
|
1169
1249
|
def main():
|
|
1250
|
+
load_dotenv()
|
|
1170
1251
|
parser = argparse.ArgumentParser(
|
|
1171
1252
|
prog="groksearch_cli",
|
|
1172
1253
|
description="GrokSearch CLI - 通过 Grok/Tavily 进行独立的网页搜索/获取/映射"
|
|
@@ -1205,11 +1286,6 @@ def main():
|
|
|
1205
1286
|
p_config = subparsers.add_parser("get_config_info", help="显示配置并测试连接")
|
|
1206
1287
|
p_config.add_argument("--no-test", action="store_true", help="跳过连接测试")
|
|
1207
1288
|
|
|
1208
|
-
# toggle_builtin_tools
|
|
1209
|
-
p_toggle = subparsers.add_parser("toggle_builtin_tools", help="切换内置 WebSearch/WebFetch")
|
|
1210
|
-
p_toggle.add_argument("--action", "-a", default="status", help="操作: on/off/status")
|
|
1211
|
-
p_toggle.add_argument("--root", "-r", help="项目根路径 (默认: 通过 .git 自动检测)")
|
|
1212
|
-
|
|
1213
1289
|
args = parser.parse_args()
|
|
1214
1290
|
|
|
1215
1291
|
# 应用覆盖
|