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/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()