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
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""计划文件存储 —— 项目 .nexforge/plans/ 下的 .plan.md 文件。
|
|
2
|
+
|
|
3
|
+
每个计划文件:
|
|
4
|
+
- 标题:取首行 # 或 ## 作为显示名
|
|
5
|
+
- 正文:完整 MD 计划
|
|
6
|
+
- 进度:统计 [x] / [ ] 约定或表格完成行
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _plans_dir() -> Path:
|
|
17
|
+
return Path.cwd() / ".nexforge" / "plans"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class PlanInfo:
|
|
22
|
+
id: str # 文件名(不含 .plan.md)
|
|
23
|
+
title: str # 计划标题
|
|
24
|
+
path: Path # 完整路径
|
|
25
|
+
total: int = 0 # 总任务数
|
|
26
|
+
done: int = 0 # 已完成任务数
|
|
27
|
+
content: str = "" # 缓存内容(切换时用)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _ensure_dir() -> None:
|
|
31
|
+
_plans_dir().mkdir(parents=True, exist_ok=True)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def save_plan(title: str, content: str, plan_id: str = "") -> Path:
|
|
35
|
+
"""保存计划文件。返回文件路径。"""
|
|
36
|
+
_ensure_dir()
|
|
37
|
+
if not plan_id:
|
|
38
|
+
import time
|
|
39
|
+
plan_id = f"plan-{int(time.time())}"
|
|
40
|
+
path = _plans_dir() / f"{plan_id}.plan.md"
|
|
41
|
+
md = f"# {title}\n\n{content}"
|
|
42
|
+
path.write_text(md, encoding="utf-8")
|
|
43
|
+
return path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_plan(plan_id: str) -> str | None:
|
|
47
|
+
"""加载计划的完整 MD 内容。"""
|
|
48
|
+
path = _plans_dir() / f"{plan_id}.plan.md"
|
|
49
|
+
if not path.is_file():
|
|
50
|
+
return None
|
|
51
|
+
return path.read_text(encoding="utf-8")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def list_plans() -> list[PlanInfo]:
|
|
55
|
+
"""列出所有计划,附进度统计。"""
|
|
56
|
+
d = _plans_dir()
|
|
57
|
+
if not d.is_dir():
|
|
58
|
+
return []
|
|
59
|
+
infos: list[PlanInfo] = []
|
|
60
|
+
for md in sorted(d.glob("*.plan.md")):
|
|
61
|
+
text = md.read_text(encoding="utf-8")
|
|
62
|
+
title = _read_title(text)
|
|
63
|
+
total, done = _count_progress(text)
|
|
64
|
+
pid = md.stem.removesuffix(".plan") # plan-xxx.plan.md → plan-xxx
|
|
65
|
+
infos.append(PlanInfo(
|
|
66
|
+
id=pid,
|
|
67
|
+
title=title or pid,
|
|
68
|
+
path=md,
|
|
69
|
+
total=total,
|
|
70
|
+
done=done,
|
|
71
|
+
))
|
|
72
|
+
return infos
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def delete_plan(plan_id: str) -> bool:
|
|
76
|
+
path = _plans_dir() / f"{plan_id}.plan.md"
|
|
77
|
+
if path.is_file():
|
|
78
|
+
path.unlink()
|
|
79
|
+
return True
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def update_plan_progress(plan_id: str, content: str) -> None:
|
|
84
|
+
"""用最新内容覆写计划文件(保留标题)。"""
|
|
85
|
+
path = _plans_dir() / f"{plan_id}.plan.md"
|
|
86
|
+
if not path.is_file():
|
|
87
|
+
return
|
|
88
|
+
old = path.read_text(encoding="utf-8")
|
|
89
|
+
# 保留原标题
|
|
90
|
+
title_line = old.split("\n")[0] if old.startswith("#") else f"# {plan_id}"
|
|
91
|
+
path.write_text(f"{title_line}\n\n{content}", encoding="utf-8")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _read_title(text: str) -> str | None:
|
|
95
|
+
for line in text.split("\n"):
|
|
96
|
+
s = line.strip()
|
|
97
|
+
if s.startswith("# "):
|
|
98
|
+
return s[2:].strip() or None
|
|
99
|
+
if s.startswith("## "):
|
|
100
|
+
return s[3:].strip() or None
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _count_progress(text: str) -> tuple[int, int]:
|
|
105
|
+
"""统计 [x] 和 [ ] 标记的任务数。"""
|
|
106
|
+
done = len(re.findall(r"^\s*- \[[xX]\]", text, re.MULTILINE))
|
|
107
|
+
total = len(re.findall(r"^\s*- \[[xX\s]\]", text, re.MULTILINE))
|
|
108
|
+
# 如果没有 checkbox,用表格行数(跳过表头)估计
|
|
109
|
+
if total == 0:
|
|
110
|
+
rows = re.findall(r"^\|\s*\d+\s*\|", text, re.MULTILINE)
|
|
111
|
+
total = len(rows)
|
|
112
|
+
return total, done
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""会话持久化引擎 —— 按项目+日期保存 MD 文件。
|
|
2
|
+
|
|
3
|
+
目录结构:
|
|
4
|
+
.nexforge/sessions/{项目名}/{YYYY-MM-DD}/
|
|
5
|
+
{序号}-{会话名}.md
|
|
6
|
+
|
|
7
|
+
每个 MD 包含完整对话(用户/助手/工具调用/工具结果),
|
|
8
|
+
人可直接阅读。每轮完成自动写入;`/save [name]` 手动命名。
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
def _sessions_dir() -> Path:
|
|
20
|
+
"""会话存储目录:项目根/.nexforge/sessions/。"""
|
|
21
|
+
return Path.cwd() / ".nexforge" / "sessions"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class SessionInfo:
|
|
26
|
+
id: str # 文件名(不含 .md)
|
|
27
|
+
name: str # 会话标题
|
|
28
|
+
path: Path # 完整路径
|
|
29
|
+
created: str # ISO 日期
|
|
30
|
+
message_count: int = 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _project_name() -> str:
|
|
34
|
+
"""从当前工作目录名取项目名。"""
|
|
35
|
+
return Path.cwd().name or "default"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _today_dir() -> Path:
|
|
39
|
+
base = _sessions_dir()
|
|
40
|
+
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
41
|
+
d = base / today
|
|
42
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
return d
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _next_id(directory: Path, name: str = "") -> str:
|
|
47
|
+
"""生成稳定的时间戳文件名(标题才是显示名,可随对话更新)。"""
|
|
48
|
+
ts = datetime.now(timezone.utc).strftime("%H%M%S")
|
|
49
|
+
cand = f"session-{ts}"
|
|
50
|
+
n = 1
|
|
51
|
+
while (directory / f"{cand}.md").exists():
|
|
52
|
+
n += 1
|
|
53
|
+
cand = f"session-{ts}-{n}"
|
|
54
|
+
return cand
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _format_message(msg: dict) -> str:
|
|
58
|
+
"""单条消息转 Markdown 块。"""
|
|
59
|
+
role = msg.get("role", "?")
|
|
60
|
+
content = msg.get("content", "")
|
|
61
|
+
reasoning = msg.get("reasoning_content", "")
|
|
62
|
+
tool_calls = msg.get("tool_calls")
|
|
63
|
+
|
|
64
|
+
if role == "system":
|
|
65
|
+
return f"> ℹ {content}\n"
|
|
66
|
+
if role == "user":
|
|
67
|
+
return f"### ❯ 用户\n\n{content}\n"
|
|
68
|
+
if role == "assistant":
|
|
69
|
+
parts = [f"### ◆ 助手\n"]
|
|
70
|
+
if reasoning:
|
|
71
|
+
parts.append(f"<details>\n<summary>💭 思考中…</summary>\n\n{reasoning}\n</details>\n")
|
|
72
|
+
if content:
|
|
73
|
+
parts.append(content)
|
|
74
|
+
if tool_calls:
|
|
75
|
+
for tc in tool_calls:
|
|
76
|
+
fn = tc.get("function", {})
|
|
77
|
+
parts.append(
|
|
78
|
+
f"\n🔧 `{fn.get('name', '?')}` "
|
|
79
|
+
f"`{fn.get('arguments', '')[:200]}`\n"
|
|
80
|
+
)
|
|
81
|
+
return "".join(parts) + "\n"
|
|
82
|
+
if role == "tool":
|
|
83
|
+
tool_id = msg.get("tool_call_id", "")
|
|
84
|
+
return f"> 🔧 结果 `{tool_id[:8]}…`:\n>\n> {content.strip()}\n"
|
|
85
|
+
return f"_{role}: {content}_\n"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def save_session(session_id: str, name: str, messages: list[dict]) -> Path:
|
|
89
|
+
"""把消息列表写成 MD 文件,返回文件路径。"""
|
|
90
|
+
target = _today_dir() / f"{session_id}.md"
|
|
91
|
+
lines = [
|
|
92
|
+
f"# {name}",
|
|
93
|
+
f"",
|
|
94
|
+
f"> 创建 {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
|
|
95
|
+
f"> 项目 `{_project_name()}`",
|
|
96
|
+
f"",
|
|
97
|
+
"---",
|
|
98
|
+
"",
|
|
99
|
+
]
|
|
100
|
+
for msg in messages:
|
|
101
|
+
lines.append(_format_message(msg))
|
|
102
|
+
target.write_text("\n".join(lines), encoding="utf-8")
|
|
103
|
+
return target
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def load_session(path: Path) -> list[dict] | None:
|
|
107
|
+
"""从 MD 文件重建消息列表(仅含 user/assistant/system 消息)。"""
|
|
108
|
+
if not path.is_file():
|
|
109
|
+
return None
|
|
110
|
+
text = path.read_text(encoding="utf-8")
|
|
111
|
+
messages: list[dict] = []
|
|
112
|
+
current_role = ""
|
|
113
|
+
current_content: list[str] = []
|
|
114
|
+
skip_details = False
|
|
115
|
+
|
|
116
|
+
def _flush() -> None:
|
|
117
|
+
nonlocal current_content
|
|
118
|
+
if current_role and current_content:
|
|
119
|
+
messages.append({"role": current_role, "content": "\n".join(current_content).strip()})
|
|
120
|
+
current_content = []
|
|
121
|
+
|
|
122
|
+
for line in text.splitlines():
|
|
123
|
+
# <details> 块内全部跳过(含 <summary> 和思考正文)
|
|
124
|
+
if line.startswith("<details>"):
|
|
125
|
+
skip_details = True
|
|
126
|
+
continue
|
|
127
|
+
if skip_details:
|
|
128
|
+
if line.startswith("</details>"):
|
|
129
|
+
skip_details = False
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
if line.startswith("### ❯ 用户"):
|
|
133
|
+
_flush()
|
|
134
|
+
current_role = "user"
|
|
135
|
+
elif line.startswith("### ◆ 助手"):
|
|
136
|
+
_flush()
|
|
137
|
+
current_role = "assistant"
|
|
138
|
+
elif line.startswith("> ℹ"):
|
|
139
|
+
_flush()
|
|
140
|
+
current_role = "system"
|
|
141
|
+
current_content.append(line[3:].strip())
|
|
142
|
+
elif line.startswith("> 🔧 结果") or line.startswith("🔧"):
|
|
143
|
+
current_role = ""
|
|
144
|
+
elif line == "---" or line.startswith("# "):
|
|
145
|
+
continue
|
|
146
|
+
elif line.startswith("> 创建") or line.startswith("> 项目"):
|
|
147
|
+
continue
|
|
148
|
+
elif current_role:
|
|
149
|
+
current_content.append(line)
|
|
150
|
+
_flush()
|
|
151
|
+
return messages if messages else None
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def list_sessions() -> list[SessionInfo]:
|
|
155
|
+
"""列出当前项目所有会话(按时间倒序)。"""
|
|
156
|
+
proj = _sessions_dir()
|
|
157
|
+
if not proj.is_dir():
|
|
158
|
+
return []
|
|
159
|
+
infos: list[SessionInfo] = []
|
|
160
|
+
for day_dir in sorted(proj.iterdir(), reverse=True):
|
|
161
|
+
if not day_dir.is_dir():
|
|
162
|
+
continue
|
|
163
|
+
for md in sorted(day_dir.glob("*.md"), key=os.path.getmtime, reverse=True):
|
|
164
|
+
name = _read_title(md) or md.stem
|
|
165
|
+
infos.append(SessionInfo(
|
|
166
|
+
id=md.stem,
|
|
167
|
+
name=name,
|
|
168
|
+
path=md,
|
|
169
|
+
created=day_dir.name,
|
|
170
|
+
message_count=_count_messages(md),
|
|
171
|
+
))
|
|
172
|
+
return infos
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _read_title(path: Path) -> str | None:
|
|
176
|
+
try:
|
|
177
|
+
first = path.read_text(encoding="utf-8").split("\n", 1)[0]
|
|
178
|
+
return first.lstrip("# ").strip() or None
|
|
179
|
+
except Exception:
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _count_messages(path: Path) -> int:
|
|
184
|
+
try:
|
|
185
|
+
text = path.read_text(encoding="utf-8")
|
|
186
|
+
return text.count("### ❯ 用户")
|
|
187
|
+
except Exception:
|
|
188
|
+
return 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def delete_session(session_id: str) -> bool:
|
|
192
|
+
today = _today_dir()
|
|
193
|
+
target = today / f"{session_id}.md"
|
|
194
|
+
if target.exists():
|
|
195
|
+
target.unlink()
|
|
196
|
+
return True
|
|
197
|
+
# 也搜其他日期目录
|
|
198
|
+
proj = _sessions_dir()
|
|
199
|
+
for day_dir in proj.iterdir():
|
|
200
|
+
if not day_dir.is_dir():
|
|
201
|
+
continue
|
|
202
|
+
t = day_dir / f"{session_id}.md"
|
|
203
|
+
if t.exists():
|
|
204
|
+
t.unlink()
|
|
205
|
+
return True
|
|
206
|
+
return False
|
nexforge/theme.tcss
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/* NexForgeCLI 主题 —— 品牌色 #6D5EF5(靛) / #22D3EE(青)。
|
|
2
|
+
*
|
|
3
|
+
* 背景:不做透明(Textual alt-screen 限制),使用终端原生暗色系,
|
|
4
|
+
* 与主流终端(Windows Terminal / iTerm2 / Kitty)暗色主题融为一体。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
Screen {
|
|
8
|
+
background: #101014;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/* 水平容器:主区 + 侧边栏 */
|
|
12
|
+
Horizontal {
|
|
13
|
+
height: 1fr;
|
|
14
|
+
}
|
|
15
|
+
#main-area {
|
|
16
|
+
width: 1fr;
|
|
17
|
+
margin: 0 1 0 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* 顶部品牌横幅 */
|
|
21
|
+
#banner {
|
|
22
|
+
height: auto;
|
|
23
|
+
padding: 1 2 0 2;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* 聊天记录区:占据剩余空间,可滚动 */
|
|
27
|
+
#chat-log {
|
|
28
|
+
height: 1fr;
|
|
29
|
+
padding: 0 2;
|
|
30
|
+
scrollbar-size-vertical: 1;
|
|
31
|
+
scrollbar-color: #6D5EF5 30%;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* 聊天记录区:占据剩余空间,可滚动 */
|
|
35
|
+
#chat-log {
|
|
36
|
+
height: 1fr;
|
|
37
|
+
padding: 0 2;
|
|
38
|
+
scrollbar-size-vertical: 1;
|
|
39
|
+
scrollbar-color: #6D5EF5 30%;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* 用户消息 */
|
|
43
|
+
UserMessage {
|
|
44
|
+
height: auto;
|
|
45
|
+
margin: 1 0 0 0;
|
|
46
|
+
color: $text;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* 助手回合容器 */
|
|
50
|
+
AssistantTurn {
|
|
51
|
+
height: auto;
|
|
52
|
+
margin: 1 0 0 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* 思考面板 */
|
|
56
|
+
AssistantTurn Collapsible {
|
|
57
|
+
border-left: outer #6D5EF5 30%;
|
|
58
|
+
color: $text-muted;
|
|
59
|
+
margin: 0 0 1 0;
|
|
60
|
+
}
|
|
61
|
+
AssistantTurn Collapsible > Contents {
|
|
62
|
+
color: $text-muted;
|
|
63
|
+
max-height: 12;
|
|
64
|
+
overflow-y: auto;
|
|
65
|
+
scrollbar-size-vertical: 1;
|
|
66
|
+
}
|
|
67
|
+
#reasoning-body {
|
|
68
|
+
color: $text-muted;
|
|
69
|
+
text-style: italic;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* 正文 markdown */
|
|
73
|
+
AssistantTurn Markdown {
|
|
74
|
+
margin: 0;
|
|
75
|
+
padding: 0;
|
|
76
|
+
}
|
|
77
|
+
/* 压掉段落/标题的默认下边距,避免句子之间出现大空白 */
|
|
78
|
+
AssistantTurn Markdown MarkdownParagraph {
|
|
79
|
+
margin: 0;
|
|
80
|
+
}
|
|
81
|
+
AssistantTurn Markdown MarkdownH1,
|
|
82
|
+
AssistantTurn Markdown MarkdownH2,
|
|
83
|
+
AssistantTurn Markdown MarkdownH3,
|
|
84
|
+
AssistantTurn Markdown MarkdownH4 {
|
|
85
|
+
margin: 0;
|
|
86
|
+
padding: 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* 工具卡片 */
|
|
90
|
+
ToolCard {
|
|
91
|
+
height: auto;
|
|
92
|
+
margin: 0 0 0 1;
|
|
93
|
+
color: #22D3EE;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/* 系统提示 / 错误 */
|
|
97
|
+
SystemNote {
|
|
98
|
+
height: auto;
|
|
99
|
+
margin: 1 0;
|
|
100
|
+
color: $text-warning;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* 状态栏 */
|
|
104
|
+
#status {
|
|
105
|
+
height: 1;
|
|
106
|
+
padding: 0 2;
|
|
107
|
+
color: $text-muted;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/* 输入框 */
|
|
111
|
+
#prompt {
|
|
112
|
+
dock: bottom;
|
|
113
|
+
margin: 0 0 1 0;
|
|
114
|
+
border: solid #6D5EF5;
|
|
115
|
+
height: auto;
|
|
116
|
+
max-height: 7;
|
|
117
|
+
overflow-y: auto;
|
|
118
|
+
}
|
|
119
|
+
#prompt:focus {
|
|
120
|
+
border: round #22D3EE;
|
|
121
|
+
}
|
|
122
|
+
#prompt-hint {
|
|
123
|
+
height: 1;
|
|
124
|
+
dock: bottom;
|
|
125
|
+
color: $text-muted;
|
|
126
|
+
text-style: dim;
|
|
127
|
+
padding: 0 2;
|
|
128
|
+
}
|
nexforge/tools/_paths.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""工具通用辅助:路径解析与输出截断。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
MAX_OUTPUT_CHARS = 20_000
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def resolve(path: str) -> Path:
|
|
11
|
+
"""相对路径按当前工作目录解析。"""
|
|
12
|
+
p = Path(path).expanduser()
|
|
13
|
+
if not p.is_absolute():
|
|
14
|
+
p = Path.cwd() / p
|
|
15
|
+
return p
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def truncate(text: str, limit: int = MAX_OUTPUT_CHARS) -> str:
|
|
19
|
+
if len(text) <= limit:
|
|
20
|
+
return text
|
|
21
|
+
head = text[:limit]
|
|
22
|
+
return f"{head}\n… [输出被截断,共 {len(text)} 字符,已显示前 {limit}]"
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""后台作业工具:run_background / job_output / wait_for_job / stop_job / list_jobs。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from nexforge.core.jobs import job_manager
|
|
6
|
+
from nexforge.tools._paths import truncate
|
|
7
|
+
from nexforge.tools.base import BaseTool, ToolResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class RunBackgroundTool(BaseTool):
|
|
11
|
+
name = "run_background"
|
|
12
|
+
description = "在后台启动一个长时进程(dev server / 监听 / 下载等),立即返回作业 id,不阻塞。"
|
|
13
|
+
parameters = {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"properties": {"command": {"type": "string", "description": "要执行的命令"}},
|
|
16
|
+
"required": ["command"],
|
|
17
|
+
}
|
|
18
|
+
requires_approval = True
|
|
19
|
+
parallel_safe = False
|
|
20
|
+
|
|
21
|
+
def preview(self, args: dict) -> str:
|
|
22
|
+
return f"run_background($ {args.get('command', '')[:50]})"
|
|
23
|
+
|
|
24
|
+
def run(self, command: str) -> ToolResult:
|
|
25
|
+
try:
|
|
26
|
+
job = job_manager.start(command)
|
|
27
|
+
except Exception as exc:
|
|
28
|
+
return ToolResult(ok=False, error=f"启动失败:{exc}")
|
|
29
|
+
return ToolResult(
|
|
30
|
+
ok=True,
|
|
31
|
+
output=f"已启动后台作业 #{job.id}(pid {job.proc.pid}):{command}",
|
|
32
|
+
summary=f"job #{job.id} started",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class JobOutputTool(BaseTool):
|
|
37
|
+
name = "job_output"
|
|
38
|
+
description = "读取后台作业最近输出。"
|
|
39
|
+
parameters = {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"properties": {
|
|
42
|
+
"job_id": {"type": "integer"},
|
|
43
|
+
"tail_lines": {"type": "integer", "description": "末尾行数,默认 80"},
|
|
44
|
+
},
|
|
45
|
+
"required": ["job_id"],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
def run(self, job_id: int, tail_lines: int = 80) -> ToolResult:
|
|
49
|
+
job = job_manager.get(job_id)
|
|
50
|
+
if job is None:
|
|
51
|
+
return ToolResult(ok=False, error=f"无此作业:#{job_id}")
|
|
52
|
+
status = "运行中" if job.running else f"已退出(code {job.exit_code})"
|
|
53
|
+
body = f"[作业 #{job_id} {status}]\n{job.tail(tail_lines)}"
|
|
54
|
+
return ToolResult(ok=True, output=truncate(body), summary=f"job #{job_id} {status}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class WaitForJobTool(BaseTool):
|
|
58
|
+
name = "wait_for_job"
|
|
59
|
+
description = "阻塞等待后台作业结束(或超时)。返回是否退出 + 最近输出。"
|
|
60
|
+
parameters = {
|
|
61
|
+
"type": "object",
|
|
62
|
+
"properties": {
|
|
63
|
+
"job_id": {"type": "integer"},
|
|
64
|
+
"timeout": {"type": "integer", "description": "最大等待秒数,默认 60"},
|
|
65
|
+
},
|
|
66
|
+
"required": ["job_id"],
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
def run(self, job_id: int, timeout: int = 60) -> ToolResult:
|
|
70
|
+
job = job_manager.wait(job_id, timeout)
|
|
71
|
+
if job is None:
|
|
72
|
+
return ToolResult(ok=False, error=f"无此作业:#{job_id}")
|
|
73
|
+
exited = not job.running
|
|
74
|
+
status = f"已退出(code {job.exit_code})" if exited else "仍在运行(超时)"
|
|
75
|
+
body = f"[作业 #{job_id} {status}]\n{job.tail(40)}"
|
|
76
|
+
return ToolResult(ok=exited, output=truncate(body), summary=f"job #{job_id} {status}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class StopJobTool(BaseTool):
|
|
80
|
+
name = "stop_job"
|
|
81
|
+
description = "停止后台作业(SIGTERM,必要时 SIGKILL)。"
|
|
82
|
+
parameters = {
|
|
83
|
+
"type": "object",
|
|
84
|
+
"properties": {"job_id": {"type": "integer"}},
|
|
85
|
+
"required": ["job_id"],
|
|
86
|
+
}
|
|
87
|
+
requires_approval = True
|
|
88
|
+
parallel_safe = False
|
|
89
|
+
|
|
90
|
+
def run(self, job_id: int) -> ToolResult:
|
|
91
|
+
job = job_manager.stop(job_id)
|
|
92
|
+
if job is None:
|
|
93
|
+
return ToolResult(ok=False, error=f"无此作业:#{job_id}")
|
|
94
|
+
return ToolResult(ok=True, output=f"已停止作业 #{job_id}", summary=f"job #{job_id} stopped")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ListJobsTool(BaseTool):
|
|
98
|
+
name = "list_jobs"
|
|
99
|
+
description = "列出本会话所有后台作业(运行中 + 已退出)。"
|
|
100
|
+
parameters = {"type": "object", "properties": {}}
|
|
101
|
+
|
|
102
|
+
def run(self) -> ToolResult:
|
|
103
|
+
jobs = job_manager.all()
|
|
104
|
+
if not jobs:
|
|
105
|
+
return ToolResult(ok=True, output="(无后台作业)", summary="0 jobs")
|
|
106
|
+
lines = [
|
|
107
|
+
f"#{j.id} {'运行中' if j.running else f'退出{j.exit_code}'} "
|
|
108
|
+
f"pid={j.proc.pid} {j.command[:50]}"
|
|
109
|
+
for j in jobs
|
|
110
|
+
]
|
|
111
|
+
return ToolResult(ok=True, output="\n".join(lines), summary=f"{len(jobs)} jobs")
|
nexforge/tools/base.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""工具基类与结果类型。
|
|
2
|
+
|
|
3
|
+
每个工具声明 name/description/parameters(JSON Schema),实现 run()。
|
|
4
|
+
写类/危险工具置 requires_approval=True,由上层审批门处理(M2 起接 UI)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from abc import ABC, abstractmethod
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ToolResult:
|
|
15
|
+
ok: bool
|
|
16
|
+
output: str = ""
|
|
17
|
+
error: str = ""
|
|
18
|
+
# 供 UI 摘要显示(如受影响文件、行数)
|
|
19
|
+
summary: str = ""
|
|
20
|
+
|
|
21
|
+
def to_content(self) -> str:
|
|
22
|
+
"""转成回灌给模型的 tool 消息内容。"""
|
|
23
|
+
if self.ok:
|
|
24
|
+
return self.output if self.output else "(无输出)"
|
|
25
|
+
return f"ERROR: {self.error}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BaseTool(ABC):
|
|
29
|
+
name: str = ""
|
|
30
|
+
description: str = ""
|
|
31
|
+
parameters: dict = {"type": "object", "properties": {}}
|
|
32
|
+
requires_approval: bool = False
|
|
33
|
+
# 只读工具可并行;写类工具需串行(M2 调度用)
|
|
34
|
+
parallel_safe: bool = True
|
|
35
|
+
|
|
36
|
+
def to_openai(self) -> dict:
|
|
37
|
+
return {
|
|
38
|
+
"type": "function",
|
|
39
|
+
"function": {
|
|
40
|
+
"name": self.name,
|
|
41
|
+
"description": self.description,
|
|
42
|
+
"parameters": self.parameters,
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
def preview(self, args: dict) -> str:
|
|
47
|
+
"""一行参数摘要,用于 UI 的工具卡片(如 read_file(src/x.py:1-40))。"""
|
|
48
|
+
if not args:
|
|
49
|
+
return self.name
|
|
50
|
+
head = ", ".join(f"{k}={v}" for k, v in list(args.items())[:2])
|
|
51
|
+
return f"{self.name}({head})"
|
|
52
|
+
|
|
53
|
+
@abstractmethod
|
|
54
|
+
def run(self, **kwargs) -> ToolResult:
|
|
55
|
+
"""同步执行。Agent 会在线程池中调用,避免阻塞事件循环。"""
|
|
56
|
+
raise NotImplementedError
|