nexforge-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.
- nexforge/__init__.py +7 -0
- nexforge/__main__.py +6 -0
- nexforge/app.py +38 -0
- nexforge/assets/icons/agent.svg +13 -0
- nexforge/assets/icons/cost.svg +5 -0
- nexforge/assets/icons/edit.svg +4 -0
- nexforge/assets/icons/err.svg +5 -0
- nexforge/assets/icons/file.svg +5 -0
- nexforge/assets/icons/model.svg +7 -0
- nexforge/assets/icons/ok.svg +4 -0
- nexforge/assets/icons/read.svg +8 -0
- nexforge/assets/icons/running.svg +5 -0
- nexforge/assets/icons/search.svg +5 -0
- nexforge/assets/icons/shell.svg +5 -0
- nexforge/assets/icons/thinking.svg +5 -0
- nexforge/assets/icons/web.svg +6 -0
- nexforge/assets/icons/write.svg +4 -0
- nexforge/cli.py +159 -0
- nexforge/config.py +192 -0
- nexforge/core/__init__.py +1 -0
- nexforge/core/agent.py +244 -0
- nexforge/core/jobs.py +97 -0
- nexforge/core/permissions.py +73 -0
- nexforge/core/router.py +62 -0
- nexforge/core/session.py +55 -0
- nexforge/core/types.py +103 -0
- nexforge/icons.py +173 -0
- nexforge/mcp/__init__.py +168 -0
- nexforge/provider/__init__.py +1 -0
- nexforge/provider/balance.py +37 -0
- nexforge/provider/deepseek.py +204 -0
- nexforge/skills/loader.py +102 -0
- nexforge/store/__init__.py +1 -0
- nexforge/store/plan_store.py +112 -0
- nexforge/store/session_store.py +206 -0
- nexforge/theme.tcss +128 -0
- nexforge/tools/__init__.py +6 -0
- nexforge/tools/_paths.py +22 -0
- nexforge/tools/background.py +111 -0
- nexforge/tools/base.py +56 -0
- nexforge/tools/edit_file.py +73 -0
- nexforge/tools/fs_ops.py +125 -0
- nexforge/tools/git.py +89 -0
- nexforge/tools/list_dir.py +49 -0
- nexforge/tools/multi_edit.py +84 -0
- nexforge/tools/read_file.py +51 -0
- nexforge/tools/registry.py +94 -0
- nexforge/tools/search_content.py +90 -0
- nexforge/tools/search_files.py +48 -0
- nexforge/tools/shell.py +60 -0
- nexforge/tools/web.py +40 -0
- nexforge/tools/write_file.py +40 -0
- nexforge/ui/__init__.py +1 -0
- nexforge/ui/screens/__init__.py +1 -0
- nexforge/ui/screens/chat.py +825 -0
- nexforge/ui/widgets/__init__.py +1 -0
- nexforge/ui/widgets/apikey_modal.py +65 -0
- nexforge/ui/widgets/banner.py +18 -0
- nexforge/ui/widgets/confirm_modal.py +58 -0
- nexforge/ui/widgets/diff_modal.py +67 -0
- nexforge/ui/widgets/messages.py +132 -0
- nexforge/ui/widgets/mode_warning.py +57 -0
- nexforge/ui/widgets/multiline_modal.py +59 -0
- nexforge/ui/widgets/multiline_prompt.py +25 -0
- nexforge/ui/widgets/plan_sidebar.py +89 -0
- nexforge/ui/widgets/sidebar.py +99 -0
- nexforge/ui/widgets/statusbar.py +70 -0
- nexforge/web/__init__.py +0 -0
- nexforge/web/server.py +177 -0
- nexforge/web/static/index.html +123 -0
- nexforge_cli-0.1.0.dist-info/METADATA +168 -0
- nexforge_cli-0.1.0.dist-info/RECORD +74 -0
- nexforge_cli-0.1.0.dist-info/WHEEL +4 -0
- nexforge_cli-0.1.0.dist-info/entry_points.txt +3 -0
nexforge/__init__.py
ADDED
nexforge/__main__.py
ADDED
nexforge/app.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Textual TUI 入口。
|
|
2
|
+
|
|
3
|
+
NexForgeApp 提到模块级,便于无头测试(App.run_test)。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from textual.app import App
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from nexforge.config import NexForgeConfig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class NexForgeApp(App):
|
|
17
|
+
CSS_PATH = "theme.tcss"
|
|
18
|
+
TITLE = "NexForgeCLI"
|
|
19
|
+
USE_ALT_SCREEN = True
|
|
20
|
+
|
|
21
|
+
def __init__(self, config: "NexForgeConfig", *, resume: bool = False) -> None:
|
|
22
|
+
super().__init__()
|
|
23
|
+
self._config = config
|
|
24
|
+
self._resume = resume
|
|
25
|
+
|
|
26
|
+
def get_default_screen(self):
|
|
27
|
+
from nexforge.ui.screens.chat import ChatScreen
|
|
28
|
+
return ChatScreen(config=self._config, resume_session_id=None)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_tui(config: "NexForgeConfig", *, resume: bool = False) -> int:
|
|
32
|
+
app = NexForgeApp(config, resume=resume)
|
|
33
|
+
try:
|
|
34
|
+
# 鼠标开启(Button 需要点击);子进程 stdin=DEVNULL 防鼠标事件回显乱码
|
|
35
|
+
app.run()
|
|
36
|
+
except KeyboardInterrupt:
|
|
37
|
+
return 0
|
|
38
|
+
return 0
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="nf" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
|
|
4
|
+
<stop stop-color="#6D5EF5"/>
|
|
5
|
+
<stop offset="1" stop-color="#22D3EE"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<!-- NexForge 铁砧 / 分叉标记 -->
|
|
9
|
+
<path d="M16 3 4 9v8c0 6.5 5.1 10.7 12 12 6.9-1.3 12-5.5 12-12V9L16 3z"
|
|
10
|
+
stroke="url(#nf)" stroke-width="2" stroke-linejoin="round"/>
|
|
11
|
+
<path d="M11 15h10M16 11v10M11 15l-2 3M21 15l2 3" stroke="url(#nf)"
|
|
12
|
+
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
|
13
|
+
</svg>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#6D5EF5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<!-- file / generic document -->
|
|
3
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
4
|
+
<polyline points="14,2 14,8 20,8"/>
|
|
5
|
+
</svg>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#6D5EF5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<!-- model / diamond with AI dots -->
|
|
3
|
+
<rect x="4" y="4" width="16" height="16" rx="2" transform="rotate(45 12 12)"/>
|
|
4
|
+
<circle cx="10" cy="10" r="1.5" fill="#22D3EE" stroke="none"/>
|
|
5
|
+
<circle cx="14" cy="10" r="1.5" fill="#22D3EE" stroke="none"/>
|
|
6
|
+
<circle cx="12" cy="14" r="1.5" fill="#22D3EE" stroke="none"/>
|
|
7
|
+
</svg>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#6D5EF5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<!-- read_file / document -->
|
|
3
|
+
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
4
|
+
<polyline points="14,2 14,8 20,8"/>
|
|
5
|
+
<line x1="16" y1="13" x2="8" y2="13"/>
|
|
6
|
+
<line x1="16" y1="17" x2="8" y2="17"/>
|
|
7
|
+
<polyline points="10,9 9,9 8,9"/>
|
|
8
|
+
</svg>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#6D5EF5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<!-- running / spinner dot + arc -->
|
|
3
|
+
<circle cx="12" cy="12" r="10"/>
|
|
4
|
+
<path stroke="#22D3EE" d="M12 2a10 10 0 0 1 10 10"/>
|
|
5
|
+
</svg>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#6D5EF5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<!-- search / magnifying glass -->
|
|
3
|
+
<circle cx="11" cy="11" r="8"/>
|
|
4
|
+
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
|
5
|
+
</svg>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#6D5EF5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<!-- shell / terminal / >_ -->
|
|
3
|
+
<polyline points="4,17 10,11 4,5"/>
|
|
4
|
+
<line x1="12" y1="19" x2="20" y2="19"/>
|
|
5
|
+
</svg>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#6D5EF5" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<!-- thinking / brain -->
|
|
3
|
+
<path d="M12 2a5 5 0 0 0-5 5c0 1.2.4 2.3 1 3.2L5 21h14l-3-10.8a5 5 0 0 0-4-8.2z"/>
|
|
4
|
+
<path d="M9 13h6M10 17h4" stroke="#22D3EE"/>
|
|
5
|
+
</svg>
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#22D3EE" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<!-- web / globe -->
|
|
3
|
+
<circle cx="12" cy="12" r="10"/>
|
|
4
|
+
<line x1="2" y1="12" x2="22" y2="12"/>
|
|
5
|
+
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
|
6
|
+
</svg>
|
nexforge/cli.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""NexForgeCLI 命令行入口 —— 解析参数并分发到 TUI 或 one-shot 模式。
|
|
2
|
+
|
|
3
|
+
用法::
|
|
4
|
+
|
|
5
|
+
nexforge # 进入全屏 TUI
|
|
6
|
+
nexforge -p "这段代码做什么?" # one-shot,打印答案后退出
|
|
7
|
+
echo "review main.py" | nexforge # 管道输入 → one-shot
|
|
8
|
+
nexforge --model pro # 指定档位
|
|
9
|
+
nexforge --no-thinking # 关闭思考模式
|
|
10
|
+
|
|
11
|
+
重量级模块(Textual / provider)在各分支内延迟导入,保证
|
|
12
|
+
`--version` / `--help` 在缺失可选依赖时也能工作。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from nexforge import __version__
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
24
|
+
parser = argparse.ArgumentParser(
|
|
25
|
+
prog="nexforge",
|
|
26
|
+
description="NexForgeCLI — DeepSeek V4 终端智能体(TUI + CLI 双模式)",
|
|
27
|
+
epilog="不带参数直接运行进入全屏 TUI;-p 或管道输入进入 one-shot 模式。",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"prompt",
|
|
31
|
+
nargs="*",
|
|
32
|
+
help="one-shot 提问;省略则进入 TUI。",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"-p",
|
|
36
|
+
"--print",
|
|
37
|
+
dest="print_prompt",
|
|
38
|
+
metavar="TEXT",
|
|
39
|
+
help="one-shot 模式:回答后打印并退出(可与位置参数二选一)。",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"-m",
|
|
43
|
+
"--model",
|
|
44
|
+
choices=["flash", "pro", "auto"],
|
|
45
|
+
help="模型档位(覆盖配置默认值)。",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--think",
|
|
49
|
+
dest="thinking",
|
|
50
|
+
action="store_true",
|
|
51
|
+
default=None,
|
|
52
|
+
help="强制开启思考模式。",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"--no-think",
|
|
56
|
+
dest="thinking",
|
|
57
|
+
action="store_false",
|
|
58
|
+
help="关闭思考模式(更快、更省)。",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument(
|
|
61
|
+
"--resume",
|
|
62
|
+
action="store_true",
|
|
63
|
+
help="恢复最近一次会话(M4)。",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--auto",
|
|
67
|
+
action="store_true",
|
|
68
|
+
help="启动时即设为全自动模式(警告后)。",
|
|
69
|
+
)
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
"--config",
|
|
72
|
+
action="store_true",
|
|
73
|
+
help="打印当前生效配置后退出。",
|
|
74
|
+
)
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--login",
|
|
77
|
+
action="store_true",
|
|
78
|
+
help="安全地保存 API Key 到凭据文件(~/.nexforge/auth.toml)。",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"-V",
|
|
82
|
+
"--version",
|
|
83
|
+
action="version",
|
|
84
|
+
version=f"NexForgeCLI {__version__}",
|
|
85
|
+
)
|
|
86
|
+
return parser
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _collect_oneshot_prompt(args: argparse.Namespace) -> str | None:
|
|
90
|
+
"""按优先级取 one-shot 提示词:-p > 位置参数 > 管道 stdin。"""
|
|
91
|
+
if args.print_prompt:
|
|
92
|
+
return args.print_prompt
|
|
93
|
+
if args.prompt:
|
|
94
|
+
return " ".join(args.prompt)
|
|
95
|
+
# 非交互式 stdin(被管道 / 重定向)→ 读取全部作为提示。
|
|
96
|
+
if not sys.stdin.isatty():
|
|
97
|
+
piped = sys.stdin.read().strip()
|
|
98
|
+
if piped:
|
|
99
|
+
return piped
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def main(argv: list[str] | None = None) -> int:
|
|
104
|
+
args = build_parser().parse_args(argv)
|
|
105
|
+
|
|
106
|
+
# login:交互式保存 key 后退出(不依赖已有配置)。
|
|
107
|
+
if args.login or (args.prompt and len(args.prompt) == 1 and args.prompt[0] == "login"):
|
|
108
|
+
return _do_login()
|
|
109
|
+
|
|
110
|
+
# 延迟导入:让 --version / --help 不依赖可选运行时。
|
|
111
|
+
from nexforge.config import load_config
|
|
112
|
+
|
|
113
|
+
config = load_config()
|
|
114
|
+
if args.model:
|
|
115
|
+
config.models.default_tier = args.model
|
|
116
|
+
if args.thinking is not None:
|
|
117
|
+
config.models.thinking = args.thinking
|
|
118
|
+
if args.auto:
|
|
119
|
+
config.ui.mode = "auto"
|
|
120
|
+
|
|
121
|
+
if args.config:
|
|
122
|
+
print(config.pretty())
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
prompt = _collect_oneshot_prompt(args)
|
|
126
|
+
if prompt is not None:
|
|
127
|
+
# one-shot:无需 TUI,跑一次智能体循环打印结果。
|
|
128
|
+
from nexforge.core.agent import run_oneshot
|
|
129
|
+
|
|
130
|
+
return run_oneshot(config, prompt)
|
|
131
|
+
|
|
132
|
+
# 默认:全屏 TUI。
|
|
133
|
+
from nexforge.app import run_tui
|
|
134
|
+
|
|
135
|
+
return run_tui(config, resume=args.resume)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _do_login() -> int:
|
|
139
|
+
"""交互式输入 API Key 并保存到凭据文件,不会回显输入内容。"""
|
|
140
|
+
from getpass import getpass
|
|
141
|
+
|
|
142
|
+
from nexforge.config import AUTH_PATH, save_api_key
|
|
143
|
+
|
|
144
|
+
print("NexForgeCLI — API Key 配置")
|
|
145
|
+
print(f"凭据将写入: {AUTH_PATH}")
|
|
146
|
+
print()
|
|
147
|
+
key = getpass("粘贴或输入 API Key (以 sk- 开头): ").strip()
|
|
148
|
+
if not key:
|
|
149
|
+
print("✖ 未输入 Key,已取消。")
|
|
150
|
+
return 1
|
|
151
|
+
if not key.startswith("sk-"):
|
|
152
|
+
print("⚠ Key 似乎不以 'sk-' 开头,但仍将保存。")
|
|
153
|
+
save_api_key(key)
|
|
154
|
+
print(f"✔ 已保存到 {AUTH_PATH}")
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
raise SystemExit(main())
|
nexforge/config.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""NexForgeCLI 配置系统。
|
|
2
|
+
|
|
3
|
+
优先级:内置默认值 → ``~/.nexforge/config.toml`` 覆盖 → 环境变量(API key)。
|
|
4
|
+
|
|
5
|
+
API Key 解析链:环境变量 > 凭据文件(nexforge login 写入) > config.toml 内 api_key。
|
|
6
|
+
凭据文件 ``~/.nexforge/auth.toml`` 独立于主配置,不会被 git 追踪。
|
|
7
|
+
|
|
8
|
+
所有 DeepSeek V4 的真实参数(model id / endpoint / 定价)作为内置默认值,
|
|
9
|
+
用户可在 ``config.toml`` 覆盖。数据核实自 api-docs.deepseek.com(2026-04)。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import tomllib
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Literal
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, Field, PrivateAttr
|
|
20
|
+
|
|
21
|
+
Tier = Literal["flash", "pro", "auto"]
|
|
22
|
+
Effort = Literal["high", "max"]
|
|
23
|
+
|
|
24
|
+
CONFIG_DIR = Path.home() / ".nexforge"
|
|
25
|
+
CONFIG_PATH = CONFIG_DIR / "config.toml"
|
|
26
|
+
AUTH_PATH = CONFIG_DIR / "auth.toml"
|
|
27
|
+
SESSIONS_DIR = CONFIG_DIR / "sessions"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PricingTier(BaseModel):
|
|
31
|
+
input_cache_hit: float
|
|
32
|
+
input_cache_miss: float
|
|
33
|
+
output: float
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ProviderConfig(BaseModel):
|
|
37
|
+
base_url: str = "https://api.deepseek.com"
|
|
38
|
+
api_key_env: str = "DEEPSEEK_API_KEY"
|
|
39
|
+
api_key: str | None = None
|
|
40
|
+
timeout: float = 120.0
|
|
41
|
+
max_retries: int = 2
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ModelsConfig(BaseModel):
|
|
45
|
+
flash: str = "deepseek-v4-flash"
|
|
46
|
+
pro: str = "deepseek-v4-pro"
|
|
47
|
+
default_tier: Tier = "auto"
|
|
48
|
+
thinking: bool = True
|
|
49
|
+
reasoning_effort: Effort = "high"
|
|
50
|
+
context_window: int = 1_000_000
|
|
51
|
+
max_output_tokens: int = 8192
|
|
52
|
+
temperature: float | None = None # 0-2,仅 thinking=off 生效
|
|
53
|
+
top_p: float | None = None
|
|
54
|
+
frequency_penalty: float | None = None
|
|
55
|
+
presence_penalty: float | None = None
|
|
56
|
+
stop: str | list[str] | None = None
|
|
57
|
+
use_max_completion_tokens: bool = True # DeepSeek 推荐用新参数名
|
|
58
|
+
pricing: dict[str, PricingTier] = Field(
|
|
59
|
+
default_factory=lambda: {
|
|
60
|
+
"flash": PricingTier(input_cache_hit=0.0028, input_cache_miss=0.14, output=0.28),
|
|
61
|
+
"pro": PricingTier(input_cache_hit=0.003625, input_cache_miss=0.435, output=0.87),
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class RouterConfig(BaseModel):
|
|
67
|
+
entry: Tier = "flash"
|
|
68
|
+
escalate_to: Tier = "pro"
|
|
69
|
+
escalate_on_tool_failures: int = 2
|
|
70
|
+
escalate_keywords: list[str] = Field(
|
|
71
|
+
default_factory=lambda: [
|
|
72
|
+
"重构", "refactor", "架构", "architecture", "算法", "algorithm",
|
|
73
|
+
"证明", "prove", "调试", "debug", "为什么", "why", "设计", "design",
|
|
74
|
+
]
|
|
75
|
+
)
|
|
76
|
+
escalate_context_tokens: int = 200_000
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class UIConfig(BaseModel):
|
|
80
|
+
theme: Literal["dark", "light"] = "dark"
|
|
81
|
+
show_thinking: bool = True
|
|
82
|
+
icons: Literal["auto", "image", "nerd", "glyph", "ascii"] = "auto"
|
|
83
|
+
mode: Literal["review", "auto"] = "review"
|
|
84
|
+
plan_mode: bool = False # /plan 切换
|
|
85
|
+
json_mode: bool = False # /json 开关,强制 JSON 输出
|
|
86
|
+
accent: str = "#6D5EF5"
|
|
87
|
+
accent2: str = "#22D3EE"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ToolsConfig(BaseModel):
|
|
91
|
+
enable_git_commit: bool = False
|
|
92
|
+
max_tool_iters: int = 50 # 工具轮数安全上限(防死循环)
|
|
93
|
+
turn_timeout: int = 300 # 单轮总超时(秒),防卡死
|
|
94
|
+
tool_timeout: int = 60 # 单个工具超时(秒)
|
|
95
|
+
|
|
96
|
+
class PermissionsConfig(BaseModel):
|
|
97
|
+
profile: str = "supervised" # safe | supervised | auto
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class NexForgeConfig(BaseModel):
|
|
101
|
+
provider: ProviderConfig = Field(default_factory=ProviderConfig)
|
|
102
|
+
models: ModelsConfig = Field(default_factory=ModelsConfig)
|
|
103
|
+
router: RouterConfig = Field(default_factory=RouterConfig)
|
|
104
|
+
ui: UIConfig = Field(default_factory=UIConfig)
|
|
105
|
+
tools: ToolsConfig = Field(default_factory=ToolsConfig)
|
|
106
|
+
permissions: PermissionsConfig = Field(default_factory=PermissionsConfig)
|
|
107
|
+
_raw_toml: dict = PrivateAttr(default_factory=dict)
|
|
108
|
+
|
|
109
|
+
def resolve_api_key(self) -> str | None:
|
|
110
|
+
return os.environ.get(self.provider.api_key_env) or load_saved_api_key() or self.provider.api_key
|
|
111
|
+
|
|
112
|
+
def model_id(self, tier: Tier | None = None) -> str:
|
|
113
|
+
t = tier or self.models.default_tier
|
|
114
|
+
if t == "auto":
|
|
115
|
+
t = self.router.entry
|
|
116
|
+
return self.models.pro if t == "pro" else self.models.flash
|
|
117
|
+
|
|
118
|
+
def price(self, tier: Tier) -> PricingTier:
|
|
119
|
+
key = "pro" if tier == "pro" else "flash"
|
|
120
|
+
return self.models.pricing[key]
|
|
121
|
+
|
|
122
|
+
def pretty(self) -> str:
|
|
123
|
+
key = self.resolve_api_key()
|
|
124
|
+
masked = "(未设置,运行 nexforge login 配置)" if not key else f"{key[:6]}…{key[-4:]}"
|
|
125
|
+
if os.environ.get(self.provider.api_key_env):
|
|
126
|
+
source = f"env:{self.provider.api_key_env}"
|
|
127
|
+
elif load_saved_api_key():
|
|
128
|
+
source = f"凭据文件 {AUTH_PATH}"
|
|
129
|
+
elif self.provider.api_key:
|
|
130
|
+
source = "config.toml"
|
|
131
|
+
else:
|
|
132
|
+
source = "无"
|
|
133
|
+
return (
|
|
134
|
+
"NexForgeCLI 配置\n"
|
|
135
|
+
f" endpoint : {self.provider.base_url}\n"
|
|
136
|
+
f" api_key : {masked} (来源 {source})\n"
|
|
137
|
+
f" default_tier : {self.models.default_tier}\n"
|
|
138
|
+
f" flash / pro : {self.models.flash} / {self.models.pro}\n"
|
|
139
|
+
f" thinking : {self.models.thinking} (effort={self.models.reasoning_effort})\n"
|
|
140
|
+
f" theme / icons : {self.ui.theme} / {self.ui.icons}\n"
|
|
141
|
+
f" mode : {self.ui.mode}"
|
|
142
|
+
f"{' (plan)' if self.ui.plan_mode else ''}\n"
|
|
143
|
+
f" max_tool_iters: {self.tools.max_tool_iters}\n"
|
|
144
|
+
f" config file : {CONFIG_PATH} ({'存在' if CONFIG_PATH.exists() else '缺省'})"
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _deep_merge(base: dict, override: dict) -> dict:
|
|
149
|
+
out = dict(base)
|
|
150
|
+
for k, v in override.items():
|
|
151
|
+
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
|
152
|
+
out[k] = _deep_merge(out[k], v)
|
|
153
|
+
else:
|
|
154
|
+
out[k] = v
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def load_config(path: Path | None = None) -> NexForgeConfig:
|
|
159
|
+
cfg_path = path or CONFIG_PATH
|
|
160
|
+
defaults = NexForgeConfig().model_dump()
|
|
161
|
+
raw = {}
|
|
162
|
+
if cfg_path.exists():
|
|
163
|
+
with cfg_path.open("rb") as f:
|
|
164
|
+
raw = tomllib.load(f)
|
|
165
|
+
merged = _deep_merge(defaults, raw)
|
|
166
|
+
cfg = NexForgeConfig.model_validate(merged)
|
|
167
|
+
else:
|
|
168
|
+
cfg = NexForgeConfig.model_validate(defaults)
|
|
169
|
+
object.__setattr__(cfg, "_raw_toml", raw)
|
|
170
|
+
return cfg
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def save_api_key(key: str) -> None:
|
|
174
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
AUTH_PATH.write_text(f'api_key = "{key}"\n', encoding="utf-8")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def load_saved_api_key() -> str | None:
|
|
179
|
+
if not AUTH_PATH.exists():
|
|
180
|
+
return None
|
|
181
|
+
try:
|
|
182
|
+
with AUTH_PATH.open("r", encoding="utf-8") as f:
|
|
183
|
+
for line in f:
|
|
184
|
+
s = line.strip()
|
|
185
|
+
if s.startswith("api_key"):
|
|
186
|
+
val = s.split("=", 1)[-1].strip()
|
|
187
|
+
if len(val) >= 2 and val[0] in ('"', "'") and val[0] == val[-1]:
|
|
188
|
+
val = val[1:-1]
|
|
189
|
+
return val if val else None
|
|
190
|
+
except Exception:
|
|
191
|
+
pass
|
|
192
|
+
return None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""核心层:智能体循环、会话、流式处理、auto 路由。"""
|