nexforge-cli 0.1.1__tar.gz → 0.2.0__tar.gz

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 (99) hide show
  1. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/PKG-INFO +2 -1
  2. nexforge_cli-0.2.0/doc/layout-F2.md +42 -0
  3. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/pyproject.toml +2 -1
  4. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/core/session.py +55 -55
  5. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/provider/deepseek.py +204 -204
  6. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/store/session_store.py +1 -1
  7. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/ui/screens/chat.py +86 -29
  8. nexforge_cli-0.2.0/src/nexforge/ui/widgets/banner.py +29 -0
  9. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/ui/widgets/messages.py +8 -6
  10. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/ui/widgets/multiline_modal.py +54 -59
  11. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/ui/widgets/multiline_prompt.py +2 -0
  12. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/tests/test_config.py +84 -84
  13. nexforge_cli-0.1.1/src/nexforge/ui/widgets/banner.py +0 -18
  14. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/.gitignore +0 -0
  15. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/= +0 -0
  16. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/INSTALL.md +0 -0
  17. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/README.md +0 -0
  18. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/assets/icons/agent.svg +0 -0
  19. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/assets/icons/cost.svg +0 -0
  20. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/assets/icons/edit.svg +0 -0
  21. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/assets/icons/err.svg +0 -0
  22. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/assets/icons/file.svg +0 -0
  23. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/assets/icons/model.svg +0 -0
  24. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/assets/icons/ok.svg +0 -0
  25. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/assets/icons/read.svg +0 -0
  26. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/assets/icons/running.svg +0 -0
  27. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/assets/icons/search.svg +0 -0
  28. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/assets/icons/shell.svg +0 -0
  29. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/assets/icons/thinking.svg +0 -0
  30. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/assets/icons/web.svg +0 -0
  31. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/assets/icons/write.svg +0 -0
  32. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/config.example.toml +0 -0
  33. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/devlog/2026-07-07.md +0 -0
  34. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/doc//344/274/232/350/257/235/347/256/241/347/220/206.md" +0 -0
  35. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/doc//345/221/275/344/273/244/344/270/216/345/277/253/346/215/267/351/224/256.md" +0 -0
  36. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/doc//345/256/211/350/243/205/344/270/216/351/205/215/347/275/256.md" +0 -0
  37. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/doc//345/267/245/345/205/267/345/217/202/350/200/203.md" +0 -0
  38. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/doc//346/200/273/350/247/210.md" +0 -0
  39. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/doc//346/235/203/351/231/220/347/263/273/347/273/237.md" +0 -0
  40. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/doc//350/256/241/345/210/222/346/250/241/345/274/217.md" +0 -0
  41. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/doc//351/253/230/347/272/247/345/212/237/350/203/275.md" +0 -0
  42. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/scripts/publish.sh +0 -0
  43. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/scripts/smoke_provider.py +0 -0
  44. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/scripts/smoke_tui.py +0 -0
  45. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/__init__.py +0 -0
  46. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/__main__.py +0 -0
  47. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/app.py +0 -0
  48. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/cli.py +0 -0
  49. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/config.py +0 -0
  50. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/core/__init__.py +0 -0
  51. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/core/agent.py +0 -0
  52. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/core/jobs.py +0 -0
  53. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/core/permissions.py +0 -0
  54. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/core/router.py +0 -0
  55. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/core/types.py +0 -0
  56. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/icons.py +0 -0
  57. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/mcp/__init__.py +0 -0
  58. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/provider/__init__.py +0 -0
  59. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/provider/balance.py +0 -0
  60. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/skills/loader.py +0 -0
  61. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/store/__init__.py +0 -0
  62. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/store/plan_store.py +0 -0
  63. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/theme.tcss +0 -0
  64. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/tools/__init__.py +0 -0
  65. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/tools/_paths.py +0 -0
  66. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/tools/background.py +0 -0
  67. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/tools/base.py +0 -0
  68. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/tools/edit_file.py +0 -0
  69. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/tools/fs_ops.py +0 -0
  70. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/tools/git.py +0 -0
  71. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/tools/list_dir.py +0 -0
  72. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/tools/multi_edit.py +0 -0
  73. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/tools/read_file.py +0 -0
  74. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/tools/registry.py +0 -0
  75. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/tools/search_content.py +0 -0
  76. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/tools/search_files.py +0 -0
  77. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/tools/shell.py +0 -0
  78. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/tools/web.py +0 -0
  79. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/tools/write_file.py +0 -0
  80. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/ui/__init__.py +0 -0
  81. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/ui/screens/__init__.py +0 -0
  82. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/ui/widgets/__init__.py +0 -0
  83. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/ui/widgets/apikey_modal.py +0 -0
  84. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/ui/widgets/confirm_modal.py +0 -0
  85. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/ui/widgets/diff_modal.py +0 -0
  86. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/ui/widgets/mode_warning.py +0 -0
  87. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/ui/widgets/plan_sidebar.py +0 -0
  88. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/ui/widgets/sidebar.py +0 -0
  89. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/ui/widgets/statusbar.py +0 -0
  90. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/web/__init__.py +0 -0
  91. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/web/server.py +0 -0
  92. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/src/nexforge/web/static/index.html +0 -0
  93. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/tests/test_approval.py +0 -0
  94. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/tests/test_icons.py +0 -0
  95. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/tests/test_m2_tools.py +0 -0
  96. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/tests/test_sidebar.py +0 -0
  97. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/tests/test_sidebar_actions.py +0 -0
  98. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/tests/test_tools.py +0 -0
  99. {nexforge_cli-0.1.1 → nexforge_cli-0.2.0}/tests/test_tui.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nexforge-cli
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: NexForgeCLI — 一个面向 DeepSeek V4 的终端智能体(TUI + CLI 双模式)
5
5
  Project-URL: Homepage, https://gitee.com/Lighthorn/nexforgecli
