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.
Files changed (74) hide show
  1. nexforge/__init__.py +7 -0
  2. nexforge/__main__.py +6 -0
  3. nexforge/app.py +38 -0
  4. nexforge/assets/icons/agent.svg +13 -0
  5. nexforge/assets/icons/cost.svg +5 -0
  6. nexforge/assets/icons/edit.svg +4 -0
  7. nexforge/assets/icons/err.svg +5 -0
  8. nexforge/assets/icons/file.svg +5 -0
  9. nexforge/assets/icons/model.svg +7 -0
  10. nexforge/assets/icons/ok.svg +4 -0
  11. nexforge/assets/icons/read.svg +8 -0
  12. nexforge/assets/icons/running.svg +5 -0
  13. nexforge/assets/icons/search.svg +5 -0
  14. nexforge/assets/icons/shell.svg +5 -0
  15. nexforge/assets/icons/thinking.svg +5 -0
  16. nexforge/assets/icons/web.svg +6 -0
  17. nexforge/assets/icons/write.svg +4 -0
  18. nexforge/cli.py +159 -0
  19. nexforge/config.py +192 -0
  20. nexforge/core/__init__.py +1 -0
  21. nexforge/core/agent.py +244 -0
  22. nexforge/core/jobs.py +97 -0
  23. nexforge/core/permissions.py +73 -0
  24. nexforge/core/router.py +62 -0
  25. nexforge/core/session.py +55 -0
  26. nexforge/core/types.py +103 -0
  27. nexforge/icons.py +173 -0
  28. nexforge/mcp/__init__.py +168 -0
  29. nexforge/provider/__init__.py +1 -0
  30. nexforge/provider/balance.py +37 -0
  31. nexforge/provider/deepseek.py +204 -0
  32. nexforge/skills/loader.py +102 -0
  33. nexforge/store/__init__.py +1 -0
  34. nexforge/store/plan_store.py +112 -0
  35. nexforge/store/session_store.py +206 -0
  36. nexforge/theme.tcss +128 -0
  37. nexforge/tools/__init__.py +6 -0
  38. nexforge/tools/_paths.py +22 -0
  39. nexforge/tools/background.py +111 -0
  40. nexforge/tools/base.py +56 -0
  41. nexforge/tools/edit_file.py +73 -0
  42. nexforge/tools/fs_ops.py +125 -0
  43. nexforge/tools/git.py +89 -0
  44. nexforge/tools/list_dir.py +49 -0
  45. nexforge/tools/multi_edit.py +84 -0
  46. nexforge/tools/read_file.py +51 -0
  47. nexforge/tools/registry.py +94 -0
  48. nexforge/tools/search_content.py +90 -0
  49. nexforge/tools/search_files.py +48 -0
  50. nexforge/tools/shell.py +60 -0
  51. nexforge/tools/web.py +40 -0
  52. nexforge/tools/write_file.py +40 -0
  53. nexforge/ui/__init__.py +1 -0
  54. nexforge/ui/screens/__init__.py +1 -0
  55. nexforge/ui/screens/chat.py +825 -0
  56. nexforge/ui/widgets/__init__.py +1 -0
  57. nexforge/ui/widgets/apikey_modal.py +65 -0
  58. nexforge/ui/widgets/banner.py +18 -0
  59. nexforge/ui/widgets/confirm_modal.py +58 -0
  60. nexforge/ui/widgets/diff_modal.py +67 -0
  61. nexforge/ui/widgets/messages.py +132 -0
  62. nexforge/ui/widgets/mode_warning.py +57 -0
  63. nexforge/ui/widgets/multiline_modal.py +59 -0
  64. nexforge/ui/widgets/multiline_prompt.py +25 -0
  65. nexforge/ui/widgets/plan_sidebar.py +89 -0
  66. nexforge/ui/widgets/sidebar.py +99 -0
  67. nexforge/ui/widgets/statusbar.py +70 -0
  68. nexforge/web/__init__.py +0 -0
  69. nexforge/web/server.py +177 -0
  70. nexforge/web/static/index.html +123 -0
  71. nexforge_cli-0.1.0.dist-info/METADATA +168 -0
  72. nexforge_cli-0.1.0.dist-info/RECORD +74 -0
  73. nexforge_cli-0.1.0.dist-info/WHEEL +4 -0
  74. 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
@@ -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
+ """持久化存储层。"""