vox-code 2.0.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.
- vox_code-2.0.0.dist-info/METADATA +258 -0
- vox_code-2.0.0.dist-info/RECORD +88 -0
- vox_code-2.0.0.dist-info/WHEEL +4 -0
- vox_code-2.0.0.dist-info/entry_points.txt +3 -0
- voxcli/__init__.py +3 -0
- voxcli/__main__.py +5 -0
- voxcli/agent/__init__.py +12 -0
- voxcli/agent/agent.py +449 -0
- voxcli/agent/agent_budget.py +133 -0
- voxcli/agent/agent_orchestrator.py +414 -0
- voxcli/agent/plan_execute_agent.py +514 -0
- voxcli/agent/roles.py +80 -0
- voxcli/agent/sub_agent.py +351 -0
- voxcli/catalog.py +477 -0
- voxcli/chat.py +91 -0
- voxcli/cli/__init__.py +4 -0
- voxcli/cli/main.py +452 -0
- voxcli/cli/parser.py +71 -0
- voxcli/config.py +518 -0
- voxcli/gui/__main__.py +3 -0
- voxcli/gui/main.py +22 -0
- voxcli/gui/pet/__init__.py +5 -0
- voxcli/gui/pet/base.py +62 -0
- voxcli/gui/pet/coordinator.py +888 -0
- voxcli/gui/pet/data.py +430 -0
- voxcli/gui/pet/widgets.py +683 -0
- voxcli/gui/pet/windows.py +2298 -0
- voxcli/gui/pet/workers.py +54 -0
- voxcli/gui/pet_app.py +7 -0
- voxcli/hitl/__init__.py +11 -0
- voxcli/hitl/handler.py +11 -0
- voxcli/hitl/policy.py +32 -0
- voxcli/hitl/request.py +13 -0
- voxcli/hitl/result.py +11 -0
- voxcli/hitl/terminal_handler.py +64 -0
- voxcli/hitl/tool_registry.py +64 -0
- voxcli/llm/base.py +93 -0
- voxcli/llm/factory.py +178 -0
- voxcli/llm/ollama_client.py +137 -0
- voxcli/llm/openai_compatible.py +249 -0
- voxcli/memory/base.py +16 -0
- voxcli/memory/budget.py +53 -0
- voxcli/memory/compressor.py +198 -0
- voxcli/memory/entry.py +36 -0
- voxcli/memory/long_term.py +126 -0
- voxcli/memory/manager.py +101 -0
- voxcli/memory/retriever.py +72 -0
- voxcli/memory/short_term.py +84 -0
- voxcli/memory/tokenizer.py +21 -0
- voxcli/plan/__init__.py +5 -0
- voxcli/plan/execution_plan.py +225 -0
- voxcli/plan/planner.py +198 -0
- voxcli/plan/task.py +123 -0
- voxcli/policy/audit_log.py +111 -0
- voxcli/policy/command_guard.py +34 -0
- voxcli/policy/exception.py +5 -0
- voxcli/policy/path_guard.py +32 -0
- voxcli/prompting/__init__.py +7 -0
- voxcli/prompting/presenter.py +154 -0
- voxcli/rag/__init__.py +16 -0
- voxcli/rag/analyzer.py +89 -0
- voxcli/rag/chunk.py +17 -0
- voxcli/rag/chunker.py +137 -0
- voxcli/rag/embedding.py +75 -0
- voxcli/rag/formatter.py +40 -0
- voxcli/rag/index.py +96 -0
- voxcli/rag/relation.py +14 -0
- voxcli/rag/retriever.py +58 -0
- voxcli/rag/store.py +155 -0
- voxcli/rag/tokenizer.py +26 -0
- voxcli/runtime/__init__.py +6 -0
- voxcli/runtime/session_controller.py +386 -0
- voxcli/tool/__init__.py +3 -0
- voxcli/tool/tool_registry.py +433 -0
- voxcli/util/animation.py +219 -0
- voxcli/util/ansi.py +82 -0
- voxcli/util/markdown.py +98 -0
- voxcli/web/__init__.py +17 -0
- voxcli/web/base.py +20 -0
- voxcli/web/extractor.py +77 -0
- voxcli/web/factory.py +38 -0
- voxcli/web/fetch_result.py +27 -0
- voxcli/web/fetcher.py +42 -0
- voxcli/web/network_policy.py +49 -0
- voxcli/web/result.py +23 -0
- voxcli/web/searxng.py +55 -0
- voxcli/web/serpapi.py +53 -0
- voxcli/web/zhipu.py +55 -0
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""工具注册表 - 管理所有可用工具"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
import logging
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor, Future, TimeoutError
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, List, Optional, Callable, Any
|
|
10
|
+
|
|
11
|
+
from ..policy.path_guard import PathGuard
|
|
12
|
+
from ..policy.command_guard import CommandGuard
|
|
13
|
+
from ..policy.exception import PolicyException
|
|
14
|
+
from ..policy.audit_log import AuditLog
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
_DEFAULT_COMMAND_TIMEOUT = 60
|
|
19
|
+
_DEFAULT_BATCH_TIMEOUT = 90
|
|
20
|
+
_MAX_PARALLEL_TOOLS = 4
|
|
21
|
+
_MAX_COMMAND_OUTPUT_CHARS = 8_000
|
|
22
|
+
_MAX_WRITE_FILE_BYTES = 5 * 1024 * 1024
|
|
23
|
+
_DEFAULT_FETCH_MAX_CHARS = 8_000
|
|
24
|
+
_AUDIT_TOOLS = {"write_file", "execute_command", "create_project"}
|
|
25
|
+
|
|
26
|
+
ToolExecutor = Callable[[Dict[str, str]], str]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ToolDef:
|
|
30
|
+
def __init__(self, name: str, description: str, parameters: dict, executor: ToolExecutor):
|
|
31
|
+
self.name = name
|
|
32
|
+
self.description = description
|
|
33
|
+
self.parameters = parameters
|
|
34
|
+
self.executor = executor
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ToolInvocation:
|
|
38
|
+
def __init__(self, id: str, name: str, arguments_json: str):
|
|
39
|
+
self.id = id
|
|
40
|
+
self.name = name
|
|
41
|
+
self.arguments_json = arguments_json
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ToolExecutionResult:
|
|
45
|
+
def __init__(self, id: str, name: str, arguments_json: str,
|
|
46
|
+
result: str, elapsed_ms: float, timed_out: bool = False):
|
|
47
|
+
self.id = id
|
|
48
|
+
self.name = name
|
|
49
|
+
self.arguments_json = arguments_json
|
|
50
|
+
self.result = result
|
|
51
|
+
self.elapsed_ms = elapsed_ms
|
|
52
|
+
self.timed_out = timed_out
|
|
53
|
+
|
|
54
|
+
@staticmethod
|
|
55
|
+
def completed(invocation: ToolInvocation, result: str, elapsed_ms: float) -> "ToolExecutionResult":
|
|
56
|
+
return ToolExecutionResult(invocation.id, invocation.name, invocation.arguments_json,
|
|
57
|
+
result, elapsed_ms, False)
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def failed(invocation: ToolInvocation, message: str) -> "ToolExecutionResult":
|
|
61
|
+
return ToolExecutionResult.completed(invocation, f"工具执行失败: {message}", 0)
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def timed_out(invocation: ToolInvocation, timeout_seconds: int) -> "ToolExecutionResult":
|
|
65
|
+
return ToolExecutionResult(
|
|
66
|
+
invocation.id, invocation.name, invocation.arguments_json,
|
|
67
|
+
f"工具执行超时({timeout_seconds}秒),已取消",
|
|
68
|
+
timeout_seconds * 1000, True
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ToolRegistry:
|
|
73
|
+
def __init__(self, command_timeout: int = _DEFAULT_COMMAND_TIMEOUT,
|
|
74
|
+
batch_timeout: int = _DEFAULT_BATCH_TIMEOUT):
|
|
75
|
+
self._tools: Dict[str, ToolDef] = {}
|
|
76
|
+
self._command_timeout = command_timeout
|
|
77
|
+
self._batch_timeout = batch_timeout
|
|
78
|
+
self._project_path: str = Path.cwd().as_posix()
|
|
79
|
+
self._path_guard = PathGuard(self._project_path)
|
|
80
|
+
self._audit_log = AuditLog()
|
|
81
|
+
self._search_provider = None
|
|
82
|
+
self._web_fetcher = None
|
|
83
|
+
self._html_extractor = None
|
|
84
|
+
self._network_policy = None
|
|
85
|
+
self._register_all()
|
|
86
|
+
|
|
87
|
+
def _register_all(self):
|
|
88
|
+
self._register_file_tools()
|
|
89
|
+
self._register_shell_tools()
|
|
90
|
+
self._register_code_tools()
|
|
91
|
+
self._register_rag_tools()
|
|
92
|
+
self._register_web_tools()
|
|
93
|
+
|
|
94
|
+
def set_project_path(self, project_path: str):
|
|
95
|
+
self._project_path = project_path
|
|
96
|
+
self._path_guard = PathGuard(project_path)
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def project_path(self) -> str:
|
|
100
|
+
return self._project_path
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def audit_log(self) -> AuditLog:
|
|
104
|
+
return self._audit_log
|
|
105
|
+
|
|
106
|
+
def get_tool_definitions(self) -> List[ToolDef]:
|
|
107
|
+
return list(self._tools.values())
|
|
108
|
+
|
|
109
|
+
def has_tool(self, name: str) -> bool:
|
|
110
|
+
return name in self._tools
|
|
111
|
+
|
|
112
|
+
def execute_tool(self, name: str, arguments_json: str) -> str:
|
|
113
|
+
tool = self._tools.get(name)
|
|
114
|
+
if tool is None:
|
|
115
|
+
return f"未知工具: {name}"
|
|
116
|
+
|
|
117
|
+
should_audit = name in _AUDIT_TOOLS
|
|
118
|
+
start = time.time()
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
args = json.loads(arguments_json)
|
|
122
|
+
arg_map = {k: str(v) for k, v in args.items()}
|
|
123
|
+
result = tool.executor(arg_map)
|
|
124
|
+
if should_audit:
|
|
125
|
+
self._audit_log.record(AuditLog.allow(name, arguments_json,
|
|
126
|
+
_elapsed_ms(start)))
|
|
127
|
+
return result
|
|
128
|
+
except PolicyException as e:
|
|
129
|
+
if should_audit:
|
|
130
|
+
self._audit_log.record(AuditLog.deny_by_policy(
|
|
131
|
+
name, arguments_json, str(e), _elapsed_ms(start)))
|
|
132
|
+
return f"🛡️ 策略拒绝: {e}"
|
|
133
|
+
except Exception as e:
|
|
134
|
+
if should_audit:
|
|
135
|
+
self._audit_log.record(AuditLog.error(
|
|
136
|
+
name, arguments_json, str(e), _elapsed_ms(start)))
|
|
137
|
+
return f"工具执行失败: {e}"
|
|
138
|
+
|
|
139
|
+
def execute_tools(self, invocations: List[ToolInvocation]) -> List[ToolExecutionResult]:
|
|
140
|
+
if not invocations:
|
|
141
|
+
return []
|
|
142
|
+
if len(invocations) == 1:
|
|
143
|
+
inv = invocations[0]
|
|
144
|
+
start = time.time()
|
|
145
|
+
result = self.execute_tool(inv.name, inv.arguments_json)
|
|
146
|
+
return [ToolExecutionResult.completed(inv, result, _elapsed_ms(start))]
|
|
147
|
+
|
|
148
|
+
parallelism = min(len(invocations), _MAX_PARALLEL_TOOLS)
|
|
149
|
+
results: List[Optional[ToolExecutionResult]] = [None] * len(invocations)
|
|
150
|
+
|
|
151
|
+
with ThreadPoolExecutor(max_workers=parallelism) as executor:
|
|
152
|
+
future_map: Dict[Future, int] = {}
|
|
153
|
+
for i, inv in enumerate(invocations):
|
|
154
|
+
future = executor.submit(self._execute_single, inv)
|
|
155
|
+
future_map[future] = i
|
|
156
|
+
|
|
157
|
+
for future in future_map:
|
|
158
|
+
idx = future_map[future]
|
|
159
|
+
try:
|
|
160
|
+
results[idx] = future.result(timeout=self._batch_timeout)
|
|
161
|
+
except TimeoutError:
|
|
162
|
+
results[idx] = ToolExecutionResult.timed_out(
|
|
163
|
+
invocations[idx], self._batch_timeout)
|
|
164
|
+
except Exception as e:
|
|
165
|
+
results[idx] = ToolExecutionResult.failed(invocations[idx], str(e))
|
|
166
|
+
|
|
167
|
+
return [r for r in results if r is not None]
|
|
168
|
+
|
|
169
|
+
def _execute_single(self, inv: ToolInvocation) -> ToolExecutionResult:
|
|
170
|
+
start = time.time()
|
|
171
|
+
result = self.execute_tool(inv.name, inv.arguments_json)
|
|
172
|
+
return ToolExecutionResult.completed(inv, result, _elapsed_ms(start))
|
|
173
|
+
|
|
174
|
+
# ---- Tool registration ----
|
|
175
|
+
|
|
176
|
+
def _register_file_tools(self):
|
|
177
|
+
self._tools["read_file"] = ToolDef(
|
|
178
|
+
name="read_file",
|
|
179
|
+
description="读取文件内容(仅限项目根目录之内)",
|
|
180
|
+
parameters=_make_params({"path": {"type": "string", "description": "文件路径"}}, ["path"]),
|
|
181
|
+
executor=lambda args: self._read_file(args.get("path", "")),
|
|
182
|
+
)
|
|
183
|
+
self._tools["write_file"] = ToolDef(
|
|
184
|
+
name="write_file",
|
|
185
|
+
description="写入文件内容(仅限项目根目录之内,单文件 5MB 上限)",
|
|
186
|
+
parameters=_make_params({
|
|
187
|
+
"path": {"type": "string", "description": "文件路径"},
|
|
188
|
+
"content": {"type": "string", "description": "文件内容"},
|
|
189
|
+
}, ["path", "content"]),
|
|
190
|
+
executor=lambda args: self._write_file(args.get("path", ""), args.get("content", "")),
|
|
191
|
+
)
|
|
192
|
+
self._tools["list_dir"] = ToolDef(
|
|
193
|
+
name="list_dir",
|
|
194
|
+
description="列出目录内容(仅限项目根目录之内)",
|
|
195
|
+
parameters=_make_params({"path": {"type": "string", "description": "目录路径"}}, ["path"]),
|
|
196
|
+
executor=lambda args: self._list_dir(args.get("path", "")),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def _register_shell_tools(self):
|
|
200
|
+
self._tools["execute_command"] = ToolDef(
|
|
201
|
+
name="execute_command",
|
|
202
|
+
description="在当前项目目录中执行短时 Shell 命令(默认 60 秒超时,不允许全盘扫描)",
|
|
203
|
+
parameters=_make_params({"command": {"type": "string", "description": "要执行的命令"}}, ["command"]),
|
|
204
|
+
executor=lambda args: self._execute_command(args.get("command", "")),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
def _register_code_tools(self):
|
|
208
|
+
self._tools["create_project"] = ToolDef(
|
|
209
|
+
name="create_project",
|
|
210
|
+
description="创建新项目结构",
|
|
211
|
+
parameters=_make_params({
|
|
212
|
+
"name": {"type": "string", "description": "项目名称"},
|
|
213
|
+
"type": {"type": "string", "description": "项目类型 (java/python/node)"},
|
|
214
|
+
}, ["name", "type"]),
|
|
215
|
+
executor=lambda args: self._create_project(args.get("name", ""), args.get("type", "")),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def _register_rag_tools(self):
|
|
219
|
+
self._tools["search_code"] = ToolDef(
|
|
220
|
+
name="search_code",
|
|
221
|
+
description="语义检索代码库,根据自然语言描述查找相关代码块",
|
|
222
|
+
parameters=_make_params({
|
|
223
|
+
"query": {"type": "string", "description": "自然语言查询描述"},
|
|
224
|
+
"top_k": {"type": "integer", "description": "返回结果数量(默认5)"},
|
|
225
|
+
}, ["query"]),
|
|
226
|
+
executor=lambda args: self._search_code(args.get("query", ""),
|
|
227
|
+
int(args.get("top_k", "5"))),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def _register_web_tools(self):
|
|
231
|
+
self._tools["web_search"] = ToolDef(
|
|
232
|
+
name="web_search",
|
|
233
|
+
description="搜索互联网,获取实时信息(最新版本、官方文档、技术资讯等)",
|
|
234
|
+
parameters=_make_params({
|
|
235
|
+
"query": {"type": "string", "description": "搜索关键词"},
|
|
236
|
+
"top_k": {"type": "integer", "description": "返回结果数量(默认5)"},
|
|
237
|
+
}, ["query"]),
|
|
238
|
+
executor=lambda args: self._web_search(args.get("query", ""),
|
|
239
|
+
int(args.get("top_k", "5"))),
|
|
240
|
+
)
|
|
241
|
+
self._tools["web_fetch"] = ToolDef(
|
|
242
|
+
name="web_fetch",
|
|
243
|
+
description="抓取指定 URL,提取正文转 Markdown",
|
|
244
|
+
parameters=_make_params({
|
|
245
|
+
"url": {"type": "string", "description": "完整 URL,需 http 或 https 协议"},
|
|
246
|
+
"max_chars": {"type": "integer", "description": "返回 Markdown 最大字符数(默认 8000)"},
|
|
247
|
+
}, ["url"]),
|
|
248
|
+
executor=lambda args: self._web_fetch(args.get("url", ""),
|
|
249
|
+
int(args.get("max_chars", str(_DEFAULT_FETCH_MAX_CHARS)))),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# ---- Tool implementations ----
|
|
253
|
+
|
|
254
|
+
def _read_file(self, path: str) -> str:
|
|
255
|
+
safe = self._path_guard.resolve_safe(path)
|
|
256
|
+
try:
|
|
257
|
+
content = Path(safe).read_text(encoding="utf-8")
|
|
258
|
+
return f"文件内容:\n{content}"
|
|
259
|
+
except Exception as e:
|
|
260
|
+
return f"读取文件失败: {e}"
|
|
261
|
+
|
|
262
|
+
def _write_file(self, path: str, content: str) -> str:
|
|
263
|
+
content_bytes = content.encode("utf-8")
|
|
264
|
+
if len(content_bytes) > _MAX_WRITE_FILE_BYTES:
|
|
265
|
+
raise PolicyException(
|
|
266
|
+
f"写入内容 {len(content_bytes)} 字节超过 {_MAX_WRITE_FILE_BYTES // 1024 // 1024}MB 上限")
|
|
267
|
+
safe = self._path_guard.resolve_safe(path)
|
|
268
|
+
try:
|
|
269
|
+
Path(safe).parent.mkdir(parents=True, exist_ok=True)
|
|
270
|
+
Path(safe).write_text(content, encoding="utf-8")
|
|
271
|
+
return f"文件已写入: {path}"
|
|
272
|
+
except Exception as e:
|
|
273
|
+
return f"写入文件失败: {e}"
|
|
274
|
+
|
|
275
|
+
def _list_dir(self, path: str) -> str:
|
|
276
|
+
safe = self._path_guard.resolve_safe(path)
|
|
277
|
+
try:
|
|
278
|
+
entries = list(Path(safe).iterdir())
|
|
279
|
+
if not entries:
|
|
280
|
+
return "目录为空或不存在"
|
|
281
|
+
lines = ["目录内容:"]
|
|
282
|
+
for entry in sorted(entries, key=lambda x: (not x.is_dir(), x.name)):
|
|
283
|
+
prefix = "[D]" if entry.is_dir() else "[F]"
|
|
284
|
+
lines.append(f"{prefix} {entry.name}")
|
|
285
|
+
return "\n".join(lines)
|
|
286
|
+
except Exception as e:
|
|
287
|
+
return f"列出目录失败: {e}"
|
|
288
|
+
|
|
289
|
+
def _execute_command(self, command: str) -> str:
|
|
290
|
+
normalized = (command or "").strip()
|
|
291
|
+
if not normalized:
|
|
292
|
+
return "执行命令失败: 命令不能为空"
|
|
293
|
+
deny_reason = CommandGuard.check(normalized)
|
|
294
|
+
if deny_reason:
|
|
295
|
+
raise PolicyException(deny_reason)
|
|
296
|
+
try:
|
|
297
|
+
proc = subprocess.run(
|
|
298
|
+
["bash", "-c", normalized],
|
|
299
|
+
cwd=self._project_path,
|
|
300
|
+
capture_output=True, text=True,
|
|
301
|
+
timeout=self._command_timeout,
|
|
302
|
+
)
|
|
303
|
+
output = proc.stdout + proc.stderr
|
|
304
|
+
if len(output) > _MAX_COMMAND_OUTPUT_CHARS:
|
|
305
|
+
output = output[:_MAX_COMMAND_OUTPUT_CHARS] + "\n...(输出已截断)"
|
|
306
|
+
return f"命令执行完成 (exit code: {proc.returncode})\n{output}"
|
|
307
|
+
except subprocess.TimeoutExpired:
|
|
308
|
+
return f"命令执行超时({self._command_timeout}秒),已强制终止"
|
|
309
|
+
except Exception as e:
|
|
310
|
+
return f"执行命令失败: {e}"
|
|
311
|
+
|
|
312
|
+
def _create_project(self, name: str, type_: str) -> str:
|
|
313
|
+
safe = self._path_guard.resolve_safe(name)
|
|
314
|
+
try:
|
|
315
|
+
root = Path(safe)
|
|
316
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
317
|
+
if type_ == "java":
|
|
318
|
+
(root / "src" / "main" / "java").mkdir(parents=True)
|
|
319
|
+
(root / "src" / "main" / "resources").mkdir(parents=True)
|
|
320
|
+
(root / "pom.xml").write_text(
|
|
321
|
+
f'<?xml version="1.0" encoding="UTF-8"?>\n<project>\n'
|
|
322
|
+
f' <modelVersion>4.0.0</modelVersion>\n'
|
|
323
|
+
f' <groupId>com.example</groupId>\n'
|
|
324
|
+
f' <artifactId>{name}</artifactId>\n'
|
|
325
|
+
f' <version>1.0</version>\n</project>')
|
|
326
|
+
elif type_ == "python":
|
|
327
|
+
(root / name).mkdir(exist_ok=True)
|
|
328
|
+
(root / "main.py").write_text("# 主程序入口\n")
|
|
329
|
+
(root / "requirements.txt").write_text("# 依赖列表\n")
|
|
330
|
+
elif type_ == "node":
|
|
331
|
+
(root / "package.json").write_text(
|
|
332
|
+
f'{{"name": "{name}", "version": "1.0.0"}}')
|
|
333
|
+
return f"项目已创建: {name} (类型: {type_})"
|
|
334
|
+
except Exception as e:
|
|
335
|
+
return f"创建项目失败: {e}"
|
|
336
|
+
|
|
337
|
+
def _search_code(self, query: str, top_k: int) -> str:
|
|
338
|
+
try:
|
|
339
|
+
from ..rag.retriever import CodeRetriever
|
|
340
|
+
from ..rag.formatter import SearchResultFormatter
|
|
341
|
+
with CodeRetriever(self._project_path) as retriever:
|
|
342
|
+
stats = retriever.get_stats()
|
|
343
|
+
if stats.chunk_count == 0:
|
|
344
|
+
return "代码库尚未索引,请先使用 /index 命令索引当前项目。"
|
|
345
|
+
results = retriever.hybrid_search(query, top_k)
|
|
346
|
+
if not results:
|
|
347
|
+
return "未找到与查询相关的代码。"
|
|
348
|
+
return SearchResultFormatter.format_for_tool(query, results)
|
|
349
|
+
except ImportError:
|
|
350
|
+
return "代码检索功能不可用(缺少依赖模块)"
|
|
351
|
+
except Exception as e:
|
|
352
|
+
return f"代码检索失败: {e}"
|
|
353
|
+
|
|
354
|
+
def _web_search(self, query: str, top_k: int) -> str:
|
|
355
|
+
if not query:
|
|
356
|
+
return "搜索关键词不能为空"
|
|
357
|
+
try:
|
|
358
|
+
from ..web.factory import SearchProviderFactory
|
|
359
|
+
provider = SearchProviderFactory.create()
|
|
360
|
+
if not provider.is_ready():
|
|
361
|
+
return f"⚠️ {provider.unavailable_hint()}"
|
|
362
|
+
results = provider.search(query, top_k)
|
|
363
|
+
return self._format_search_results(provider.name, query, results)
|
|
364
|
+
except ImportError:
|
|
365
|
+
return "联网搜索功能不可用(缺少 web 模块依赖)"
|
|
366
|
+
except Exception as e:
|
|
367
|
+
return f"搜索失败: {e}"
|
|
368
|
+
|
|
369
|
+
@staticmethod
|
|
370
|
+
def _format_search_results(provider_name: str, query: str, results) -> str:
|
|
371
|
+
lines = [f"🔍 [{provider_name}] {query}\n"]
|
|
372
|
+
if not results:
|
|
373
|
+
lines.append("未找到相关结果。")
|
|
374
|
+
else:
|
|
375
|
+
for r in results:
|
|
376
|
+
snippet = (r.snippet[:200] + "...") if len(r.snippet) > 200 else r.snippet
|
|
377
|
+
lines.append(f"{r.position}. {r.title}")
|
|
378
|
+
if snippet:
|
|
379
|
+
lines.append(f" {snippet}")
|
|
380
|
+
url_part = f" 🔗 {r.url}"
|
|
381
|
+
if r.source:
|
|
382
|
+
url_part += f" ({r.source})"
|
|
383
|
+
lines.append(url_part)
|
|
384
|
+
lines.append("")
|
|
385
|
+
return "\n".join(lines).strip()
|
|
386
|
+
|
|
387
|
+
def _web_fetch(self, url: str, max_chars: int) -> str:
|
|
388
|
+
if not url:
|
|
389
|
+
return "URL 不能为空"
|
|
390
|
+
try:
|
|
391
|
+
from ..web.network_policy import NetworkPolicy
|
|
392
|
+
from ..web.fetcher import WebFetcher
|
|
393
|
+
from ..web.extractor import HtmlExtractor
|
|
394
|
+
|
|
395
|
+
policy = NetworkPolicy()
|
|
396
|
+
deny = policy.check_url(url)
|
|
397
|
+
if deny:
|
|
398
|
+
return f"❌ 网络访问被拒绝: {deny}"
|
|
399
|
+
rate_reason = policy.acquire()
|
|
400
|
+
if rate_reason:
|
|
401
|
+
return f"❌ {rate_reason}"
|
|
402
|
+
|
|
403
|
+
raw = WebFetcher().fetch(url)
|
|
404
|
+
extracted = HtmlExtractor().extract(raw["body"], raw["url"])
|
|
405
|
+
md = extracted["markdown"]
|
|
406
|
+
original_length = len(md)
|
|
407
|
+
truncated = False
|
|
408
|
+
if max_chars > 0 and len(md) > max_chars:
|
|
409
|
+
md = md[:max_chars]
|
|
410
|
+
truncated = True
|
|
411
|
+
|
|
412
|
+
lines = [f"🌐 抓取: {raw['url']}"]
|
|
413
|
+
if extracted.get("title"):
|
|
414
|
+
lines.append(f"📄 标题: {extracted['title']}")
|
|
415
|
+
if not md:
|
|
416
|
+
lines.append("\n⚠️ 正文为空,可能是 SPA 或防爬墙(已知边界,不重试)")
|
|
417
|
+
else:
|
|
418
|
+
lines.append(f"📏 正文 {original_length} 字符{'(已截断)' if truncated else ''}")
|
|
419
|
+
lines.append("\n---\n")
|
|
420
|
+
lines.append(md)
|
|
421
|
+
return "\n".join(lines)
|
|
422
|
+
except ImportError:
|
|
423
|
+
return "网页抓取功能不可用(缺少 web 模块依赖)"
|
|
424
|
+
except Exception as e:
|
|
425
|
+
return f"抓取失败: {e}"
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _make_params(properties: dict, required: list) -> dict:
|
|
429
|
+
return {"type": "object", "properties": properties, "required": required}
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _elapsed_ms(start: float) -> float:
|
|
433
|
+
return (time.time() - start) * 1000
|
voxcli/util/animation.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""终端动画效果 - Claude Code 风格的 thinking 动画、打字机效果、工具调用动画"""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
import threading
|
|
6
|
+
from typing import Optional, TextIO
|
|
7
|
+
|
|
8
|
+
from .ansi import dim, subtle, success, is_enabled
|
|
9
|
+
|
|
10
|
+
# ============================================================
|
|
11
|
+
# Thinking 动画
|
|
12
|
+
# ============================================================
|
|
13
|
+
|
|
14
|
+
class ThinkingDots:
|
|
15
|
+
"""Claude Code 风格的 thinking 动画 (● ● ●)
|
|
16
|
+
|
|
17
|
+
在 LLM 响应等待期间显示脉冲动画,收到首个 delta 后自动停止。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
_FRAMES = ["● ○ ○", "○ ● ○", "○ ○ ●", "○ ● ○"]
|
|
21
|
+
|
|
22
|
+
def __init__(self, stream: Optional[TextIO] = None):
|
|
23
|
+
self._stream = stream or sys.stdout
|
|
24
|
+
self._running = False
|
|
25
|
+
self._thread: Optional[threading.Thread] = None
|
|
26
|
+
|
|
27
|
+
def start(self):
|
|
28
|
+
if not is_enabled() or not self._stream.isatty():
|
|
29
|
+
self._running = False
|
|
30
|
+
return
|
|
31
|
+
self._running = True
|
|
32
|
+
self._thread = threading.Thread(target=self._animate, daemon=True)
|
|
33
|
+
self._thread.start()
|
|
34
|
+
|
|
35
|
+
def _animate(self):
|
|
36
|
+
idx = 0
|
|
37
|
+
while self._running:
|
|
38
|
+
frame = self._FRAMES[idx % len(self._FRAMES)]
|
|
39
|
+
self._stream.write(f"\r{dim(frame)}")
|
|
40
|
+
self._stream.flush()
|
|
41
|
+
time.sleep(0.25)
|
|
42
|
+
idx += 1
|
|
43
|
+
|
|
44
|
+
def stop(self):
|
|
45
|
+
if not self._running:
|
|
46
|
+
return
|
|
47
|
+
self._running = False
|
|
48
|
+
if self._thread:
|
|
49
|
+
self._thread.join(0.3)
|
|
50
|
+
self._stream.write("\r\033[K")
|
|
51
|
+
self._stream.flush()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ============================================================
|
|
55
|
+
# 打字机效果 - 流式输出
|
|
56
|
+
# ============================================================
|
|
57
|
+
|
|
58
|
+
class Typewriter:
|
|
59
|
+
"""打字机效果 - 流式输出文本。
|
|
60
|
+
|
|
61
|
+
模拟 Claude Code 的输出节奏:字符以微节奏流出(非逐字卡顿),
|
|
62
|
+
ANSI 转义序列整体快速写入,仅在可见字符间引入极短间隔。
|
|
63
|
+
非 TTY 环境退化为直接输出。
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self, stream: Optional[TextIO] = None, char_delay: float = 0.004):
|
|
67
|
+
self._stream = stream or sys.stdout
|
|
68
|
+
# 仅在 TTY 启用延迟,管道/重定向直接输出
|
|
69
|
+
self._char_delay = char_delay if is_enabled() and self._stream.isatty() else 0.0
|
|
70
|
+
self._in_escape = False
|
|
71
|
+
|
|
72
|
+
def write(self, text: str):
|
|
73
|
+
"""写出文本,可见字符间插入微延迟,ANSI 序列整体跳过。"""
|
|
74
|
+
if self._char_delay <= 0 or not text:
|
|
75
|
+
self._stream.write(text)
|
|
76
|
+
self._stream.flush()
|
|
77
|
+
return
|
|
78
|
+
buf = []
|
|
79
|
+
for ch in text:
|
|
80
|
+
if ch == "\033":
|
|
81
|
+
self._in_escape = True
|
|
82
|
+
buf.append(ch)
|
|
83
|
+
continue
|
|
84
|
+
if self._in_escape:
|
|
85
|
+
buf.append(ch)
|
|
86
|
+
# ANSI 序列以字母结尾(a-z / A-Z)
|
|
87
|
+
if ch.isalpha():
|
|
88
|
+
self._in_escape = False
|
|
89
|
+
continue
|
|
90
|
+
buf.append(ch)
|
|
91
|
+
# 累积一段后写出并延迟
|
|
92
|
+
if len(buf) >= 32 or ch == "\n":
|
|
93
|
+
self._stream.write("".join(buf))
|
|
94
|
+
self._stream.flush()
|
|
95
|
+
buf.clear()
|
|
96
|
+
if ch != "\n":
|
|
97
|
+
time.sleep(self._char_delay)
|
|
98
|
+
if buf:
|
|
99
|
+
self._stream.write("".join(buf))
|
|
100
|
+
self._stream.flush()
|
|
101
|
+
|
|
102
|
+
def write_fast(self, text: str):
|
|
103
|
+
"""快速写出,无需动画(代码块、工具结果等)"""
|
|
104
|
+
self._stream.write(text)
|
|
105
|
+
self._stream.flush()
|
|
106
|
+
|
|
107
|
+
def newline(self):
|
|
108
|
+
self._stream.write("\n")
|
|
109
|
+
self._stream.flush()
|
|
110
|
+
|
|
111
|
+
def clear_line(self):
|
|
112
|
+
self._stream.write("\r\033[K")
|
|
113
|
+
self._stream.flush()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ============================================================
|
|
117
|
+
# 工具调用动画(流式)
|
|
118
|
+
# ============================================================
|
|
119
|
+
|
|
120
|
+
class ToolCallAnimator:
|
|
121
|
+
"""工具调用动画 - 显示工具执行状态
|
|
122
|
+
|
|
123
|
+
工具调用使用 ANSI 转义序列实现原地更新:
|
|
124
|
+
1. running() 逐行打印所有工具调用
|
|
125
|
+
2. finish_all() 回退到起始位置,逐行覆写为 ✓ 格式
|
|
126
|
+
|
|
127
|
+
示例输出:
|
|
128
|
+
✓ read_file: /path/to/file
|
|
129
|
+
✓ execute_command: npm test
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def __init__(self, stream: Optional[TextIO] = None):
|
|
133
|
+
self._stream = stream or sys.stdout
|
|
134
|
+
self._can_animate = is_enabled() and self._stream.isatty()
|
|
135
|
+
self._line_count = 0
|
|
136
|
+
self._entries: list[tuple[str, str]] = []
|
|
137
|
+
|
|
138
|
+
def running(self, tool_name: str, detail: str = ""):
|
|
139
|
+
"""记录一个正在运行的工具调用(追加一行)"""
|
|
140
|
+
self._entries.append((tool_name, detail))
|
|
141
|
+
msg = f" > {tool_name}"
|
|
142
|
+
if detail:
|
|
143
|
+
msg += f" {detail}"
|
|
144
|
+
self._stream.write(f"{subtle(msg)}\n")
|
|
145
|
+
self._stream.flush()
|
|
146
|
+
self._line_count += 1
|
|
147
|
+
|
|
148
|
+
def finish_all(self):
|
|
149
|
+
"""将所有工具调用标记为已完成(原地更新)"""
|
|
150
|
+
if not self._entries:
|
|
151
|
+
return
|
|
152
|
+
if self._can_animate:
|
|
153
|
+
# 回退到工具调用列表的起始行
|
|
154
|
+
self._stream.write(f"\033[{self._line_count}A")
|
|
155
|
+
self._stream.flush()
|
|
156
|
+
for name, detail in self._entries:
|
|
157
|
+
msg = f" {name}"
|
|
158
|
+
if detail:
|
|
159
|
+
msg += f": {detail}"
|
|
160
|
+
self._stream.write(f"{success(' ✓')} {subtle(msg)}\n")
|
|
161
|
+
self._stream.flush()
|
|
162
|
+
else:
|
|
163
|
+
for name, detail in self._entries:
|
|
164
|
+
msg = f" {name}"
|
|
165
|
+
if detail:
|
|
166
|
+
msg += f": {detail}"
|
|
167
|
+
self._stream.write(f"{success(' ✓')} {subtle(msg)}\n")
|
|
168
|
+
self._stream.flush()
|
|
169
|
+
self._line_count = 0
|
|
170
|
+
self._entries = []
|
|
171
|
+
|
|
172
|
+
def done(self, tool_name: str, detail: str = ""):
|
|
173
|
+
"""单工具兼容接口(仍使用 \r,仅用于单个工具场景)"""
|
|
174
|
+
msg = f" {tool_name}"
|
|
175
|
+
if detail:
|
|
176
|
+
msg += f": {detail}"
|
|
177
|
+
self._stream.write(f"\r{success(' ✓')} {subtle(msg)}\n")
|
|
178
|
+
self._stream.flush()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ============================================================
|
|
182
|
+
# 进度指示器(轻量)
|
|
183
|
+
# ============================================================
|
|
184
|
+
|
|
185
|
+
class ProgressDots:
|
|
186
|
+
"""简单的进度点动画 — 用于等待、搜索等短暂操作"""
|
|
187
|
+
|
|
188
|
+
def __init__(self, text: str = "", stream: Optional[TextIO] = None):
|
|
189
|
+
self._text = text
|
|
190
|
+
self._stream = stream or sys.stdout
|
|
191
|
+
self._running = False
|
|
192
|
+
self._thread: Optional[threading.Thread] = None
|
|
193
|
+
|
|
194
|
+
def start(self):
|
|
195
|
+
if not is_enabled() or not self._stream.isatty():
|
|
196
|
+
self._stream.write(f"{self._text}...\n")
|
|
197
|
+
return
|
|
198
|
+
self._running = True
|
|
199
|
+
self._thread = threading.Thread(target=self._animate, daemon=True)
|
|
200
|
+
self._thread.start()
|
|
201
|
+
|
|
202
|
+
def _animate(self):
|
|
203
|
+
idx = 0
|
|
204
|
+
prefix = f"\r{self._text}" if self._text else "\r"
|
|
205
|
+
while self._running:
|
|
206
|
+
dots = "." * ((idx % 3) + 1)
|
|
207
|
+
self._stream.write(f"{prefix}{dots} ")
|
|
208
|
+
self._stream.flush()
|
|
209
|
+
time.sleep(0.4)
|
|
210
|
+
idx += 1
|
|
211
|
+
|
|
212
|
+
def stop(self, final_msg: str = ""):
|
|
213
|
+
self._running = False
|
|
214
|
+
if self._thread:
|
|
215
|
+
self._thread.join(0.3)
|
|
216
|
+
self._stream.write("\r\033[K")
|
|
217
|
+
if final_msg:
|
|
218
|
+
self._stream.write(f"{final_msg}\n")
|
|
219
|
+
self._stream.flush()
|