6
6
  Author: NexForge
@@ -22,6 +22,7 @@ Requires-Python: >=3.11
22
22
  Requires-Dist: httpx>=0.27
23
23
  Requires-Dist: openai>=1.40
24
24
  Requires-Dist: pydantic>=2.6
25
+ Requires-Dist: pyperclip>=1.8
25
26
  Requires-Dist: rich>=13.7
26
27
  Requires-Dist: textual>=0.60
27
28
  Provides-Extra: dev
@@ -0,0 +1,42 @@
1
+ # F2 多行输入弹窗布局
2
+
3
+ ## CSS
4
+ ```css
5
+ MultilineInputModal {
6
+ align: center middle;
7
+ }
8
+ MultilineInputModal > Vertical {
9
+ width: 80;
10
+ height: 30;
11
+ background: #1a1a24;
12
+ border: round #6D5EF5;
13
+ padding: 1 2;
14
+ }
15
+ #mli-title {
16
+ color: #6D5EF5;
17
+ text-style: bold;
18
+ height: auto;
19
+ }
20
+ #mli-textarea {
21
+ height: 1fr;
22
+ margin: 1 0;
23
+ }
24
+ ```
25
+
26
+ ## 结构
27
+ ```
28
+ ┌──────────────────────────────────────┐
29
+ │ 标题 (height: auto) │
30
+ │ │
31
+ │ TextArea (height: 1fr) │
32
+ │ │
33
+ │ [发送] [取消] │
34
+ │ (独立 Button,非 Horizontal) │
35
+ └──────────────────────────────────────┘
36
+ ```
37
+
38
+ ## 关键规则
39
+ - 外层 Vertical: `height: 30`, `align: center middle`
40
+ - TextArea: `height: 1fr` 占满剩余空间
41
+ - 按钮: **两个独立 Button**(不用 Horizontal 容器),垂直排列贴底
42
+ - 总高: 30 行,内容区自动扩展
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nexforge-cli"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "NexForgeCLI — 一个面向 DeepSeek V4 的终端智能体(TUI + CLI 双模式)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -32,6 +32,7 @@ dependencies = [
32
32
  "openai>=1.40",
33
33
  "pydantic>=2.6",
34
34
  "httpx>=0.27",
35
+ "pyperclip>=1.8",
35
36
  ]
36
37
 
37
38
  [project.optional-dependencies]
