abyss-cli 0.1.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.
- abyss/__init__.py +3 -0
- abyss/ansi_menu.py +559 -0
- abyss/api_client.py +123 -0
- abyss/commands/__init__.py +12 -0
- abyss/commands/slash.py +72 -0
- abyss/config.py +121 -0
- abyss/custom_input.py +382 -0
- abyss/extensions/__init__.py +21 -0
- abyss/extensions/cli.py +160 -0
- abyss/extensions/installer.py +452 -0
- abyss/extensions/registry.py +119 -0
- abyss/extensions/url_parser.py +86 -0
- abyss/hooks/__init__.py +12 -0
- abyss/hooks/runner.py +144 -0
- abyss/logger.py +218 -0
- abyss/main.py +763 -0
- abyss/mcp/__init__.py +13 -0
- abyss/mcp/manager.py +189 -0
- abyss/prompts/__init__.py +26 -0
- abyss/session.py +79 -0
- abyss/skills/__init__.py +12 -0
- abyss/skills/loader.py +150 -0
- abyss/tools/__init__.py +20 -0
- abyss/tools/base.py +45 -0
- abyss/tools/file_edit.py +48 -0
- abyss/tools/file_read.py +54 -0
- abyss/tools/file_write.py +44 -0
- abyss/tools/registry.py +107 -0
- abyss/tools/shell_exec.py +181 -0
- abyss/tools/web_search.py +63 -0
- abyss_cli-0.1.0.dist-info/METADATA +11 -0
- abyss_cli-0.1.0.dist-info/RECORD +35 -0
- abyss_cli-0.1.0.dist-info/WHEEL +5 -0
- abyss_cli-0.1.0.dist-info/entry_points.txt +2 -0
- abyss_cli-0.1.0.dist-info/top_level.txt +1 -0
abyss/main.py
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Abyss CLI 主入口
|
|
4
|
+
类似 Claude Code,一个命令启动,一切在工具内完成。
|
|
5
|
+
补全菜单使用自定义 ANSI 渲染(ansi_menu),绕过 prompt_toolkit 灰色背景问题。
|
|
6
|
+
"""
|
|
7
|
+
import click
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import re
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import itertools
|
|
15
|
+
import traceback
|
|
16
|
+
import inspect
|
|
17
|
+
from .config import Config
|
|
18
|
+
from .api_client import DeepSeekClient
|
|
19
|
+
from .session import Session
|
|
20
|
+
from .prompts import load_system_prompt
|
|
21
|
+
from .tools import (
|
|
22
|
+
FileReadTool, FileWriteTool, FileEditTool,
|
|
23
|
+
ShellExecTool, WebSearchTool
|
|
24
|
+
)
|
|
25
|
+
from .ansi_menu import ansi_prompt
|
|
26
|
+
from . import logger
|
|
27
|
+
|
|
28
|
+
TOOLS = {
|
|
29
|
+
"file_read": FileReadTool(),
|
|
30
|
+
"file_write": FileWriteTool(),
|
|
31
|
+
"file_edit": FileEditTool(),
|
|
32
|
+
"shell_exec": ShellExecTool(),
|
|
33
|
+
"web_search": WebSearchTool()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# 工具调用往返轮数安全网(仅防 AI 死循环,正常任务不会达到)
|
|
37
|
+
MAX_TOOL_ROUNDS = 100
|
|
38
|
+
|
|
39
|
+
_SPINNER_CHARS = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Spinner:
|
|
43
|
+
"""终端动画旋转指示器,类 Claude Code 风格。"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, message="思考中"):
|
|
46
|
+
self._message = message
|
|
47
|
+
self._running = False
|
|
48
|
+
self._thread = None
|
|
49
|
+
self._lock = threading.Lock()
|
|
50
|
+
|
|
51
|
+
def start(self):
|
|
52
|
+
logger.spinner_start(self._message)
|
|
53
|
+
self._running = True
|
|
54
|
+
self._thread = threading.Thread(target=self._spin, daemon=True)
|
|
55
|
+
self._thread.start()
|
|
56
|
+
|
|
57
|
+
def stop(self):
|
|
58
|
+
with self._lock:
|
|
59
|
+
if not self._running:
|
|
60
|
+
return
|
|
61
|
+
self._running = False
|
|
62
|
+
if self._thread:
|
|
63
|
+
self._thread.join(timeout=0.5)
|
|
64
|
+
sys.stdout.write("\r\x1b[K")
|
|
65
|
+
sys.stdout.flush()
|
|
66
|
+
logger.spinner_stop(self._message)
|
|
67
|
+
|
|
68
|
+
def _spin(self):
|
|
69
|
+
chars = itertools.cycle(_SPINNER_CHARS)
|
|
70
|
+
while True:
|
|
71
|
+
with self._lock:
|
|
72
|
+
if not self._running:
|
|
73
|
+
break
|
|
74
|
+
sys.stdout.write(f"\r \033[2m{next(chars)} {self._message}\033[0m")
|
|
75
|
+
sys.stdout.flush()
|
|
76
|
+
time.sleep(0.08)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _get_tc_field(tc, name, default=None):
|
|
80
|
+
"""兼容 dict 和 SDK 对象两种 tool_call 格式"""
|
|
81
|
+
if isinstance(tc, dict):
|
|
82
|
+
return tc.get(name, default)
|
|
83
|
+
return getattr(tc, name, default)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _extract_missing_param(exc: TypeError, func) -> str:
|
|
87
|
+
"""从 TypeError 消息中提取缺失的参数名。找不到时返回空串。
|
|
88
|
+
|
|
89
|
+
Python 的 TypeError 形如:
|
|
90
|
+
"FileEditTool.execute() missing 1 required positional argument: 'path'"
|
|
91
|
+
对多余参数(unexpected keyword argument)也能识别。
|
|
92
|
+
"""
|
|
93
|
+
msg = str(exc)
|
|
94
|
+
if "missing 1 required positional argument:" in msg:
|
|
95
|
+
# 提取 'xxx' 中的 xxx
|
|
96
|
+
try:
|
|
97
|
+
return msg.split("argument: '", 1)[1].split("'", 1)[0]
|
|
98
|
+
except IndexError:
|
|
99
|
+
return ""
|
|
100
|
+
if "missing" in msg and "required" in msg:
|
|
101
|
+
# 兜底:列出 func 的所有必填参数
|
|
102
|
+
try:
|
|
103
|
+
sig = inspect.signature(func)
|
|
104
|
+
required = [
|
|
105
|
+
p.name for p in sig.parameters.values()
|
|
106
|
+
if p.default is inspect.Parameter.empty
|
|
107
|
+
and p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
108
|
+
inspect.Parameter.KEYWORD_ONLY)
|
|
109
|
+
]
|
|
110
|
+
return ", ".join(required)
|
|
111
|
+
except (ValueError, TypeError):
|
|
112
|
+
return ""
|
|
113
|
+
return ""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def merge_tool_calls(chunks):
|
|
117
|
+
"""合并流式 tool_calls delta,按 index 聚合 arguments。返回完整 tool_calls 列表"""
|
|
118
|
+
merged = {}
|
|
119
|
+
for tc in chunks:
|
|
120
|
+
idx = _get_tc_field(tc, "index", 0)
|
|
121
|
+
if idx not in merged:
|
|
122
|
+
merged[idx] = {"id": None, "type": "function", "function": {"name": "", "arguments": ""}}
|
|
123
|
+
m = merged[idx]
|
|
124
|
+
tc_id = _get_tc_field(tc, "id", None)
|
|
125
|
+
if tc_id:
|
|
126
|
+
m["id"] = tc_id
|
|
127
|
+
func = _get_tc_field(tc, "function", {})
|
|
128
|
+
func_name = _get_tc_field(func, "name", "")
|
|
129
|
+
if func_name:
|
|
130
|
+
m["function"]["name"] = func_name
|
|
131
|
+
func_args = _get_tc_field(func, "arguments", "")
|
|
132
|
+
if func_args:
|
|
133
|
+
m["function"]["arguments"] += func_args
|
|
134
|
+
return [merged[i] for i in sorted(merged)]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def execute_tool(tool_call, batch_index: int = None, batch_total: int = None) -> str:
|
|
138
|
+
"""执行单个工具调用,返回给 AI 的结果 JSON。batch_index/batch_total 非空时 UI 加 [i/total] 前缀"""
|
|
139
|
+
func = _get_tc_field(tool_call, "function", {})
|
|
140
|
+
name = _get_tc_field(func, "name", "")
|
|
141
|
+
args_str = _get_tc_field(func, "arguments", "")
|
|
142
|
+
try:
|
|
143
|
+
arguments = json.loads(args_str) if args_str else {}
|
|
144
|
+
except json.JSONDecodeError:
|
|
145
|
+
return json.dumps({"success": False, "error": f"参数解析失败: {args_str}"})
|
|
146
|
+
if name not in TOOLS:
|
|
147
|
+
return json.dumps({"success": False, "error": f"未知工具: {name}"})
|
|
148
|
+
|
|
149
|
+
_show_tool_action(name, arguments, batch_index, batch_total)
|
|
150
|
+
logger.tool_start(name, args=args_str)
|
|
151
|
+
t0 = time.time()
|
|
152
|
+
try:
|
|
153
|
+
result = TOOLS[name].execute(**arguments)
|
|
154
|
+
except TypeError as e:
|
|
155
|
+
# AI 生成的 tool call 缺必要参数(如 file_edit 漏掉 path)。
|
|
156
|
+
# 解析错误信息找出缺失字段名,返回结构化错误让 AI 修正后重试,
|
|
157
|
+
# 而不是让 TypeError 杀死整个 CLI、丢失多轮对话上下文。
|
|
158
|
+
missing = _extract_missing_param(e, TOOLS[name].execute)
|
|
159
|
+
hint = f"(缺失必要参数: {missing})" if missing else ""
|
|
160
|
+
result = {"success": False, "error": f"工具参数不合法: {e} {hint}"}
|
|
161
|
+
elapsed_ms = int((time.time() - t0) * 1000)
|
|
162
|
+
_show_tool_result(name, arguments, result)
|
|
163
|
+
logger.tool_end(name, success=result.get("success", False), duration_ms=elapsed_ms,
|
|
164
|
+
out_len=len(json.dumps(result, ensure_ascii=False)))
|
|
165
|
+
|
|
166
|
+
if name == "shell_exec":
|
|
167
|
+
# 限制给 AI 的输出长度,避免超出 context
|
|
168
|
+
max_len = 4000
|
|
169
|
+
stdout = (result.get("stdout", "") or "")
|
|
170
|
+
stderr = (result.get("stderr", "") or "")
|
|
171
|
+
if len(stdout) > max_len:
|
|
172
|
+
stdout = stdout[:max_len] + f"\n...[截断, 共 {result.get('stdout','').count(chr(10))+1} 行]"
|
|
173
|
+
if len(stderr) > max_len:
|
|
174
|
+
stderr = stderr[:max_len] + f"\n...[截断]"
|
|
175
|
+
return json.dumps({
|
|
176
|
+
"success": result.get("success", False),
|
|
177
|
+
"stdout": stdout,
|
|
178
|
+
"stderr": stderr,
|
|
179
|
+
"returncode": result.get("returncode", None),
|
|
180
|
+
}, ensure_ascii=False)
|
|
181
|
+
|
|
182
|
+
return json.dumps(result, ensure_ascii=False)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _show_tool_action(name: str, args: dict, batch_index: int = None, batch_total: int = None):
|
|
186
|
+
"""显示工具调用信息。batch_index/batch_total 非空时输出 [i/total] 编号前缀"""
|
|
187
|
+
prefix = ""
|
|
188
|
+
if batch_index is not None and batch_total is not None:
|
|
189
|
+
prefix = f"\033[2m[{batch_index}/{batch_total}]\033[0m "
|
|
190
|
+
if name == "shell_exec":
|
|
191
|
+
click.echo(f" {prefix}\033[33m$ {args.get('command', '')}\033[0m", nl=False)
|
|
192
|
+
elif name == "file_write":
|
|
193
|
+
click.echo(f" {prefix}\033[36m写入文件: {args.get('path', '')}\033[0m", nl=False)
|
|
194
|
+
elif name == "file_read":
|
|
195
|
+
click.echo(f" {prefix}\033[36m读取文件: {args.get('path', '')}\033[0m", nl=False)
|
|
196
|
+
elif name == "file_edit":
|
|
197
|
+
click.echo(f" {prefix}\033[36m编辑文件: {args.get('path', '')}\033[0m", nl=False)
|
|
198
|
+
elif name == "web_search":
|
|
199
|
+
click.echo(f" {prefix}\033[35m搜索: {args.get('query', '')}\033[0m", nl=False)
|
|
200
|
+
sys.stdout.flush()
|
|
201
|
+
else:
|
|
202
|
+
click.echo(f" {prefix}[tool] {name}", nl=False)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _show_batch_header(round_num: int, total: int, max_rounds: int = MAX_TOOL_ROUNDS):
|
|
206
|
+
"""打印工具调用批次头,旋转动画 → ● 落定。
|
|
207
|
+
先 \\n 换到新行:保护上一行 AI 输出不被 \\r 动画覆盖掉。"""
|
|
208
|
+
logger.batch_header(round_num, total, max_rounds)
|
|
209
|
+
header = f"工具调用 (第 {round_num} 轮 / {max_rounds}) — 共 {total} 个"
|
|
210
|
+
# 避免 AI 流式输出最后一行被动画 \r 覆盖:先换行到新行再开始
|
|
211
|
+
sys.stdout.write('\n')
|
|
212
|
+
sys.stdout.flush()
|
|
213
|
+
frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
214
|
+
for i, c in enumerate(frames):
|
|
215
|
+
if i > 0:
|
|
216
|
+
time.sleep(0.06)
|
|
217
|
+
sys.stdout.write(f"\r\x1b[K \033[2m{c} {header}\033[0m")
|
|
218
|
+
sys.stdout.flush()
|
|
219
|
+
sys.stdout.write(f"\r\x1b[K \033[1m●\033[0m \033[2m{header}\033[0m\n")
|
|
220
|
+
sys.stdout.flush()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _start_thinking_spinner(message="推理中"):
|
|
224
|
+
"""启动动画旋转指示器,返回 Spinner 实例供调用方在首段内容到达时停止"""
|
|
225
|
+
spinner = Spinner(message)
|
|
226
|
+
spinner.start()
|
|
227
|
+
return spinner
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _show_tool_result(name: str, args: dict, result: dict):
|
|
231
|
+
"""在终端显示工具执行结果"""
|
|
232
|
+
success = result.get("success", False)
|
|
233
|
+
|
|
234
|
+
if name == "shell_exec":
|
|
235
|
+
if success:
|
|
236
|
+
stdout = result.get("stdout", "").strip()
|
|
237
|
+
stderr = result.get("stderr", "").strip()
|
|
238
|
+
has_output = bool(stdout or stderr)
|
|
239
|
+
if has_output:
|
|
240
|
+
click.echo()
|
|
241
|
+
if stdout:
|
|
242
|
+
click.echo(stdout)
|
|
243
|
+
if stderr:
|
|
244
|
+
click.echo(f"\033[33m{stderr}\033[0m")
|
|
245
|
+
click.echo(f" \033[32m完成\033[0m")
|
|
246
|
+
else:
|
|
247
|
+
click.echo()
|
|
248
|
+
click.echo(f" \033[32m完成 (无输出)\033[0m")
|
|
249
|
+
else:
|
|
250
|
+
click.echo()
|
|
251
|
+
err = result.get("error", result.get("stderr", ""))
|
|
252
|
+
click.echo(f" \033[31m失败: {err}\033[0m")
|
|
253
|
+
|
|
254
|
+
elif name in ("file_write", "file_read", "file_edit"):
|
|
255
|
+
click.echo()
|
|
256
|
+
if success:
|
|
257
|
+
click.echo(f" \033[32m完成\033[0m")
|
|
258
|
+
else:
|
|
259
|
+
click.echo(f" \033[31m失败: {result.get('error', '')}\033[0m")
|
|
260
|
+
|
|
261
|
+
elif name == "web_search":
|
|
262
|
+
if success:
|
|
263
|
+
count = len(result.get("results", []))
|
|
264
|
+
click.echo(f" \033[32m搜索完成 ({count}条)\033[0m")
|
|
265
|
+
else:
|
|
266
|
+
click.echo()
|
|
267
|
+
click.echo(f" \033[31m搜索失败: {result.get('error', '')}\033[0m")
|
|
268
|
+
|
|
269
|
+
else:
|
|
270
|
+
click.echo()
|
|
271
|
+
if success:
|
|
272
|
+
click.echo(f" \033[32m完成\033[0m")
|
|
273
|
+
else:
|
|
274
|
+
click.echo(f" \033[31m失败\033[0m")
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _execute_tool_batch(round_num: int, tool_calls: list) -> list:
|
|
278
|
+
"""执行一批工具调用,带批次头和编号前缀。返回每个 tool_call 对应的 result 列表"""
|
|
279
|
+
_show_batch_header(round_num, len(tool_calls))
|
|
280
|
+
results = []
|
|
281
|
+
total = len(tool_calls)
|
|
282
|
+
for i, tc in enumerate(tool_calls, 1):
|
|
283
|
+
result = execute_tool(tc, batch_index=i, batch_total=total)
|
|
284
|
+
results.append(result)
|
|
285
|
+
return results
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def process_tool_calls(session, client, tools_schemas, tool_calls, cfg, show_reasoning=False):
|
|
289
|
+
"""执行工具调用并获取后续回复,支持多轮循环。首批 = 第 1 轮,for 循环从第 2 轮开始"""
|
|
290
|
+
session.add_assistant(tool_calls=tool_calls)
|
|
291
|
+
|
|
292
|
+
# 第 1 轮:执行 run_with_pt 传过来的首批 tool_calls
|
|
293
|
+
results = _execute_tool_batch(1, tool_calls)
|
|
294
|
+
for tc, result in zip(tool_calls, results):
|
|
295
|
+
session.add_tool_result(_get_tc_field(tc, "id"), result)
|
|
296
|
+
|
|
297
|
+
for round_num in range(2, MAX_TOOL_ROUNDS + 1):
|
|
298
|
+
spinner = _start_thinking_spinner()
|
|
299
|
+
content_buffer = ""
|
|
300
|
+
reasoning_buffer = ""
|
|
301
|
+
new_tool_calls = []
|
|
302
|
+
api_start = time.time()
|
|
303
|
+
first_token_logged = False
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
for chunk in client.chat(
|
|
307
|
+
messages=session.get_messages_for_api(),
|
|
308
|
+
tools=tools_schemas, stream=True,
|
|
309
|
+
model=cfg.get("model"), thinking=cfg.get("thinking"),
|
|
310
|
+
reasoning_effort=cfg.get("reasoning_effort"),
|
|
311
|
+
max_tokens=cfg.get("max_tokens"), user_id=cfg.get("user_id")
|
|
312
|
+
):
|
|
313
|
+
if not first_token_logged and (chunk.get("reasoning_content") or chunk.get("content")):
|
|
314
|
+
logger.api_first_token(int((time.time() - api_start) * 1000))
|
|
315
|
+
first_token_logged = True
|
|
316
|
+
if chunk.get("reasoning_content"):
|
|
317
|
+
if show_reasoning and not reasoning_buffer:
|
|
318
|
+
spinner.stop()
|
|
319
|
+
# 整段 reasoning 走灰色样式(\033[2;90m = dim + 亮黑色 = 真正的灰色),
|
|
320
|
+
# 与正式回答的常规字体做明显区分。
|
|
321
|
+
click.echo(" \033[2;90m思考: ", nl=False)
|
|
322
|
+
reasoning_buffer += chunk["reasoning_content"]
|
|
323
|
+
if show_reasoning:
|
|
324
|
+
click.echo(chunk["reasoning_content"], nl=False)
|
|
325
|
+
sys.stdout.flush()
|
|
326
|
+
if chunk.get("content"):
|
|
327
|
+
if not content_buffer:
|
|
328
|
+
# 关闭暗色样式,回复正常字体
|
|
329
|
+
if show_reasoning and reasoning_buffer:
|
|
330
|
+
click.echo("\033[0m", nl=False)
|
|
331
|
+
spinner.stop()
|
|
332
|
+
click.echo()
|
|
333
|
+
logger.stream_content_start()
|
|
334
|
+
content_buffer += chunk["content"]
|
|
335
|
+
click.echo(chunk["content"], nl=False)
|
|
336
|
+
sys.stdout.flush()
|
|
337
|
+
if chunk.get("tool_calls"):
|
|
338
|
+
new_tool_calls.extend(chunk["tool_calls"])
|
|
339
|
+
logger.api_response(api_ms=int((time.time() - api_start) * 1000),
|
|
340
|
+
content_len=len(content_buffer),
|
|
341
|
+
reasoning_len=len(reasoning_buffer),
|
|
342
|
+
tool_calls=len(new_tool_calls))
|
|
343
|
+
except Exception as e:
|
|
344
|
+
spinner.stop()
|
|
345
|
+
logger.api_error(str(e))
|
|
346
|
+
click.echo()
|
|
347
|
+
click.echo(f" \033[31mAPI 调用失败: {e}\033[0m")
|
|
348
|
+
return ""
|
|
349
|
+
|
|
350
|
+
spinner.stop()
|
|
351
|
+
|
|
352
|
+
if new_tool_calls:
|
|
353
|
+
merged_new = merge_tool_calls(new_tool_calls)
|
|
354
|
+
if show_reasoning:
|
|
355
|
+
# reasoning 后直接 tool_calls,没 content 触发复位 —— 手动关 dim
|
|
356
|
+
if reasoning_buffer and not content_buffer:
|
|
357
|
+
click.echo("\033[0m", nl=False)
|
|
358
|
+
click.echo()
|
|
359
|
+
session.add_assistant(
|
|
360
|
+
tool_calls=merged_new,
|
|
361
|
+
reasoning_content=reasoning_buffer if reasoning_buffer else None
|
|
362
|
+
)
|
|
363
|
+
results = _execute_tool_batch(round_num, merged_new)
|
|
364
|
+
for tc, result in zip(merged_new, results):
|
|
365
|
+
session.add_tool_result(_get_tc_field(tc, "id"), result)
|
|
366
|
+
continue
|
|
367
|
+
|
|
368
|
+
session.add_assistant(
|
|
369
|
+
content=content_buffer if content_buffer else None,
|
|
370
|
+
reasoning_content=reasoning_buffer if reasoning_buffer else None
|
|
371
|
+
)
|
|
372
|
+
return content_buffer
|
|
373
|
+
|
|
374
|
+
# 达到 MAX_TOOL_ROUNDS 安全网仍未收敛(防 AI 死循环)
|
|
375
|
+
click.echo()
|
|
376
|
+
click.echo(f" \033[33m(达到 {MAX_TOOL_ROUNDS} 轮安全网上限,疑似死循环,已停止)\033[0m")
|
|
377
|
+
return ""
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def handle_slash_command(input_str: str, session, client, cfg, sr: list):
|
|
381
|
+
"""
|
|
382
|
+
处理 / 命令。返回 True=已处理, "exit"=退出, False=非命令
|
|
383
|
+
"""
|
|
384
|
+
stripped = input_str.strip()
|
|
385
|
+
parts = stripped.split()
|
|
386
|
+
if not parts:
|
|
387
|
+
return False
|
|
388
|
+
cmd = parts[0].lower()
|
|
389
|
+
|
|
390
|
+
if cmd == "/clear":
|
|
391
|
+
session.clear()
|
|
392
|
+
click.echo("[OK] 上下文已清空")
|
|
393
|
+
return True
|
|
394
|
+
|
|
395
|
+
if cmd in ("/exit", "/quit"):
|
|
396
|
+
return "exit"
|
|
397
|
+
|
|
398
|
+
if cmd == "/help":
|
|
399
|
+
click.echo("")
|
|
400
|
+
click.echo("\033[1;36m╭─────────────────────────────────────────────╮\033[0m")
|
|
401
|
+
click.echo("\033[1;36m│ Abyss CLI · 使用帮助 │\033[0m")
|
|
402
|
+
click.echo("\033[1;36m╰─────────────────────────────────────────────╯\033[0m")
|
|
403
|
+
click.echo("")
|
|
404
|
+
click.echo("\033[1m内置命令:\033[0m")
|
|
405
|
+
click.echo(" \033[36m/help\033[0m 显示本帮助")
|
|
406
|
+
click.echo(" \033[36m/clear\033[0m 清空当前会话上下文")
|
|
407
|
+
click.echo(" \033[36m/exit\033[0m, \033[36m/quit\033[0m 退出 CLI")
|
|
408
|
+
click.echo(" \033[36m/config\033[0m 查看当前配置")
|
|
409
|
+
click.echo(" \033[36m/model\033[0m 交互式切换模型(上下键选 + 回车)")
|
|
410
|
+
click.echo(" \033[36m/thinking\033[0m 查看或开关思考模式(on/off)")
|
|
411
|
+
click.echo(" \033[36m/show-reasoning\033[0m 开关思考过程在屏幕上的显示")
|
|
412
|
+
click.echo("")
|
|
413
|
+
click.echo("\033[1m配置管理:\033[0m")
|
|
414
|
+
click.echo(" \033[33m/config set api-key <key>\033[0m 设置 API Key")
|
|
415
|
+
click.echo(" \033[33m/config set model <name>\033[0m 设置模型(deepseek-v4-pro / deepseek-v4-flash)")
|
|
416
|
+
click.echo(" \033[33m/config set thinking <on|off>\033[0m 开关思考模式")
|
|
417
|
+
click.echo(" \033[33m/config set max-tokens <n>\033[0m 设置单次响应最大 token 数")
|
|
418
|
+
click.echo(" \033[33m/config unset <key>\033[0m 删除某项配置(恢复默认值)")
|
|
419
|
+
click.echo("")
|
|
420
|
+
click.echo("\033[1m小贴士:\033[0m")
|
|
421
|
+
click.echo(" • 输入 \033[33m/\033[0m 会弹出补全菜单(上下键选 + Tab 补全)")
|
|
422
|
+
click.echo(" • 输入 \033[33m@\033[0m 引用文件或目录到上下文")
|
|
423
|
+
click.echo(" • 按 \033[33m↑\033[0m / \033[33m↓\033[0m 翻历史消息")
|
|
424
|
+
click.echo(" • 按 \033[33mEsc\033[0m 取消当前补全菜单")
|
|
425
|
+
click.echo(" • 配置文件位置:\033[33m~/.abyss/config.json\033[0m")
|
|
426
|
+
click.echo("")
|
|
427
|
+
return True
|
|
428
|
+
|
|
429
|
+
# /config
|
|
430
|
+
if cmd == "/config":
|
|
431
|
+
if len(parts) == 1:
|
|
432
|
+
click.echo("当前配置:")
|
|
433
|
+
for k, v in cfg.to_dict().items():
|
|
434
|
+
if k == "api_key" and v:
|
|
435
|
+
v = v[:8] + "****" + v[-4:]
|
|
436
|
+
click.echo(f" {k}: {v}")
|
|
437
|
+
return True
|
|
438
|
+
if len(parts) >= 3 and parts[1] == "set":
|
|
439
|
+
key = parts[2]
|
|
440
|
+
has_value = len(parts) >= 4
|
|
441
|
+
val_str = parts[3] if has_value else None
|
|
442
|
+
|
|
443
|
+
if key == "api-key":
|
|
444
|
+
if has_value:
|
|
445
|
+
val = " ".join(parts[3:])
|
|
446
|
+
cfg.set("api_key", val)
|
|
447
|
+
client.api_key = val
|
|
448
|
+
client.client.api_key = val
|
|
449
|
+
click.echo(f"[OK] API Key 已保存")
|
|
450
|
+
else:
|
|
451
|
+
click.echo(f"用法: /config set api-key <你的Key>")
|
|
452
|
+
return True
|
|
453
|
+
if key == "model":
|
|
454
|
+
if has_value:
|
|
455
|
+
cfg.set("model", val_str)
|
|
456
|
+
click.echo(f"[OK] 模型 → {val_str}")
|
|
457
|
+
else:
|
|
458
|
+
click.echo(f"当前模型: {cfg.get('model')}")
|
|
459
|
+
click.echo(f"用法: /config set model <模型名>")
|
|
460
|
+
return True
|
|
461
|
+
if key == "thinking":
|
|
462
|
+
if has_value:
|
|
463
|
+
val = val_str.lower() in ("on", "true", "1", "yes")
|
|
464
|
+
cfg.set("thinking", val)
|
|
465
|
+
else:
|
|
466
|
+
val = not cfg.get("thinking")
|
|
467
|
+
cfg.set("thinking", val)
|
|
468
|
+
click.echo(f"[OK] 思考模式: {'开' if val else '关'}")
|
|
469
|
+
return True
|
|
470
|
+
if key == "max-tokens":
|
|
471
|
+
if has_value:
|
|
472
|
+
try:
|
|
473
|
+
cfg.set("max_tokens", int(val_str))
|
|
474
|
+
click.echo(f"[OK] max_tokens → {val_str}")
|
|
475
|
+
except ValueError:
|
|
476
|
+
click.echo(f"请输入有效数字,当前: {cfg.get('max_tokens')}")
|
|
477
|
+
else:
|
|
478
|
+
click.echo(f"当前 max_tokens: {cfg.get('max_tokens')}")
|
|
479
|
+
click.echo(f"用法: /config set max-tokens <数字>")
|
|
480
|
+
return True
|
|
481
|
+
|
|
482
|
+
click.echo(f"未知配置项: {key}")
|
|
483
|
+
click.echo(f"可用: api-key, model, thinking, max-tokens")
|
|
484
|
+
return True
|
|
485
|
+
# /config unset <key>
|
|
486
|
+
if len(parts) >= 3 and parts[1] == "unset":
|
|
487
|
+
key = parts[2]
|
|
488
|
+
if cfg.delete(key):
|
|
489
|
+
click.echo(f"[OK] 已删除配置项: {key}")
|
|
490
|
+
else:
|
|
491
|
+
click.echo(f"[INFO] 配置项 {key} 本就不存在(已忽略)")
|
|
492
|
+
return True
|
|
493
|
+
return True
|
|
494
|
+
|
|
495
|
+
# /model
|
|
496
|
+
if cmd == "/model":
|
|
497
|
+
if len(parts) == 1:
|
|
498
|
+
# 无参数:弹出交互式选择器(上下键选 + 回车确认)
|
|
499
|
+
from .ansi_menu import ansi_select
|
|
500
|
+
options = ["deepseek-v4-pro", "deepseek-v4-flash"]
|
|
501
|
+
current = cfg.get("model")
|
|
502
|
+
# 高亮当前正在用的模型(如果存在)
|
|
503
|
+
try:
|
|
504
|
+
default_idx = options.index(current)
|
|
505
|
+
except ValueError:
|
|
506
|
+
default_idx = 0
|
|
507
|
+
selected = ansi_select("选择模型", options, default_idx=default_idx)
|
|
508
|
+
if selected:
|
|
509
|
+
cfg.set("model", selected)
|
|
510
|
+
click.echo(f"[OK] 已切换: {selected}")
|
|
511
|
+
return True
|
|
512
|
+
m = parts[1]
|
|
513
|
+
if m in ("deepseek-v4-pro", "deepseek-v4-flash"):
|
|
514
|
+
cfg.set("model", m)
|
|
515
|
+
click.echo(f"[OK] 已切换: {m}")
|
|
516
|
+
else:
|
|
517
|
+
click.echo(f"未知模型: {m},可用: deepseek-v4-pro, deepseek-v4-flash")
|
|
518
|
+
return True
|
|
519
|
+
|
|
520
|
+
# /thinking
|
|
521
|
+
if cmd == "/thinking":
|
|
522
|
+
if len(parts) == 1:
|
|
523
|
+
click.echo(f"思考模式: {'开' if cfg.get('thinking') else '关'}")
|
|
524
|
+
return True
|
|
525
|
+
val = parts[1].lower() in ("on", "true", "1", "yes")
|
|
526
|
+
cfg.set("thinking", val)
|
|
527
|
+
click.echo(f"[OK] 思考模式: {'开' if val else '关'}")
|
|
528
|
+
return True
|
|
529
|
+
|
|
530
|
+
# /show-reasoning
|
|
531
|
+
if cmd == "/show-reasoning":
|
|
532
|
+
sr[0] = not sr[0]
|
|
533
|
+
# 同步写盘,重启后不丢失(用户反馈:UI 切到 true 磁盘仍是 false)
|
|
534
|
+
cfg.set("show_reasoning", sr[0])
|
|
535
|
+
click.echo(f"[OK] 思考过程显示: {'开' if sr[0] else '关'} (已保存)")
|
|
536
|
+
return True
|
|
537
|
+
|
|
538
|
+
return False
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def first_time_setup(cfg):
|
|
542
|
+
"""首次启动引导设置 API Key"""
|
|
543
|
+
click.echo("""
|
|
544
|
+
╔══════════════════════════════════╗
|
|
545
|
+
║ abyss - 终端 AI 助手 ║
|
|
546
|
+
╚══════════════════════════════════╝
|
|
547
|
+
|
|
548
|
+
首次使用需要配置 DeepSeek API Key。
|
|
549
|
+
获取 Key: https://platform.deepseek.com/api_keys
|
|
550
|
+
""")
|
|
551
|
+
key = click.prompt("API Key", type=str, default="").strip()
|
|
552
|
+
if key:
|
|
553
|
+
cfg.set("api_key", key)
|
|
554
|
+
click.echo("[OK] 配置完成\n")
|
|
555
|
+
return True
|
|
556
|
+
else:
|
|
557
|
+
click.echo("跳过,稍后可用 /config set api-key <key> 配置\n")
|
|
558
|
+
return False
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def resolve_at_references(user_input: str) -> str:
|
|
562
|
+
"""解析 @ 文件引用,将当前目录下的文件内容添加到消息上下文中"""
|
|
563
|
+
pattern = re.compile(r'@([^\s@,,。;;::!!??\'\"\(\)\[\]{}<>]+)')
|
|
564
|
+
matches = list(pattern.finditer(user_input))
|
|
565
|
+
if not matches:
|
|
566
|
+
return user_input
|
|
567
|
+
|
|
568
|
+
cwd = os.getcwd()
|
|
569
|
+
file_contents = []
|
|
570
|
+
|
|
571
|
+
for m in matches:
|
|
572
|
+
file_path = m.group(1)
|
|
573
|
+
full_path = os.path.join(cwd, file_path)
|
|
574
|
+
full_path = os.path.normpath(full_path)
|
|
575
|
+
|
|
576
|
+
if not os.path.isfile(full_path):
|
|
577
|
+
continue
|
|
578
|
+
|
|
579
|
+
try:
|
|
580
|
+
with open(full_path, "r", encoding="utf-8", errors="replace") as f:
|
|
581
|
+
content = f.read()
|
|
582
|
+
|
|
583
|
+
if len(content) > 8000:
|
|
584
|
+
total = len(content)
|
|
585
|
+
content = content[:8000] + f"\n...[文件过长, 已截断, 共 {total} 字符]"
|
|
586
|
+
|
|
587
|
+
file_contents.append((file_path, content))
|
|
588
|
+
except Exception:
|
|
589
|
+
continue
|
|
590
|
+
|
|
591
|
+
if not file_contents:
|
|
592
|
+
return user_input
|
|
593
|
+
|
|
594
|
+
for fp, _ in file_contents:
|
|
595
|
+
click.echo(f" \033[2m📎 @{fp}\033[0m")
|
|
596
|
+
|
|
597
|
+
parts = []
|
|
598
|
+
for fp, content in file_contents:
|
|
599
|
+
ext = os.path.splitext(fp)[1].lstrip(".") or "text"
|
|
600
|
+
parts.append(f"[文件: {fp}]\n```{ext}\n{content}\n```")
|
|
601
|
+
|
|
602
|
+
return "\n\n".join(parts) + "\n\n---\n" + user_input
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def run_with_pt(cfg, show_reasoning=False):
|
|
606
|
+
"""运行对话循环(使用自定义 ANSI 补全菜单)"""
|
|
607
|
+
logger.session_start(model=cfg.get("model"), cwd=os.getcwd())
|
|
608
|
+
|
|
609
|
+
if not cfg.is_ready():
|
|
610
|
+
first_time_setup(cfg)
|
|
611
|
+
|
|
612
|
+
system_prompt = load_system_prompt()
|
|
613
|
+
session = Session(system_prompt)
|
|
614
|
+
client = DeepSeekClient(
|
|
615
|
+
api_key=cfg.get("api_key"),
|
|
616
|
+
base_url=cfg.get("base_url")
|
|
617
|
+
)
|
|
618
|
+
tools_schemas = [t.to_openai_schema() for t in TOOLS.values()]
|
|
619
|
+
sr = [show_reasoning]
|
|
620
|
+
|
|
621
|
+
click.echo(f"模型: {cfg.get('model')} | / 命令 @ 文件\n")
|
|
622
|
+
|
|
623
|
+
while True:
|
|
624
|
+
try:
|
|
625
|
+
sys.stdout.flush()
|
|
626
|
+
user_input = ansi_prompt("> ").strip()
|
|
627
|
+
if not user_input:
|
|
628
|
+
continue
|
|
629
|
+
|
|
630
|
+
if user_input.startswith("/"):
|
|
631
|
+
logger.slash_command(user_input)
|
|
632
|
+
# 回显用户输入的命令,给个视觉确认(避免以为命令没收到)
|
|
633
|
+
click.echo(f" \033[2m> {user_input}\033[0m")
|
|
634
|
+
result = handle_slash_command(user_input, session, client, cfg, sr)
|
|
635
|
+
if result == "exit":
|
|
636
|
+
logger.session_end("exit_command")
|
|
637
|
+
break
|
|
638
|
+
if result:
|
|
639
|
+
continue
|
|
640
|
+
|
|
641
|
+
logger.user_input(user_input)
|
|
642
|
+
|
|
643
|
+
if not cfg.is_ready():
|
|
644
|
+
click.echo("请先配置 API Key: /config set api-key <your-key>")
|
|
645
|
+
continue
|
|
646
|
+
|
|
647
|
+
user_input = resolve_at_references(user_input)
|
|
648
|
+
session.add_user(user_input)
|
|
649
|
+
|
|
650
|
+
reasoning_buffer = ""
|
|
651
|
+
content_buffer = ""
|
|
652
|
+
current_tool_calls = []
|
|
653
|
+
|
|
654
|
+
spinner = Spinner()
|
|
655
|
+
spinner.start()
|
|
656
|
+
|
|
657
|
+
api_start = time.time()
|
|
658
|
+
first_token_logged = False
|
|
659
|
+
try:
|
|
660
|
+
for chunk in client.chat(
|
|
661
|
+
messages=session.get_messages_for_api(),
|
|
662
|
+
tools=tools_schemas, stream=True,
|
|
663
|
+
model=cfg.get("model"), thinking=cfg.get("thinking"),
|
|
664
|
+
reasoning_effort=cfg.get("reasoning_effort"),
|
|
665
|
+
max_tokens=cfg.get("max_tokens"), user_id=cfg.get("user_id")
|
|
666
|
+
):
|
|
667
|
+
if not first_token_logged and (chunk.get("reasoning_content") or chunk.get("content")):
|
|
668
|
+
logger.api_first_token(int((time.time() - api_start) * 1000))
|
|
669
|
+
first_token_logged = True
|
|
670
|
+
if chunk.get("reasoning_content"):
|
|
671
|
+
if sr[0] and not reasoning_buffer:
|
|
672
|
+
spinner.stop()
|
|
673
|
+
# 整段 reasoning 走灰色(dim + 亮黑 = 真正的灰色)
|
|
674
|
+
click.echo(" \033[2;90m思考: ", nl=False)
|
|
675
|
+
logger.stream_reasoning_start()
|
|
676
|
+
reasoning_buffer += chunk["reasoning_content"]
|
|
677
|
+
if sr[0]:
|
|
678
|
+
click.echo(chunk["reasoning_content"], nl=False)
|
|
679
|
+
sys.stdout.flush()
|
|
680
|
+
if chunk.get("content"):
|
|
681
|
+
if not content_buffer:
|
|
682
|
+
# 关闭暗色样式,回复正常字体
|
|
683
|
+
if sr[0] and reasoning_buffer:
|
|
684
|
+
click.echo("\033[0m", nl=False)
|
|
685
|
+
spinner.stop()
|
|
686
|
+
click.echo()
|
|
687
|
+
logger.stream_content_start()
|
|
688
|
+
content_buffer += chunk["content"]
|
|
689
|
+
click.echo(chunk["content"], nl=False)
|
|
690
|
+
sys.stdout.flush()
|
|
691
|
+
if chunk.get("tool_calls"):
|
|
692
|
+
current_tool_calls.extend(chunk["tool_calls"])
|
|
693
|
+
logger.api_response(api_ms=int((time.time() - api_start) * 1000),
|
|
694
|
+
content_len=len(content_buffer),
|
|
695
|
+
reasoning_len=len(reasoning_buffer),
|
|
696
|
+
tool_calls=len(current_tool_calls))
|
|
697
|
+
except Exception as e:
|
|
698
|
+
spinner.stop()
|
|
699
|
+
logger.api_error(str(e))
|
|
700
|
+
click.echo()
|
|
701
|
+
click.echo(f" \033[31mAPI 调用失败: {e}\033[0m")
|
|
702
|
+
continue
|
|
703
|
+
|
|
704
|
+
spinner.stop()
|
|
705
|
+
|
|
706
|
+
if not content_buffer and not current_tool_calls:
|
|
707
|
+
# reasoning 后无 content 也无 tool_calls —— 手动关 dim
|
|
708
|
+
if sr[0] and reasoning_buffer:
|
|
709
|
+
click.echo("\033[0m", nl=False)
|
|
710
|
+
click.echo()
|
|
711
|
+
click.echo(f" \033[33m(模型未返回内容,请重试)\033[0m")
|
|
712
|
+
continue
|
|
713
|
+
|
|
714
|
+
if current_tool_calls:
|
|
715
|
+
# reasoning 后直接 tool_calls,没 content 触发复位 —— 手动关 dim
|
|
716
|
+
if sr[0] and reasoning_buffer and not content_buffer:
|
|
717
|
+
click.echo("\033[0m", nl=False)
|
|
718
|
+
merged = merge_tool_calls(current_tool_calls)
|
|
719
|
+
process_tool_calls(session, client, tools_schemas,
|
|
720
|
+
merged, cfg, sr[0])
|
|
721
|
+
else:
|
|
722
|
+
session.add_assistant(
|
|
723
|
+
content=content_buffer,
|
|
724
|
+
reasoning_content=reasoning_buffer if reasoning_buffer else None
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
# AI 回复结束后确保光标在行首新行,
|
|
728
|
+
# 否则 ansi_prompt 的菜单会叠在回复文字下面。
|
|
729
|
+
click.echo()
|
|
730
|
+
|
|
731
|
+
except KeyboardInterrupt:
|
|
732
|
+
logger.keyboard_interrupt()
|
|
733
|
+
logger.session_end("keyboard_interrupt")
|
|
734
|
+
click.echo("\n再见!")
|
|
735
|
+
break
|
|
736
|
+
except EOFError:
|
|
737
|
+
logger.eof()
|
|
738
|
+
logger.session_end("eof")
|
|
739
|
+
click.echo("\n再见!")
|
|
740
|
+
break
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
@click.command()
|
|
744
|
+
@click.option("--model", "-m", help="指定模型 (deepseek-v4-pro / deepseek-v4-flash)")
|
|
745
|
+
@click.option("--hide-reasoning", is_flag=True, help="隐藏思考过程(默认显示)")
|
|
746
|
+
@click.option("--version", "-v", is_flag=True, help="显示版本")
|
|
747
|
+
def cli(model, hide_reasoning, version):
|
|
748
|
+
"""abyss - 终端 AI 开发助手"""
|
|
749
|
+
if version:
|
|
750
|
+
from . import __version__
|
|
751
|
+
click.echo(f"abyss v{__version__}")
|
|
752
|
+
return
|
|
753
|
+
|
|
754
|
+
cfg = Config()
|
|
755
|
+
if model:
|
|
756
|
+
cfg.set("model", model)
|
|
757
|
+
|
|
758
|
+
# 默认显示思考过程(用户已确认要"看效果"),--hide-reasoning 关闭
|
|
759
|
+
run_with_pt(cfg, show_reasoning=not hide_reasoning)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
if __name__ == "__main__":
|
|
763
|
+
cli()
|