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/icons.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""图标系统 —— SVG 单一源,按终端能力分级渲染。
|
|
2
|
+
|
|
3
|
+
三级文本渲染:
|
|
4
|
+
nerd 终端装了 Nerd Font → 图标字体(即插即用)
|
|
5
|
+
glyph 几何符号(默认,科技感单色,任何现代终端都显)
|
|
6
|
+
ascii 纯 ASCII 兜底(NO_COLOR / 老终端)
|
|
7
|
+
|
|
8
|
+
加上图像协议模式(需 Kitty/iTerm2/Sixel + cairosvg):只用于横幅/大图,
|
|
9
|
+
不在内联文本中使用。
|
|
10
|
+
|
|
11
|
+
SVG 位于 assets/icons/<name>.svg,是设计的唯一真源。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
from functools import lru_cache
|
|
18
|
+
from importlib import resources
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Literal
|
|
21
|
+
|
|
22
|
+
Mode = Literal["image", "nerd", "glyph", "ascii"]
|
|
23
|
+
|
|
24
|
+
# name → (nerd, glyph, ascii)
|
|
25
|
+
# nerd 栏为空表示该图标没有理想的 Nerd Font 字形,fallback 到 glyph
|
|
26
|
+
ICONS: dict[str, tuple[str, str, str]] = {
|
|
27
|
+
"agent": ("", "◆", "#"),
|
|
28
|
+
"user": ("", "❯", ">"),
|
|
29
|
+
"read": ("", "▤", "r"),
|
|
30
|
+
"write": ("", "✎", "w"),
|
|
31
|
+
"edit": ("", "✎", "e"),
|
|
32
|
+
"shell": ("", "▸", "$"),
|
|
33
|
+
"search": ("", "⌕", "?"),
|
|
34
|
+
"dir": ("", "▸", "/"),
|
|
35
|
+
"web": ("", "◍", "@"),
|
|
36
|
+
"thinking": ("", "✦", "..."),
|
|
37
|
+
"ok": ("", "✓", "+"),
|
|
38
|
+
"err": ("", "✗", "x"),
|
|
39
|
+
"running": ("", "●", "*"),
|
|
40
|
+
"model": ("", "⬡", "M"),
|
|
41
|
+
"cost": ("", "$", "$"),
|
|
42
|
+
"file": ("", "▢", "f"),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# 工具名 → 图标名
|
|
46
|
+
_TOOL_ICON = {
|
|
47
|
+
"read_file": "read",
|
|
48
|
+
"write_file": "write",
|
|
49
|
+
"edit_file": "edit",
|
|
50
|
+
"list_dir": "dir",
|
|
51
|
+
"run_shell": "shell",
|
|
52
|
+
"search_content": "search",
|
|
53
|
+
"search_files": "search",
|
|
54
|
+
"web_fetch": "web",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def detect_image_protocol() -> str | None:
|
|
59
|
+
"""探测终端图像协议;探测不到返回 None。"""
|
|
60
|
+
env = os.environ
|
|
61
|
+
if env.get("KITTY_WINDOW_ID") or env.get("TERM") == "xterm-kitty":
|
|
62
|
+
return "kitty"
|
|
63
|
+
prog = env.get("TERM_PROGRAM", "")
|
|
64
|
+
if prog == "iTerm.app":
|
|
65
|
+
return "iterm2"
|
|
66
|
+
if prog == "WezTerm":
|
|
67
|
+
return "iterm2"
|
|
68
|
+
if "sixel" in env.get("TERM", ""):
|
|
69
|
+
return "sixel"
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _images_available() -> bool:
|
|
74
|
+
try:
|
|
75
|
+
import cairosvg # noqa: F401
|
|
76
|
+
|
|
77
|
+
return True
|
|
78
|
+
except Exception:
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _nerd_font_available() -> bool:
|
|
83
|
+
"""简单探测:环境变量 NERD_FONT=1 或用户设置 icons=nerd。"""
|
|
84
|
+
return os.environ.get("NERD_FONT") in ("1", "true", "yes")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class IconRenderer:
|
|
88
|
+
"""按配置 + 终端能力,把图标名解析成可打印文本。"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, mode: str = "auto") -> None:
|
|
91
|
+
self.configured = mode
|
|
92
|
+
self.mode: Mode = self.resolve_mode(mode)
|
|
93
|
+
self.image_protocol = detect_image_protocol()
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def resolve_mode(configured: str) -> Mode:
|
|
97
|
+
if configured in ("ascii", "glyph", "nerd", "image"):
|
|
98
|
+
return configured # type: ignore[return-value]
|
|
99
|
+
# auto: NO_COLOR → ascii, 图像 → image, nerd env → nerd, 默认 glyph
|
|
100
|
+
if os.environ.get("NO_COLOR") is not None:
|
|
101
|
+
return "ascii"
|
|
102
|
+
if detect_image_protocol() and _images_available():
|
|
103
|
+
return "image"
|
|
104
|
+
if _nerd_font_available():
|
|
105
|
+
return "nerd"
|
|
106
|
+
return "glyph"
|
|
107
|
+
|
|
108
|
+
def text(self, name: str) -> str:
|
|
109
|
+
"""返回图标的文本表示:nerd → fallback 到 glyph, ascii → ascii。"""
|
|
110
|
+
nerd, glyph, ascii_ = ICONS.get(name, ("", "•", "*"))
|
|
111
|
+
if self.mode == "ascii":
|
|
112
|
+
return ascii_
|
|
113
|
+
if self.mode == "nerd" and nerd:
|
|
114
|
+
return nerd
|
|
115
|
+
return glyph
|
|
116
|
+
|
|
117
|
+
def rasterize(self, name: str, size: int = 24) -> bytes | None:
|
|
118
|
+
"""把 SVG 转成 PNG 字节;缺 cairosvg 或缺文件时返回 None。"""
|
|
119
|
+
svg = svg_path(name)
|
|
120
|
+
if svg is None:
|
|
121
|
+
return None
|
|
122
|
+
try:
|
|
123
|
+
import cairosvg
|
|
124
|
+
except Exception:
|
|
125
|
+
return None
|
|
126
|
+
return cairosvg.svg2png(url=str(svg), output_width=size, output_height=size)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---- 便捷助手 ---------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
@lru_cache(maxsize=1)
|
|
132
|
+
def _icons_dir() -> Path | None:
|
|
133
|
+
try:
|
|
134
|
+
pkg = resources.files("nexforge") / "assets" / "icons"
|
|
135
|
+
if pkg.is_dir(): # type: ignore[union-attr]
|
|
136
|
+
return Path(str(pkg))
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
dev = Path(__file__).resolve().parent.parent.parent / "assets" / "icons"
|
|
140
|
+
return dev if dev.is_dir() else None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def svg_path(name: str) -> Path | None:
|
|
144
|
+
d = _icons_dir()
|
|
145
|
+
if d is None:
|
|
146
|
+
return None
|
|
147
|
+
p = d / f"{name}.svg"
|
|
148
|
+
return p if p.exists() else None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# 进程级默认渲染器
|
|
152
|
+
default_icons = IconRenderer("auto")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def icon(name: str) -> str:
|
|
156
|
+
return default_icons.text(name)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def tool_icon(tool_name: str) -> str:
|
|
160
|
+
return default_icons.text(_TOOL_ICON.get(tool_name, "running"))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def build_pngs(out_dir: str | Path, size: int = 24) -> list[str]:
|
|
164
|
+
out = Path(out_dir)
|
|
165
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
r = IconRenderer("image")
|
|
167
|
+
made: list[str] = []
|
|
168
|
+
for name in ICONS:
|
|
169
|
+
data = r.rasterize(name, size=size)
|
|
170
|
+
if data:
|
|
171
|
+
(out / f"{name}.png").write_bytes(data)
|
|
172
|
+
made.append(f"{name}.png")
|
|
173
|
+
return made
|
nexforge/mcp/__init__.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""MCP 客户端 —— 挂载外部 MCP server 的工具。
|
|
2
|
+
|
|
3
|
+
从 config.toml 的 [[mcp]] 数组读取 server 定义(stdio / SSE / streamable-http),
|
|
4
|
+
启动时握手 → 获取工具列表 → 包装成 McpTool 注入 ToolRegistry。
|
|
5
|
+
|
|
6
|
+
设计(M1):仅 stdio 运输,一个 client 管理一个 server 生命周期。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import json
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
from nexforge.tools.base import BaseTool, ToolResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class McpToolInfo:
|
|
20
|
+
name: str
|
|
21
|
+
description: str = ""
|
|
22
|
+
parameters: dict = field(default_factory=lambda: {"type": "object", "properties": {}})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class McpServerConfig:
|
|
27
|
+
name: str
|
|
28
|
+
transport: str = "stdio" # stdio | sse | streamable-http
|
|
29
|
+
command: str = ""
|
|
30
|
+
args: list[str] = field(default_factory=list)
|
|
31
|
+
url: str = ""
|
|
32
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class McpTool(BaseTool):
|
|
36
|
+
"""包装来自 MCP server 的工具。"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, info: McpToolInfo, call_fn):
|
|
39
|
+
self.name = info.name
|
|
40
|
+
self.description = info.description
|
|
41
|
+
self.parameters = info.parameters
|
|
42
|
+
self._call = call_fn
|
|
43
|
+
self.requires_approval = False
|
|
44
|
+
self.parallel_safe = True
|
|
45
|
+
|
|
46
|
+
def run(self, **kwargs) -> ToolResult:
|
|
47
|
+
"""同步封包(Agent 在 asyncio.to_thread 中调用)。"""
|
|
48
|
+
try:
|
|
49
|
+
result = asyncio.get_event_loop().run_until_complete(self._call(kwargs))
|
|
50
|
+
return ToolResult(ok=True, output=result, summary=f"MCP {self.name}")
|
|
51
|
+
except Exception as exc:
|
|
52
|
+
return ToolResult(ok=False, error=f"MCP 工具调用失败:{exc}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class McpClient:
|
|
56
|
+
"""管理一个 MCP server 的连接与工具清单。"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, cfg: McpServerConfig) -> None:
|
|
59
|
+
self.cfg = cfg
|
|
60
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
61
|
+
self._tools: list[McpToolInfo] = []
|
|
62
|
+
self._connected = False
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def connected(self) -> bool:
|
|
66
|
+
return self._connected
|
|
67
|
+
|
|
68
|
+
async def connect(self) -> list[McpToolInfo]:
|
|
69
|
+
"""启动 server 子进程,做 initialize → tools/list 握手。"""
|
|
70
|
+
if self.cfg.transport == "stdio":
|
|
71
|
+
self._process = await asyncio.create_subprocess_exec(
|
|
72
|
+
self.cfg.command,
|
|
73
|
+
*self.cfg.args,
|
|
74
|
+
stdin=asyncio.subprocess.PIPE,
|
|
75
|
+
stdout=asyncio.subprocess.PIPE,
|
|
76
|
+
stderr=asyncio.subprocess.PIPE,
|
|
77
|
+
env={**__import__("os").environ, **self.cfg.env},
|
|
78
|
+
)
|
|
79
|
+
# initialize
|
|
80
|
+
init = await self._send_jsonrpc("initialize", {
|
|
81
|
+
"protocolVersion": "2024-11-05",
|
|
82
|
+
"capabilities": {},
|
|
83
|
+
"clientInfo": {"name": "NexForgeCLI", "version": "0.1.0"},
|
|
84
|
+
})
|
|
85
|
+
if "error" in init:
|
|
86
|
+
await self.disconnect()
|
|
87
|
+
raise RuntimeError(f"MCP init 失败:{init['error']}")
|
|
88
|
+
# 发送 initialized 通知
|
|
89
|
+
await self._send_jsonrpc_note("notifications/initialized", {})
|
|
90
|
+
# tools/list
|
|
91
|
+
resp = await self._send_jsonrpc("tools/list", {})
|
|
92
|
+
tools = resp.get("result", {}).get("tools", [])
|
|
93
|
+
self._tools = [
|
|
94
|
+
McpToolInfo(
|
|
95
|
+
name=t["name"],
|
|
96
|
+
description=t.get("description", ""),
|
|
97
|
+
parameters=t.get("inputSchema", {"type": "object", "properties": {}}),
|
|
98
|
+
)
|
|
99
|
+
for t in tools
|
|
100
|
+
]
|
|
101
|
+
self._connected = True
|
|
102
|
+
return self._tools
|
|
103
|
+
# SSE / streamable-http 留 TODO(需要 httpx async client)
|
|
104
|
+
raise RuntimeError(f"不支持的 transport:{self.cfg.transport}")
|
|
105
|
+
|
|
106
|
+
async def call_tool(self, name: str, args: dict) -> str:
|
|
107
|
+
if not self._connected:
|
|
108
|
+
raise RuntimeError("未连接")
|
|
109
|
+
resp = await self._send_jsonrpc("tools/call", {"name": name, "arguments": args})
|
|
110
|
+
contents = resp.get("result", {}).get("content", [])
|
|
111
|
+
text = "\n".join(
|
|
112
|
+
c.get("text", str(c)) for c in contents if isinstance(c, dict)
|
|
113
|
+
)
|
|
114
|
+
return text or json.dumps(contents)
|
|
115
|
+
|
|
116
|
+
async def disconnect(self) -> None:
|
|
117
|
+
if self._process:
|
|
118
|
+
try:
|
|
119
|
+
self._process.stdin.close()
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
try:
|
|
123
|
+
self._process.terminate()
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
try:
|
|
127
|
+
await asyncio.wait_for(self._process.wait(), timeout=3)
|
|
128
|
+
except Exception:
|
|
129
|
+
self._process.kill()
|
|
130
|
+
self._process = None
|
|
131
|
+
self._connected = False
|
|
132
|
+
|
|
133
|
+
async def _send_jsonrpc(self, method: str, params: dict) -> dict:
|
|
134
|
+
assert self._process and self._process.stdin and self._process.stdout
|
|
135
|
+
msg = json.dumps(
|
|
136
|
+
{"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
|
|
137
|
+
)
|
|
138
|
+
self._process.stdin.write(msg.encode() + b"\n")
|
|
139
|
+
await self._process.stdin.drain()
|
|
140
|
+
line = await asyncio.wait_for(self._process.stdout.readline(), timeout=15)
|
|
141
|
+
return json.loads(line.decode())
|
|
142
|
+
|
|
143
|
+
async def _send_jsonrpc_note(self, method: str, params: dict) -> None:
|
|
144
|
+
assert self._process and self._process.stdin
|
|
145
|
+
msg = json.dumps(
|
|
146
|
+
{"jsonrpc": "2.0", "method": method, "params": params}
|
|
147
|
+
)
|
|
148
|
+
self._process.stdin.write(msg.encode() + b"\n")
|
|
149
|
+
await self._process.stdin.drain()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---- 便捷:从配置实例化多个 client ----------------------------------------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def load_mcp_clients(config: "NexForgeConfig") -> list[McpClient]: # noqa: F821
|
|
156
|
+
servers: list[dict] = getattr(config, "_raw_toml", {}).get("mcp", [])
|
|
157
|
+
clients: list[McpClient] = []
|
|
158
|
+
for raw in servers:
|
|
159
|
+
cfg = McpServerConfig(
|
|
160
|
+
name=raw.get("name", "unnamed"),
|
|
161
|
+
transport=raw.get("transport", "stdio"),
|
|
162
|
+
command=raw.get("command", ""),
|
|
163
|
+
args=raw.get("args", []),
|
|
164
|
+
url=raw.get("url", ""),
|
|
165
|
+
env=raw.get("env", {}),
|
|
166
|
+
)
|
|
167
|
+
clients.append(McpClient(cfg))
|
|
168
|
+
return clients
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""集成层:DeepSeek Provider。"""
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""DeepSeek 余额查询。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
async def fetch_balance(api_key: str) -> str:
|
|
7
|
+
"""查询 DeepSeek 账户余额。返回 ¥xx.xx 格式。"""
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
endpoints = [
|
|
11
|
+
"https://api.deepseek.com/user/balance",
|
|
12
|
+
]
|
|
13
|
+
for url in endpoints:
|
|
14
|
+
try:
|
|
15
|
+
async with httpx.AsyncClient(timeout=15) as cli:
|
|
16
|
+
resp = await cli.get(
|
|
17
|
+
url,
|
|
18
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
19
|
+
)
|
|
20
|
+
if resp.status_code != 200:
|
|
21
|
+
continue
|
|
22
|
+
data = resp.json()
|
|
23
|
+
# {"is_available":true,"balance_infos":[{"currency":"CNY","total_balance":"24.94",...}]}
|
|
24
|
+
bal = None
|
|
25
|
+
cur = "CNY"
|
|
26
|
+
items = data.get("balance_infos", [])
|
|
27
|
+
if items:
|
|
28
|
+
bal = items[0].get("total_balance")
|
|
29
|
+
cur = items[0].get("currency", "CNY")
|
|
30
|
+
if bal is not None:
|
|
31
|
+
try:
|
|
32
|
+
return f"¥{float(bal):,.2f}"
|
|
33
|
+
except (ValueError, TypeError):
|
|
34
|
+
return f"¥{bal}"
|
|
35
|
+
except Exception:
|
|
36
|
+
continue
|
|
37
|
+
return "(无法获取)"
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""DeepSeek V4 Provider —— 基于 OpenAI 兼容 SDK。
|
|
2
|
+
|
|
3
|
+
要点(核实自 api-docs.deepseek.com):
|
|
4
|
+
- 模型:deepseek-v4-flash / deepseek-v4-pro
|
|
5
|
+
- 思考模式:extra_body={"thinking":{"type":"enabled"|"disabled"}} + reasoning_effort
|
|
6
|
+
- 思考链走 reasoning_content(流式为 delta.reasoning_content)
|
|
7
|
+
- 思考模式忽略 temperature/top_p/penalty,故思考开启时不传这些参数
|
|
8
|
+
- 工具调用的回合必须回传 reasoning_content(由 Session/Agent 负责)
|
|
9
|
+
- 缓存字段:usage.prompt_cache_hit_tokens / prompt_cache_miss_tokens
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any, AsyncIterator
|
|
15
|
+
|
|
16
|
+
from openai import AsyncOpenAI
|
|
17
|
+
|
|
18
|
+
from nexforge.config import Effort, NexForgeConfig, Tier
|
|
19
|
+
from nexforge.core.types import (
|
|
20
|
+
AssistantMessage,
|
|
21
|
+
ChunkKind,
|
|
22
|
+
StreamChunk,
|
|
23
|
+
ToolCall,
|
|
24
|
+
Usage,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ProviderError(RuntimeError):
|
|
29
|
+
"""Provider 层的可读错误(缺 key、网络、API 报错)。"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _extra(obj: Any, name: str) -> Any:
|
|
33
|
+
"""从 openai 返回对象里取标准字段或 extra 字段(DeepSeek 自定义字段)。"""
|
|
34
|
+
val = getattr(obj, name, None)
|
|
35
|
+
if val is not None:
|
|
36
|
+
return val
|
|
37
|
+
extra = getattr(obj, "model_extra", None)
|
|
38
|
+
if isinstance(extra, dict):
|
|
39
|
+
return extra.get(name)
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class DeepSeekProvider:
|
|
44
|
+
def __init__(self, config: NexForgeConfig):
|
|
45
|
+
self.config = config
|
|
46
|
+
key = config.resolve_api_key()
|
|
47
|
+
if not key:
|
|
48
|
+
raise ProviderError(
|
|
49
|
+
f"未找到 API Key。请设置环境变量 {config.provider.api_key_env},"
|
|
50
|
+
f"或在 {config.provider.base_url} 对应的 config.toml 中填写 api_key。"
|
|
51
|
+
)
|
|
52
|
+
self._client = AsyncOpenAI(
|
|
53
|
+
api_key=key,
|
|
54
|
+
base_url=config.provider.base_url,
|
|
55
|
+
timeout=config.provider.timeout,
|
|
56
|
+
max_retries=config.provider.max_retries,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
async def aclose(self) -> None:
|
|
60
|
+
await self._client.close()
|
|
61
|
+
|
|
62
|
+
def _build_kwargs(
|
|
63
|
+
self,
|
|
64
|
+
messages: list[dict],
|
|
65
|
+
tools: list[dict] | None,
|
|
66
|
+
tier: Tier | None,
|
|
67
|
+
thinking: bool | None,
|
|
68
|
+
effort: Effort | None,
|
|
69
|
+
) -> dict:
|
|
70
|
+
cfg = self.config
|
|
71
|
+
think = cfg.models.thinking if thinking is None else thinking
|
|
72
|
+
eff = effort or cfg.models.reasoning_effort
|
|
73
|
+
# DeepSeek 专有参数走 extra_body(直接进请求体顶层)
|
|
74
|
+
extra_body: dict[str, Any] = {
|
|
75
|
+
"thinking": {"type": "enabled" if think else "disabled"}
|
|
76
|
+
}
|
|
77
|
+
if think:
|
|
78
|
+
extra_body["reasoning_effort"] = eff
|
|
79
|
+
|
|
80
|
+
kwargs: dict[str, Any] = {
|
|
81
|
+
"model": cfg.model_id(tier),
|
|
82
|
+
"messages": messages,
|
|
83
|
+
"stream": True,
|
|
84
|
+
"stream_options": {"include_usage": True},
|
|
85
|
+
"extra_body": extra_body,
|
|
86
|
+
}
|
|
87
|
+
# max_tokens vs max_completion_tokens(DeepSeek 推荐新参数)
|
|
88
|
+
if cfg.models.use_max_completion_tokens:
|
|
89
|
+
kwargs["max_completion_tokens"] = cfg.models.max_output_tokens
|
|
90
|
+
else:
|
|
91
|
+
kwargs["max_tokens"] = cfg.models.max_output_tokens
|
|
92
|
+
# 采样参数(仅 thinking=off 生效)
|
|
93
|
+
if not think:
|
|
94
|
+
m = cfg.models
|
|
95
|
+
if m.temperature is not None:
|
|
96
|
+
kwargs["temperature"] = m.temperature
|
|
97
|
+
if m.top_p is not None:
|
|
98
|
+
kwargs["top_p"] = m.top_p
|
|
99
|
+
if m.frequency_penalty is not None:
|
|
100
|
+
kwargs["frequency_penalty"] = m.frequency_penalty
|
|
101
|
+
if m.presence_penalty is not None:
|
|
102
|
+
kwargs["presence_penalty"] = m.presence_penalty
|
|
103
|
+
if m.stop is not None:
|
|
104
|
+
kwargs["stop"] = m.stop
|
|
105
|
+
# JSON Mode
|
|
106
|
+
if cfg.ui.json_mode:
|
|
107
|
+
kwargs["response_format"] = {"type": "json_object"}
|
|
108
|
+
# 工具 + tool_choice(plan 模式强制 none)
|
|
109
|
+
if tools:
|
|
110
|
+
kwargs["tools"] = tools
|
|
111
|
+
kwargs["tool_choice"] = "none" if cfg.ui.plan_mode else "auto"
|
|
112
|
+
return kwargs
|
|
113
|
+
|
|
114
|
+
async def stream(
|
|
115
|
+
self,
|
|
116
|
+
messages: list[dict],
|
|
117
|
+
*,
|
|
118
|
+
tools: list[dict] | None = None,
|
|
119
|
+
tier: Tier | None = None,
|
|
120
|
+
thinking: bool | None = None,
|
|
121
|
+
effort: Effort | None = None,
|
|
122
|
+
) -> AsyncIterator[StreamChunk]:
|
|
123
|
+
"""流式请求,逐块产出 reasoning / content,结束时产出完整 AssistantMessage。"""
|
|
124
|
+
resolved_tier: Tier = tier or self.config.models.default_tier
|
|
125
|
+
model_id = self.config.model_id(tier)
|
|
126
|
+
kwargs = self._build_kwargs(messages, tools, tier, thinking, effort)
|
|
127
|
+
|
|
128
|
+
content = ""
|
|
129
|
+
reasoning = ""
|
|
130
|
+
tool_buf: dict[int, dict] = {}
|
|
131
|
+
usage = Usage()
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
stream = await self._client.chat.completions.create(**kwargs)
|
|
135
|
+
async for chunk in stream:
|
|
136
|
+
# 用量(include_usage 时最后一块携带,choices 可能为空)
|
|
137
|
+
if getattr(chunk, "usage", None):
|
|
138
|
+
u = chunk.usage
|
|
139
|
+
usage = Usage(
|
|
140
|
+
prompt_tokens=u.prompt_tokens or 0,
|
|
141
|
+
completion_tokens=u.completion_tokens or 0,
|
|
142
|
+
prompt_cache_hit_tokens=_extra(u, "prompt_cache_hit_tokens") or 0,
|
|
143
|
+
prompt_cache_miss_tokens=_extra(u, "prompt_cache_miss_tokens") or 0,
|
|
144
|
+
)
|
|
145
|
+
if not chunk.choices:
|
|
146
|
+
continue
|
|
147
|
+
delta = chunk.choices[0].delta
|
|
148
|
+
rc = _extra(delta, "reasoning_content")
|
|
149
|
+
if rc:
|
|
150
|
+
reasoning += rc
|
|
151
|
+
yield StreamChunk(ChunkKind.REASONING, rc)
|
|
152
|
+
if getattr(delta, "content", None):
|
|
153
|
+
content += delta.content
|
|
154
|
+
yield StreamChunk(ChunkKind.CONTENT, delta.content)
|
|
155
|
+
if getattr(delta, "tool_calls", None):
|
|
156
|
+
for tc in delta.tool_calls:
|
|
157
|
+
slot = tool_buf.setdefault(
|
|
158
|
+
tc.index, {"id": "", "name": "", "arguments": ""}
|
|
159
|
+
)
|
|
160
|
+
if tc.id:
|
|
161
|
+
slot["id"] = tc.id
|
|
162
|
+
fn = tc.function
|
|
163
|
+
if fn and fn.name:
|
|
164
|
+
slot["name"] = fn.name
|
|
165
|
+
if fn and fn.arguments:
|
|
166
|
+
slot["arguments"] += fn.arguments
|
|
167
|
+
except ProviderError:
|
|
168
|
+
raise
|
|
169
|
+
except Exception as exc: # openai / 网络异常 → 统一为可读错误
|
|
170
|
+
raise ProviderError(f"DeepSeek 请求失败:{exc}") from exc
|
|
171
|
+
|
|
172
|
+
tool_calls = [
|
|
173
|
+
ToolCall(id=slot["id"], name=slot["name"], arguments=slot["arguments"])
|
|
174
|
+
for _, slot in sorted(tool_buf.items())
|
|
175
|
+
if slot["name"]
|
|
176
|
+
]
|
|
177
|
+
message = AssistantMessage(
|
|
178
|
+
content=content,
|
|
179
|
+
reasoning_content=reasoning,
|
|
180
|
+
tool_calls=tool_calls,
|
|
181
|
+
usage=usage,
|
|
182
|
+
model=model_id,
|
|
183
|
+
tier=resolved_tier,
|
|
184
|
+
)
|
|
185
|
+
yield StreamChunk(ChunkKind.DONE, message=message)
|
|
186
|
+
|
|
187
|
+
async def complete(
|
|
188
|
+
self,
|
|
189
|
+
messages: list[dict],
|
|
190
|
+
*,
|
|
191
|
+
tools: list[dict] | None = None,
|
|
192
|
+
tier: Tier | None = None,
|
|
193
|
+
thinking: bool | None = None,
|
|
194
|
+
effort: Effort | None = None,
|
|
195
|
+
) -> AssistantMessage:
|
|
196
|
+
"""非流式便捷封装:消费整个流,返回最终消息。"""
|
|
197
|
+
final: AssistantMessage | None = None
|
|
198
|
+
async for ch in self.stream(
|
|
199
|
+
messages, tools=tools, tier=tier, thinking=thinking, effort=effort
|
|
200
|
+
):
|
|
201
|
+
if ch.kind is ChunkKind.DONE:
|
|
202
|
+
final = ch.message
|
|
203
|
+
assert final is not None
|
|
204
|
+
return final
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""SKILL.md 技能加载器。
|
|
2
|
+
|
|
3
|
+
目录约定:
|
|
4
|
+
~/.nexforge/skills/ (全局)
|
|
5
|
+
.nexforge/skills/ (项目)
|
|
6
|
+
|
|
7
|
+
文件格式:
|
|
8
|
+
---
|
|
9
|
+
name: code-review
|
|
10
|
+
description: 审查当前分支 diff
|
|
11
|
+
allowed_tools: [read_file, search_content, git_diff]
|
|
12
|
+
model: pro # 可选:flash|pro
|
|
13
|
+
---
|
|
14
|
+
(markdown 正文,作为一次性系统提示注入)
|
|
15
|
+
|
|
16
|
+
/ skill <name> 命令调用:正文注入为 system 消息,引导模型按技能剧本行事。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
HOME_SKILLS = Path.home() / ".nexforge" / "skills"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Skill:
|
|
30
|
+
name: str
|
|
31
|
+
description: str = ""
|
|
32
|
+
body: str = ""
|
|
33
|
+
allowed_tools: list[str] = field(default_factory=list)
|
|
34
|
+
model: str = ""
|
|
35
|
+
source: str = "" # 文件路径
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_frontmatter(text: str) -> tuple[dict, str]:
|
|
39
|
+
"""解析 YAML 前导(极简:仅支持顶级 key: value)。"""
|
|
40
|
+
m = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.DOTALL)
|
|
41
|
+
if not m:
|
|
42
|
+
return {}, text
|
|
43
|
+
meta = {}
|
|
44
|
+
for line in m.group(1).splitlines():
|
|
45
|
+
if ":" in line:
|
|
46
|
+
k, v = line.split(":", 1)
|
|
47
|
+
v = v.strip().strip('"').strip("'")
|
|
48
|
+
meta[k.strip()] = v
|
|
49
|
+
return meta, text[m.end():]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_skill(path: Path) -> Skill | None:
|
|
53
|
+
if not path.is_file():
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
raw = path.read_text(encoding="utf-8")
|
|
57
|
+
except Exception:
|
|
58
|
+
return None
|
|
59
|
+
meta, body = _parse_frontmatter(raw)
|
|
60
|
+
name = meta.get("name") or path.stem
|
|
61
|
+
return Skill(
|
|
62
|
+
name=name,
|
|
63
|
+
description=meta.get("description", ""),
|
|
64
|
+
body=body.strip(),
|
|
65
|
+
allowed_tools=[
|
|
66
|
+
t.strip() for t in meta.get("allowed_tools", "").strip("[]").split(",")
|
|
67
|
+
if t.strip()
|
|
68
|
+
],
|
|
69
|
+
model=meta.get("model", ""),
|
|
70
|
+
source=str(path),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def discover_skills(project_dir: Path | None = None) -> list[Skill]:
|
|
75
|
+
skills: dict[str, Skill] = {} # 项目覆盖全局同名项
|
|
76
|
+
|
|
77
|
+
# 全局技能
|
|
78
|
+
if HOME_SKILLS.is_dir():
|
|
79
|
+
for p in HOME_SKILLS.rglob("*.md"):
|
|
80
|
+
if s := load_skill(p):
|
|
81
|
+
skills[s.name] = s
|
|
82
|
+
|
|
83
|
+
# 项目技能
|
|
84
|
+
if project_dir:
|
|
85
|
+
proj = project_dir / ".nexforge" / "skills"
|
|
86
|
+
if proj.is_dir():
|
|
87
|
+
for p in proj.rglob("*.md"):
|
|
88
|
+
if s := load_skill(p):
|
|
89
|
+
skills[s.name] = s
|
|
90
|
+
|
|
91
|
+
return list(skills.values())
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def skill_index_str(skills: list[Skill]) -> str:
|
|
95
|
+
if not skills:
|
|
96
|
+
return ""
|
|
97
|
+
lines = ["\n## 可用技能(SKILL.md)\n"]
|
|
98
|
+
for s in skills:
|
|
99
|
+
desc = f" — {s.description}" if s.description else ""
|
|
100
|
+
model = f" (推荐 {s.model})" if s.model else ""
|
|
101
|
+
lines.append(f"- `/skill {s.name}`{desc}{model}")
|
|
102
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""持久化存储层。"""
|