@@ -1,55 +1,55 @@
1
- """会话状态:消息列表 + 累计用量。
2
-
3
- 关键规则(DeepSeek):发生工具调用的回合,assistant 的 reasoning_content
4
- 必须回传给 API,否则 400;无工具调用的最终回合无需回传。
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- from dataclasses import dataclass, field
10
-
11
- from nexforge.core.types import AssistantMessage, Usage
12
-
13
- DEFAULT_SYSTEM = (
14
- "你是 NexForgeCLI,一个运行在终端里的智能体助手,由 DeepSeek V4 驱动。\n"
15
- "你可以调用工具读写文件、执行命令来完成用户的编码任务。\n"
16
- "回答用简洁的中文;修改文件用 edit_file 工具;需要执行命令用 run_shell。\n"
17
- "完成任务后用一两句话总结你做了什么。\n"
18
- "不要用 emoji(📦🌤等),用纯文本或 Markdown 列表/表格表示信息。\n"
19
- "执行 Shell 命令前先用 run_shell 探测操作系统(Windows用cmd命令,Linux/macOS用bash命令)。"
20
- )
21
-
22
-
23
- @dataclass
24
- class Session:
25
- system_prompt: str = DEFAULT_SYSTEM
26
- messages: list[dict] = field(default_factory=list)
27
- total_usage: Usage = field(default_factory=Usage)
28
-
29
- def __post_init__(self) -> None:
30
- if not self.messages:
31
- self.messages.append({"role": "system", "content": self.system_prompt})
32
-
33
- def add_user(self, text: str) -> None:
34
- self.messages.append({"role": "user", "content": text})
35
-
36
- def add_assistant(self, msg: AssistantMessage) -> None:
37
- # 始终回传 reasoning_content(DeepSeek 多轮思考对话要求)
38
- include_reasoning = bool(msg.reasoning_content)
39
- self.messages.append(msg.to_message_dict(include_reasoning=include_reasoning))
40
- if msg.usage:
41
- self.total_usage = self.total_usage + msg.usage
42
-
43
- def add_tool_result(self, tool_call_id: str, content: str) -> None:
44
- self.messages.append(
45
- {"role": "tool", "tool_call_id": tool_call_id, "content": content}
46
- )
47
-
48
- def inject_skill_index(self, idx: str) -> None:
49
- """在 system 消息后插入技能索引。"""
50
- if idx and self.messages:
51
- self.messages.insert(1, {"role": "system", "content": idx})
52
-
53
- def approx_context_tokens(self) -> int:
54
- chars = sum(len(str(m.get("content") or "")) for m in self.messages)
55
- return chars // 4
1
+ """会话状态:消息列表 + 累计用量。
2
+
3
+ 关键规则(DeepSeek):发生工具调用的回合,assistant 的 reasoning_content
4
+ 必须回传给 API,否则 400;无工具调用的最终回合无需回传。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+ from nexforge.core.types import AssistantMessage, Usage
12
+
13
+ DEFAULT_SYSTEM = (
14
+ "你是 NexForgeCLI,一个运行在终端里的智能体助手,由 DeepSeek V4 驱动。\n"
15
+ "你可以调用工具读写文件、执行命令来完成用户的编码任务。\n"
16
+ "回答用简洁的中文;修改文件用 edit_file 工具;需要执行命令用 run_shell。\n"
17
+ "完成任务后用一两句话总结你做了什么。\n"
18
+ "不要用 emoji(📦🌤等),用纯文本或 Markdown 列表/表格表示信息。\n"
19
+ "执行 Shell 命令前先用 run_shell 探测操作系统(Windows用cmd命令,Linux/macOS用bash命令)。"
20
+ )
21
+
22
+
23
+ @dataclass
24
+ class Session:
25
+ system_prompt: str = DEFAULT_SYSTEM
26
+ messages: list[dict] = field(default_factory=list)
27
+ total_usage: Usage = field(default_factory=Usage)
28
+
29
+ def __post_init__(self) -> None:
30
+ if not self.messages:
31
+ self.messages.append({"role": "system", "content": self.system_prompt})
32
+
33
+ def add_user(self, text: str) -> None:
34
+ self.messages.append({"role": "user", "content": text})
35
+
36
+ def add_assistant(self, msg: AssistantMessage) -> None:
37
+ # 始终回传 reasoning_content(DeepSeek 多轮思考对话要求)
38
+ include_reasoning = bool(msg.reasoning_content)
39
+ self.messages.append(msg.to_message_dict(include_reasoning=include_reasoning))
40
+ if msg.usage:
41
+ self.total_usage = self.total_usage + msg.usage
42
+
43
+ def add_tool_result(self, tool_call_id: str, content: str) -> None:
44
+ self.messages.append(
45
+ {"role": "tool", "tool_call_id": tool_call_id, "content": content}
46
+ )
47
+
48
+ def inject_skill_index(self, idx: str) -> None:
49
+ """在 system 消息后插入技能索引。"""
50
+ if idx and self.messages:
51
+ self.messages.insert(1, {"role": "system", "content": idx})
52
+
53
+ def approx_context_tokens(self) -> int:
54
+ chars = sum(len(str(m.get("content") or "")) for m in self.messages)
55
+ return chars // 4
@@ -1,204 +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
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
@@ -192,7 +192,7 @@ def delete_session(session_id: str) -> bool:
192
192
  today = _today_dir()
193
193
  target = today / f"{session_id}.md"
194
194
  if target.exists():
195
- target.unlink()
195
+ target.unlink(missing_ok=True)
196
196
  return True
197
197
  # 也搜其他日期目录
198
198
  proj = _sessions_dir()