zrcoder 0.2.0__tar.gz → 0.3.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.
- {zrcoder-0.2.0 → zrcoder-0.3.0}/PKG-INFO +13 -4
- {zrcoder-0.2.0 → zrcoder-0.3.0}/README.md +11 -3
- {zrcoder-0.2.0 → zrcoder-0.3.0}/core/agent/core.py +2 -2
- zrcoder-0.3.0/core/agent/hooks.py +147 -0
- zrcoder-0.3.0/core/agent/openai_compat.py +44 -0
- zrcoder-0.3.0/core/agent/tools.py +66 -0
- {zrcoder-0.2.0 → zrcoder-0.3.0}/core/cli.py +16 -5
- zrcoder-0.3.0/core/session.py +91 -0
- {zrcoder-0.2.0 → zrcoder-0.3.0}/core/ui/commands.py +64 -1
- {zrcoder-0.2.0 → zrcoder-0.3.0}/pyproject.toml +2 -1
- {zrcoder-0.2.0 → zrcoder-0.3.0}/pyproject.toml.orig +2 -1
- zrcoder-0.2.0/core/agent/hooks.py +0 -73
- zrcoder-0.2.0/core/agent/tools.py +0 -45
- {zrcoder-0.2.0 → zrcoder-0.3.0}/LICENSE +0 -0
- {zrcoder-0.2.0 → zrcoder-0.3.0}/core/__init__.py +0 -0
- {zrcoder-0.2.0 → zrcoder-0.3.0}/core/agent/__init__.py +0 -0
- {zrcoder-0.2.0 → zrcoder-0.3.0}/core/ui/__init__.py +0 -0
- {zrcoder-0.2.0 → zrcoder-0.3.0}/core/ui/render.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: zrcoder
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Terminal coding agent chat — OpenAI-compatible APIs, tools, and slash commands
|
|
5
5
|
Keywords: cli,agent,coding,terminal,openai
|
|
6
6
|
Author: Zr
|
|
@@ -18,6 +18,7 @@ Classifier: Topic :: Software Development
|
|
|
18
18
|
Requires-Dist: prompt-toolkit>=3.0.53
|
|
19
19
|
Requires-Dist: pydantic-ai>=2.40.0
|
|
20
20
|
Requires-Dist: python-dotenv>=1.2.3
|
|
21
|
+
Requires-Dist: questionary>=2.1.1
|
|
21
22
|
Requires-Dist: rich>=15.0.0
|
|
22
23
|
Requires-Python: >=3.12
|
|
23
24
|
Project-URL: Homepage, https://github.com/ZRMYDYCG/ClaudeCode
|
|
@@ -28,7 +29,11 @@ Description-Content-Type: text/markdown
|
|
|
28
29
|
|
|
29
30
|
# Zrcoder
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
<p align="center">
|
|
33
|
+
<img src="docs/zrcoder-banner.jpg" alt="Zrcoder — AI Terminal CLI" width="100%">
|
|
34
|
+
</p>
|
|
35
|
+
|
|
36
|
+
终端里的编程助手:连 OpenAI 兼容 API,可读写文件、执行命令,并支持斜杠命令与会话恢复。
|
|
32
37
|
|
|
33
38
|
## 安装
|
|
34
39
|
|
|
@@ -48,7 +53,11 @@ pip install zrcoder
|
|
|
48
53
|
|
|
49
54
|
## 配置
|
|
50
55
|
|
|
51
|
-
|
|
56
|
+
设置环境变量,或在以下位置之一创建 `.env`(可参考 `.env.example`):
|
|
57
|
+
|
|
58
|
+
- 当前目录:`./.env`
|
|
59
|
+
- 用户配置:`~/.config/zrcoder/.env`
|
|
60
|
+
- 兼容路径:`~/.zrcoder.env`
|
|
52
61
|
|
|
53
62
|
```bash
|
|
54
63
|
export API_KEY=your_api_key
|
|
@@ -63,7 +72,7 @@ export BASE_URL=https://api.example.com/v1
|
|
|
63
72
|
zrcoder
|
|
64
73
|
```
|
|
65
74
|
|
|
66
|
-
常用命令:`/help`、`/status`、`/new`、`/api-detail`、`/exit`。
|
|
75
|
+
常用命令:`/help`、`/status`、`/new`、`/resume`、`/api-detail`、`/exit`。
|
|
67
76
|
|
|
68
77
|
## 变更与发版
|
|
69
78
|
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# Zrcoder
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
<img src="docs/zrcoder-banner.jpg" alt="Zrcoder — AI Terminal CLI" width="100%">
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
终端里的编程助手:连 OpenAI 兼容 API,可读写文件、执行命令,并支持斜杠命令与会话恢复。
|
|
4
8
|
|
|
5
9
|
## 安装
|
|
6
10
|
|
|
@@ -20,7 +24,11 @@ pip install zrcoder
|
|
|
20
24
|
|
|
21
25
|
## 配置
|
|
22
26
|
|
|
23
|
-
|
|
27
|
+
设置环境变量,或在以下位置之一创建 `.env`(可参考 `.env.example`):
|
|
28
|
+
|
|
29
|
+
- 当前目录:`./.env`
|
|
30
|
+
- 用户配置:`~/.config/zrcoder/.env`
|
|
31
|
+
- 兼容路径:`~/.zrcoder.env`
|
|
24
32
|
|
|
25
33
|
```bash
|
|
26
34
|
export API_KEY=your_api_key
|
|
@@ -35,7 +43,7 @@ export BASE_URL=https://api.example.com/v1
|
|
|
35
43
|
zrcoder
|
|
36
44
|
```
|
|
37
45
|
|
|
38
|
-
常用命令:`/help`、`/status`、`/new`、`/api-detail`、`/exit`。
|
|
46
|
+
常用命令:`/help`、`/status`、`/new`、`/resume`、`/api-detail`、`/exit`。
|
|
39
47
|
|
|
40
48
|
## 变更与发版
|
|
41
49
|
|
|
@@ -8,10 +8,10 @@ from urllib.parse import urlparse, urlunparse
|
|
|
8
8
|
|
|
9
9
|
from dotenv import load_dotenv
|
|
10
10
|
from pydantic_ai import Agent
|
|
11
|
-
from pydantic_ai.models.openai import OpenAIChatModel
|
|
12
11
|
from pydantic_ai.providers.openai import OpenAIProvider
|
|
13
12
|
|
|
14
13
|
from .hooks import hooks
|
|
14
|
+
from .openai_compat import CompatibleOpenAIChatModel
|
|
15
15
|
from .tools import TOOLS
|
|
16
16
|
|
|
17
17
|
load_dotenv()
|
|
@@ -41,7 +41,7 @@ def _normalize_openai_base_url(url: str) -> str:
|
|
|
41
41
|
|
|
42
42
|
MODEL_NAME = os.environ.get("MODEL_NAME", "deepseek-v4-flash")
|
|
43
43
|
|
|
44
|
-
model =
|
|
44
|
+
model = CompatibleOpenAIChatModel(
|
|
45
45
|
MODEL_NAME,
|
|
46
46
|
provider=OpenAIProvider(base_url=_normalize_openai_base_url(BASE_URL), api_key=API_KEY),
|
|
47
47
|
)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
挂在 Agent 上的 hooks:
|
|
3
|
+
1. API 调用元数据记录(/api-detail 命令用)
|
|
4
|
+
2. API 请求失败时的自动重试(wrap_model_request)
|
|
5
|
+
3. 工具执行异常的兜底处理(on_tool_execute_error)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pydantic_ai.capabilities import Hooks
|
|
15
|
+
from pydantic_ai.exceptions import ModelAPIError, ModelHTTPError, UnexpectedModelBehavior
|
|
16
|
+
|
|
17
|
+
from core.ui.render import console
|
|
18
|
+
|
|
19
|
+
MAX_RETRIES = 3
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ApiCall:
|
|
24
|
+
"""
|
|
25
|
+
一次 model API 调用的元数据。before_model_request 创建并填充上半部分,
|
|
26
|
+
after_model_request 填充下半部分。
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
# request 侧
|
|
30
|
+
model: str
|
|
31
|
+
messages_count: int
|
|
32
|
+
# 这次发送给模型的 messages 中最后一条消息的最后一个 part
|
|
33
|
+
last_part: Any
|
|
34
|
+
tools: list[str]
|
|
35
|
+
# response 侧(after hook 填充)
|
|
36
|
+
finish_reason: str = ""
|
|
37
|
+
parts_kinds: list[str] = field(default_factory=list)
|
|
38
|
+
input_tokens: int = 0
|
|
39
|
+
output_tokens: int = 0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# 主循环在每轮 agent.iter() 之前清空它
|
|
43
|
+
api_call_log: list[ApiCall] = []
|
|
44
|
+
|
|
45
|
+
hooks = Hooks()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------- API 调用记录 ----------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@hooks.on.before_model_request
|
|
52
|
+
async def _record_request(ctx: Any, request_context: Any) -> Any:
|
|
53
|
+
"""
|
|
54
|
+
每次发起 model 调用之前,创建一条 ApiCall 记录。
|
|
55
|
+
"""
|
|
56
|
+
msgs = list(request_context.messages)
|
|
57
|
+
last_part = msgs[-1].parts[-1] if msgs and msgs[-1].parts else None
|
|
58
|
+
try:
|
|
59
|
+
tool_names = [t.name for t in request_context.model_request_parameters.function_tools]
|
|
60
|
+
except AttributeError:
|
|
61
|
+
tool_names = []
|
|
62
|
+
api_call_log.append(
|
|
63
|
+
ApiCall(
|
|
64
|
+
model=request_context.model.model_name,
|
|
65
|
+
messages_count=len(msgs),
|
|
66
|
+
last_part=last_part,
|
|
67
|
+
tools=tool_names,
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
return request_context
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@hooks.on.after_model_request
|
|
74
|
+
async def _record_response(ctx: Any, *, request_context: Any, response: Any) -> Any:
|
|
75
|
+
"""
|
|
76
|
+
每次 model 调用返回后,填充上面这条 ApiCall 的 response 字段。
|
|
77
|
+
"""
|
|
78
|
+
if api_call_log:
|
|
79
|
+
call = api_call_log[-1]
|
|
80
|
+
call.finish_reason = str(response.finish_reason) if response.finish_reason else "unknown"
|
|
81
|
+
call.parts_kinds = [p.part_kind for p in response.parts]
|
|
82
|
+
call.input_tokens = response.usage.input_tokens
|
|
83
|
+
call.output_tokens = response.usage.output_tokens
|
|
84
|
+
return response
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------- API 请求重试 ----------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@hooks.on.model_request
|
|
91
|
+
async def _retry_on_error(ctx: Any, *, request_context: Any, handler: Any) -> Any:
|
|
92
|
+
"""
|
|
93
|
+
包裹 model 请求,遇到可重试错误时自动指数退避重试。
|
|
94
|
+
|
|
95
|
+
重试在 wrap 内部完成,对话历史和 before/after hooks 不受影响。
|
|
96
|
+
兼容网关偶发的 UnexpectedModelBehavior(响应 schema 不对)也会重试。
|
|
97
|
+
"""
|
|
98
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
99
|
+
try:
|
|
100
|
+
return await handler(request_context)
|
|
101
|
+
except ModelHTTPError as e:
|
|
102
|
+
if e.status_code < 500:
|
|
103
|
+
raise
|
|
104
|
+
if attempt >= MAX_RETRIES:
|
|
105
|
+
console.print(f"[bold red]✗ HTTP {e.status_code},重试 {MAX_RETRIES} 次后仍失败[/]")
|
|
106
|
+
raise
|
|
107
|
+
wait = 2**attempt
|
|
108
|
+
console.print(
|
|
109
|
+
f"[bold yellow]⟳ HTTP {e.status_code},{wait}s 后重试 "
|
|
110
|
+
f"({attempt + 1}/{MAX_RETRIES})...[/]"
|
|
111
|
+
)
|
|
112
|
+
await asyncio.sleep(wait)
|
|
113
|
+
except UnexpectedModelBehavior as e:
|
|
114
|
+
if attempt >= MAX_RETRIES:
|
|
115
|
+
console.print(f"[bold red]✗ 模型响应异常,重试 {MAX_RETRIES} 次后仍失败:{e}[/]")
|
|
116
|
+
raise
|
|
117
|
+
wait = 2**attempt
|
|
118
|
+
console.print(
|
|
119
|
+
f"[bold yellow]⟳ 模型响应异常,{wait}s 后重试 ({attempt + 1}/{MAX_RETRIES})...[/]"
|
|
120
|
+
)
|
|
121
|
+
await asyncio.sleep(wait)
|
|
122
|
+
except ModelAPIError:
|
|
123
|
+
if attempt >= MAX_RETRIES:
|
|
124
|
+
console.print(f"[bold red]✗ 网络连接失败,重试 {MAX_RETRIES} 次后仍无法连接[/]")
|
|
125
|
+
raise
|
|
126
|
+
wait = 2**attempt
|
|
127
|
+
console.print(
|
|
128
|
+
f"[bold yellow]⟳ 网络连接失败,{wait}s 后重试 ({attempt + 1}/{MAX_RETRIES})...[/]"
|
|
129
|
+
)
|
|
130
|
+
await asyncio.sleep(wait)
|
|
131
|
+
|
|
132
|
+
raise RuntimeError("unreachable") # pragma: no cover
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ---------- 工具执行异常兜底 ----------
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@hooks.on.tool_execute_error
|
|
139
|
+
async def _handle_tool_error(
|
|
140
|
+
ctx: Any, *, call: Any, tool_def: Any, args: Any, error: Exception
|
|
141
|
+
) -> str:
|
|
142
|
+
"""
|
|
143
|
+
工具函数抛出未捕获异常时,不让进程崩溃,
|
|
144
|
+
而是把错误信息作为 tool result 返回给模型,让它自行纠正。
|
|
145
|
+
"""
|
|
146
|
+
console.print(f"[bold red]✗ 工具 {call.tool_name} 出错:{error}[/]")
|
|
147
|
+
return f"工具执行出错:{type(error).__name__}: {error}"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
对 OpenAI 兼容网关做响应清洗:部分服务商偶发返回不规范字段,
|
|
3
|
+
导致 pydantic-ai 校验 ChatCompletion 失败(表现为「好一次、坏一次」)。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from openai.types import chat
|
|
11
|
+
from pydantic_ai.models import openai as openai_model
|
|
12
|
+
from pydantic_ai.models.openai import OpenAIChatModel
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _coerce_chat_completion_payload(data: dict[str, Any]) -> dict[str, Any]:
|
|
16
|
+
"""
|
|
17
|
+
尽量把兼容网关的松散 JSON 修成 OpenAI ChatCompletion 能过校验的形状。
|
|
18
|
+
"""
|
|
19
|
+
if data.get("object") != "chat.completion":
|
|
20
|
+
data["object"] = "chat.completion"
|
|
21
|
+
|
|
22
|
+
choices = data.get("choices")
|
|
23
|
+
if isinstance(choices, list):
|
|
24
|
+
for i, choice in enumerate(choices):
|
|
25
|
+
if not isinstance(choice, dict):
|
|
26
|
+
continue
|
|
27
|
+
index = choice.get("index", i)
|
|
28
|
+
if isinstance(index, str) and index.isdigit():
|
|
29
|
+
choice["index"] = int(index)
|
|
30
|
+
elif not isinstance(index, int):
|
|
31
|
+
choice["index"] = i
|
|
32
|
+
if choice.get("finish_reason") is None:
|
|
33
|
+
choice["finish_reason"] = "stop"
|
|
34
|
+
return data
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CompatibleOpenAIChatModel(OpenAIChatModel):
|
|
38
|
+
"""
|
|
39
|
+
覆盖校验钩子:先 coerce,再走与父类相同的 _ChatCompletion 校验。
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def _validate_completion(self, response: chat.ChatCompletion) -> Any:
|
|
43
|
+
data = _coerce_chat_completion_payload(response.model_dump())
|
|
44
|
+
return openai_model._ChatCompletion.model_validate(data)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Coding Agent 用到的三个工具:读文件、写文件、跑 shell 命令。
|
|
3
|
+
|
|
4
|
+
每个工具自己处理已知错误:能靠换参数纠正的(路径不存在、没权限)抛 ModelRetry
|
|
5
|
+
让模型重试,不能纠正的(二进制文件、命令出错)直接返回错误信息。
|
|
6
|
+
意料之外的异常由 hooks 里的 on_tool_execute_error 统一兜底。
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import subprocess
|
|
10
|
+
|
|
11
|
+
from pydantic_ai.exceptions import ModelRetry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def read_file(path: str) -> str:
|
|
15
|
+
"""
|
|
16
|
+
读取指定文件的内容。
|
|
17
|
+
"""
|
|
18
|
+
try:
|
|
19
|
+
with open(path, encoding="utf-8") as f:
|
|
20
|
+
return f.read()
|
|
21
|
+
except FileNotFoundError as e:
|
|
22
|
+
raise ModelRetry(f"文件 {path} 不存在,请确认路径或换一个文件") from e
|
|
23
|
+
except PermissionError as e:
|
|
24
|
+
raise ModelRetry(f"没有权限读取 {path},请换一个可读的文件") from e
|
|
25
|
+
except IsADirectoryError as e:
|
|
26
|
+
raise ModelRetry(f"{path} 是一个目录,请指定目录下的具体文件") from e
|
|
27
|
+
except UnicodeDecodeError:
|
|
28
|
+
return f"错误:{path} 不是文本文件,无法读取"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def write_file(path: str, content: str) -> str:
|
|
32
|
+
"""
|
|
33
|
+
将内容写入指定文件。
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
37
|
+
f.write(content)
|
|
38
|
+
return f"已写入 {path}"
|
|
39
|
+
except FileNotFoundError as e:
|
|
40
|
+
raise ModelRetry(f"目录不存在,无法写入 {path},请换一个已存在的目录") from e
|
|
41
|
+
except PermissionError as e:
|
|
42
|
+
raise ModelRetry(f"没有权限写入 {path},请换一个可写的路径") from e
|
|
43
|
+
except OSError as e:
|
|
44
|
+
return f"错误:写入 {path} 失败 ({e})"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run_command(command: str) -> str:
|
|
48
|
+
"""
|
|
49
|
+
执行一条 shell 命令并返回输出。
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
result = subprocess.run(
|
|
53
|
+
command, shell=True, capture_output=True, text=True, errors="replace", timeout=10
|
|
54
|
+
)
|
|
55
|
+
output = result.stdout
|
|
56
|
+
if result.returncode != 0:
|
|
57
|
+
output += f"\n[错误] {result.stderr}"
|
|
58
|
+
return output or "(无输出)"
|
|
59
|
+
except subprocess.TimeoutExpired:
|
|
60
|
+
return "[错误] 命令执行超时(10秒)"
|
|
61
|
+
except OSError as e:
|
|
62
|
+
return f"[错误] 无法执行命令 ({e})"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# Pydantic AI 支持 tools=[plain_function],从函数签名 + docstring 自动生成 JSON Schema
|
|
66
|
+
TOOLS = [read_file, write_file, run_command]
|
|
@@ -12,6 +12,7 @@ from pydantic_ai import Agent
|
|
|
12
12
|
from pydantic_graph import End
|
|
13
13
|
|
|
14
14
|
from core.agent import MODEL_NAME, agent, api_call_log
|
|
15
|
+
from core.session import append_messages, new_session_id
|
|
15
16
|
from core.ui.commands import COMMANDS, SessionState, print_divider, print_part
|
|
16
17
|
from core.ui.render import console, print_welcome_banner
|
|
17
18
|
|
|
@@ -55,18 +56,19 @@ def handle_command(user_input: str, state: SessionState) -> CommandAction:
|
|
|
55
56
|
|
|
56
57
|
def apply_result(state: SessionState, result: Any) -> None:
|
|
57
58
|
"""
|
|
58
|
-
跑完一轮 Agent 后,把结果同步到 SessionState
|
|
59
|
+
跑完一轮 Agent 后,把结果同步到 SessionState,并追加写入会话文件。
|
|
59
60
|
"""
|
|
60
61
|
state.history = result.all_messages()
|
|
61
62
|
usage = result.usage
|
|
62
63
|
state.input_tokens += usage.input_tokens
|
|
63
64
|
state.output_tokens += usage.output_tokens
|
|
64
65
|
state.last_api_calls = list(api_call_log)
|
|
66
|
+
append_messages(state.session_id, result.new_messages())
|
|
65
67
|
|
|
66
68
|
|
|
67
69
|
async def run_agent_loop(user_input: str, state: SessionState) -> None:
|
|
68
70
|
"""
|
|
69
|
-
|
|
71
|
+
展开 agent.run_sync(),逐节点驱动 Agent 循环,每步实时打印。
|
|
70
72
|
"""
|
|
71
73
|
api_call_log.clear()
|
|
72
74
|
|
|
@@ -82,7 +84,8 @@ async def run_agent_loop(user_input: str, state: SessionState) -> None:
|
|
|
82
84
|
|
|
83
85
|
elif Agent.is_model_request_node(node):
|
|
84
86
|
for request_part in node.request.parts:
|
|
85
|
-
|
|
87
|
+
kind = getattr(request_part, "part_kind", None)
|
|
88
|
+
if kind in ("tool-return", "retry-prompt"):
|
|
86
89
|
print_part(request_part)
|
|
87
90
|
|
|
88
91
|
apply_result(state, run.result)
|
|
@@ -90,7 +93,10 @@ async def run_agent_loop(user_input: str, state: SessionState) -> None:
|
|
|
90
93
|
|
|
91
94
|
|
|
92
95
|
def main() -> None:
|
|
93
|
-
state = SessionState(
|
|
96
|
+
state = SessionState(
|
|
97
|
+
model_name=MODEL_NAME,
|
|
98
|
+
session_id=new_session_id(),
|
|
99
|
+
)
|
|
94
100
|
print_welcome_banner("Zrcoder")
|
|
95
101
|
|
|
96
102
|
while True:
|
|
@@ -109,7 +115,12 @@ def main() -> None:
|
|
|
109
115
|
continue
|
|
110
116
|
|
|
111
117
|
# 核心 Agent 循环:自己驱动节点流转,实时打印每一步
|
|
112
|
-
|
|
118
|
+
try:
|
|
119
|
+
asyncio.run(run_agent_loop(user_input, state))
|
|
120
|
+
except KeyboardInterrupt:
|
|
121
|
+
console.print("\n[bold yellow]已中断[/]\n")
|
|
122
|
+
except Exception as e:
|
|
123
|
+
console.print(f"\n[bold red]✗ {type(e).__name__}: {e}[/]\n")
|
|
113
124
|
|
|
114
125
|
|
|
115
126
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
会话持久化:把对话历史写成 jsonl 文件,支持扫描和恢复历史会话。
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pydantic_ai.messages import ModelMessagesTypeAdapter
|
|
15
|
+
from pydantic_core import to_jsonable_python
|
|
16
|
+
|
|
17
|
+
# 所有会话记录的根目录
|
|
18
|
+
STORAGE_ROOT = Path.home() / ".zrcoder" / "projects"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def sanitize_path(path: str) -> str:
|
|
22
|
+
"""
|
|
23
|
+
把项目绝对路径转码成合法的目录名:非字母数字字符一律换成 -
|
|
24
|
+
"""
|
|
25
|
+
return re.sub(r"[^a-zA-Z0-9]", "-", path)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def project_dir() -> Path:
|
|
29
|
+
"""
|
|
30
|
+
当前项目(工作目录)对应的会话存储目录。
|
|
31
|
+
"""
|
|
32
|
+
return STORAGE_ROOT / sanitize_path(str(Path.cwd()))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def new_session_id() -> str:
|
|
36
|
+
return str(uuid.uuid4())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def session_file(session_id: str) -> Path:
|
|
40
|
+
return project_dir() / f"{session_id}.jsonl"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def append_messages(session_id: str, messages: Any) -> None:
|
|
44
|
+
"""
|
|
45
|
+
把本轮新增的消息追加到会话文件末尾,一行一条。
|
|
46
|
+
"""
|
|
47
|
+
if not session_id:
|
|
48
|
+
return
|
|
49
|
+
path = session_file(session_id)
|
|
50
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
52
|
+
for msg in messages:
|
|
53
|
+
f.write(json.dumps(to_jsonable_python(msg), ensure_ascii=False) + "\n")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_history(session_id: str) -> list[Any]:
|
|
57
|
+
"""
|
|
58
|
+
读取整个会话文件,把每行 JSON 还原成 SDK 的消息对象列表。
|
|
59
|
+
"""
|
|
60
|
+
lines = session_file(session_id).read_text(encoding="utf-8").splitlines()
|
|
61
|
+
return ModelMessagesTypeAdapter.validate_python([json.loads(line) for line in lines])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def first_prompt(path: Path) -> str:
|
|
65
|
+
"""
|
|
66
|
+
只读文件第一行,提取首条用户输入作为这个会话的摘要。
|
|
67
|
+
"""
|
|
68
|
+
with open(path, encoding="utf-8") as f:
|
|
69
|
+
head = f.readline()
|
|
70
|
+
if not head:
|
|
71
|
+
return "(空会话)"
|
|
72
|
+
msg = json.loads(head)
|
|
73
|
+
for part in msg.get("parts", []):
|
|
74
|
+
if part.get("part_kind") == "user-prompt":
|
|
75
|
+
return str(part.get("content", ""))
|
|
76
|
+
return "(空会话)"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def list_sessions() -> list[tuple[str, datetime, str]]:
|
|
80
|
+
"""
|
|
81
|
+
扫描当前项目的所有会话文件,按修改时间从新到旧返回
|
|
82
|
+
(session_id, 修改时间, 首条用户输入) 列表。
|
|
83
|
+
"""
|
|
84
|
+
if not project_dir().exists():
|
|
85
|
+
return []
|
|
86
|
+
files = sorted(
|
|
87
|
+
project_dir().glob("*.jsonl"),
|
|
88
|
+
key=lambda p: p.stat().st_mtime,
|
|
89
|
+
reverse=True,
|
|
90
|
+
)
|
|
91
|
+
return [(p.stem, datetime.fromtimestamp(p.stat().st_mtime), first_prompt(p)) for p in files]
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
from collections.abc import Callable, Iterable
|
|
2
2
|
from dataclasses import dataclass, field
|
|
3
|
+
from datetime import datetime
|
|
3
4
|
from typing import Any
|
|
4
5
|
|
|
6
|
+
import questionary
|
|
5
7
|
from rich.console import Console, ConsoleOptions, RenderResult
|
|
6
8
|
from rich.markdown import Heading, Markdown
|
|
7
9
|
from rich.markup import escape
|
|
8
10
|
from rich.padding import Padding
|
|
9
11
|
from rich.rule import Rule
|
|
10
12
|
|
|
13
|
+
from core import session
|
|
14
|
+
|
|
11
15
|
from .render import console, print_step
|
|
12
16
|
|
|
13
17
|
|
|
@@ -38,6 +42,8 @@ class SessionState:
|
|
|
38
42
|
model_name: str = ""
|
|
39
43
|
# 最近一轮 user input 触发的所有 model API 调用记录
|
|
40
44
|
last_api_calls: list[Any] = field(default_factory=list)
|
|
45
|
+
# 当前会话 id,对应 ~/.…/projects/<cwd>/<id>.jsonl
|
|
46
|
+
session_id: str = ""
|
|
41
47
|
|
|
42
48
|
|
|
43
49
|
@dataclass
|
|
@@ -157,18 +163,74 @@ def cmd_help(state: SessionState) -> bool:
|
|
|
157
163
|
|
|
158
164
|
def cmd_new(state: SessionState) -> bool:
|
|
159
165
|
"""
|
|
160
|
-
开启新会话:清空历史、token 计数、API
|
|
166
|
+
开启新会话:清空历史、token 计数、API 调用记录,并换一个 session_id。
|
|
161
167
|
"""
|
|
162
168
|
state.history.clear()
|
|
163
169
|
state.input_tokens = 0
|
|
164
170
|
state.output_tokens = 0
|
|
165
171
|
state.last_api_calls.clear()
|
|
172
|
+
state.session_id = session.new_session_id()
|
|
166
173
|
console.print("已开启新会话\n")
|
|
167
174
|
return True
|
|
168
175
|
|
|
169
176
|
|
|
177
|
+
def _summary_line(mtime: datetime, prompt: str) -> str:
|
|
178
|
+
"""
|
|
179
|
+
拼一条会话列表的展示文本:修改时间 + 首条用户输入摘要。
|
|
180
|
+
"""
|
|
181
|
+
prompt = " ".join(str(prompt).split())
|
|
182
|
+
if len(prompt) > 50:
|
|
183
|
+
prompt = prompt[:50] + "..."
|
|
184
|
+
return f"{mtime:%m-%d %H:%M} {prompt}"
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def cmd_resume(state: SessionState) -> bool:
|
|
188
|
+
"""
|
|
189
|
+
列出当前项目的历史会话,选中后恢复对话历史。
|
|
190
|
+
"""
|
|
191
|
+
sessions = session.list_sessions()
|
|
192
|
+
if not sessions:
|
|
193
|
+
console.print("(当前项目还没有历史会话)\n")
|
|
194
|
+
return True
|
|
195
|
+
|
|
196
|
+
choices = [
|
|
197
|
+
questionary.Choice(title=_summary_line(mtime, prompt), value=sid)
|
|
198
|
+
for sid, mtime, prompt in sessions
|
|
199
|
+
]
|
|
200
|
+
selected = questionary.select(
|
|
201
|
+
"选择要恢复的会话(上下键移动,回车确认):",
|
|
202
|
+
choices=choices,
|
|
203
|
+
).ask()
|
|
204
|
+
# 用户按 Ctrl+C 取消选择
|
|
205
|
+
if selected is None:
|
|
206
|
+
return True
|
|
207
|
+
|
|
208
|
+
# 还原对话历史,并把会话 ID 切换成选中的旧会话,后续消息继续追加到同一个文件
|
|
209
|
+
state.history = session.load_history(selected)
|
|
210
|
+
state.session_id = selected
|
|
211
|
+
|
|
212
|
+
# jsonl 里每条模型回复都带 usage,把会话的 token 用量累加回来
|
|
213
|
+
state.input_tokens = sum(
|
|
214
|
+
m.usage.input_tokens for m in state.history if getattr(m, "kind", None) == "response"
|
|
215
|
+
)
|
|
216
|
+
state.output_tokens = sum(
|
|
217
|
+
m.usage.output_tokens for m in state.history if getattr(m, "kind", None) == "response"
|
|
218
|
+
)
|
|
219
|
+
# 最近一轮的 API 调用记录只在进程内有效,没法恢复,清空
|
|
220
|
+
state.last_api_calls.clear()
|
|
221
|
+
|
|
222
|
+
# 把恢复的对话回放到屏幕上
|
|
223
|
+
console.print(f"\n已恢复会话 {selected[:8]},共 {len(state.history)} 条消息:\n")
|
|
224
|
+
for msg in state.history:
|
|
225
|
+
for part in msg.parts:
|
|
226
|
+
print_part(part)
|
|
227
|
+
console.print()
|
|
228
|
+
return True
|
|
229
|
+
|
|
230
|
+
|
|
170
231
|
def cmd_status(state: SessionState) -> bool:
|
|
171
232
|
console.print(f"模型: {state.model_name}")
|
|
233
|
+
console.print(f"会话 ID: {state.session_id or '(无)'}")
|
|
172
234
|
console.print(f"历史消息条数: {len(state.history)}")
|
|
173
235
|
console.print(f"累计输入 tokens:{state.input_tokens}")
|
|
174
236
|
console.print(f"累计输出 tokens:{state.output_tokens}\n")
|
|
@@ -205,6 +267,7 @@ def cmd_api_detail(state: SessionState) -> bool:
|
|
|
205
267
|
|
|
206
268
|
COMMANDS = {
|
|
207
269
|
"new": Command("new", "开启新会话", cmd_new),
|
|
270
|
+
"resume": Command("resume", "恢复历史会话", cmd_resume),
|
|
208
271
|
"status": Command("status", "显示当前会话状态", cmd_status),
|
|
209
272
|
"api-detail": Command("api-detail", "显示最近一轮 model API 调用详情", cmd_api_detail),
|
|
210
273
|
"help": Command("help", "显示可用命令", cmd_help),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "zrcoder"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Terminal coding agent chat — OpenAI-compatible APIs, tools, and slash commands"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -9,6 +9,7 @@ dependencies = [
|
|
|
9
9
|
"prompt-toolkit>=3.0.53",
|
|
10
10
|
"pydantic-ai>=2.40.0",
|
|
11
11
|
"python-dotenv>=1.2.3",
|
|
12
|
+
"questionary>=2.1.1",
|
|
12
13
|
"rich>=15.0.0",
|
|
13
14
|
]
|
|
14
15
|
keywords = [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "zrcoder"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Terminal coding agent chat — OpenAI-compatible APIs, tools, and slash commands"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -12,6 +12,7 @@ dependencies = [
|
|
|
12
12
|
"prompt-toolkit>=3.0.53",
|
|
13
13
|
"pydantic-ai>=2.40.0",
|
|
14
14
|
"python-dotenv>=1.2.3",
|
|
15
|
+
"questionary>=2.1.1",
|
|
15
16
|
"rich>=15.0.0",
|
|
16
17
|
]
|
|
17
18
|
keywords = ["cli", "agent", "coding", "terminal", "openai"]
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
挂在 Agent 上的 hooks,用来抓每次 model API 调用的元数据。
|
|
3
|
-
|
|
4
|
-
主循环在每轮 run_sync 之前清空 api_call_log,跑完后快照到 SessionState 里,
|
|
5
|
-
/api-detail 命令再把这一轮的所有调用展示给用户。
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from dataclasses import dataclass, field
|
|
9
|
-
from typing import Any
|
|
10
|
-
|
|
11
|
-
from pydantic_ai.capabilities import Hooks
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@dataclass
|
|
15
|
-
class ApiCall:
|
|
16
|
-
"""
|
|
17
|
-
一次 model API 调用的元数据。before_model_request 创建并填充上半部分,
|
|
18
|
-
after_model_request 填充下半部分。
|
|
19
|
-
"""
|
|
20
|
-
|
|
21
|
-
# request 侧
|
|
22
|
-
model: str
|
|
23
|
-
messages_count: int
|
|
24
|
-
# 这次发送给模型的 messages 中最后一条消息的最后一个 part
|
|
25
|
-
last_part: Any
|
|
26
|
-
tools: list[str]
|
|
27
|
-
# response 侧(after hook 填充)
|
|
28
|
-
finish_reason: str = ""
|
|
29
|
-
parts_kinds: list[str] = field(default_factory=list)
|
|
30
|
-
input_tokens: int = 0
|
|
31
|
-
output_tokens: int = 0
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
# 主循环在每轮 run_sync 之前清空它
|
|
35
|
-
api_call_log: list[ApiCall] = []
|
|
36
|
-
|
|
37
|
-
hooks = Hooks()
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
@hooks.on.before_model_request
|
|
41
|
-
async def _record_request(ctx: Any, request_context: Any) -> Any:
|
|
42
|
-
"""
|
|
43
|
-
每次发起 model 调用之前,创建一条 ApiCall 记录。
|
|
44
|
-
"""
|
|
45
|
-
msgs = list(request_context.messages)
|
|
46
|
-
last_part = msgs[-1].parts[-1] if msgs and msgs[-1].parts else None
|
|
47
|
-
try:
|
|
48
|
-
tool_names = [t.name for t in request_context.model_request_parameters.function_tools]
|
|
49
|
-
except AttributeError:
|
|
50
|
-
tool_names = []
|
|
51
|
-
api_call_log.append(
|
|
52
|
-
ApiCall(
|
|
53
|
-
model=request_context.model.model_name,
|
|
54
|
-
messages_count=len(msgs),
|
|
55
|
-
last_part=last_part,
|
|
56
|
-
tools=tool_names,
|
|
57
|
-
)
|
|
58
|
-
)
|
|
59
|
-
return request_context
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
@hooks.on.after_model_request
|
|
63
|
-
async def _record_response(ctx: Any, request_context: Any, response: Any) -> Any:
|
|
64
|
-
"""
|
|
65
|
-
每次 model 调用返回后,填充上面这条 ApiCall 的 response 字段。
|
|
66
|
-
"""
|
|
67
|
-
if api_call_log:
|
|
68
|
-
call = api_call_log[-1]
|
|
69
|
-
call.finish_reason = str(response.finish_reason) if response.finish_reason else "unknown"
|
|
70
|
-
call.parts_kinds = [p.part_kind for p in response.parts]
|
|
71
|
-
call.input_tokens = response.usage.input_tokens
|
|
72
|
-
call.output_tokens = response.usage.output_tokens
|
|
73
|
-
return response
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Coding Agent 用到的三个工具:读文件、写文件、跑 shell 命令。
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
import subprocess
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def read_file(path: str) -> str:
|
|
9
|
-
"""
|
|
10
|
-
读取指定文件的内容。
|
|
11
|
-
"""
|
|
12
|
-
try:
|
|
13
|
-
with open(path, encoding="utf-8") as f:
|
|
14
|
-
return f.read()
|
|
15
|
-
except FileNotFoundError:
|
|
16
|
-
return f"错误:文件 {path} 不存在"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def write_file(path: str, content: str) -> str:
|
|
20
|
-
"""
|
|
21
|
-
将内容写入指定文件。
|
|
22
|
-
"""
|
|
23
|
-
with open(path, "w", encoding="utf-8") as f:
|
|
24
|
-
f.write(content)
|
|
25
|
-
return f"已写入 {path}"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def run_command(command: str) -> str:
|
|
29
|
-
"""
|
|
30
|
-
执行一条 shell 命令并返回输出。
|
|
31
|
-
"""
|
|
32
|
-
try:
|
|
33
|
-
result = subprocess.run(
|
|
34
|
-
command, shell=True, capture_output=True, text=True, errors="replace", timeout=10
|
|
35
|
-
)
|
|
36
|
-
output = result.stdout
|
|
37
|
-
if result.returncode != 0:
|
|
38
|
-
output += f"\n[错误] {result.stderr}"
|
|
39
|
-
return output or "(无输出)"
|
|
40
|
-
except subprocess.TimeoutExpired:
|
|
41
|
-
return "[错误] 命令执行超时(10秒)"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
# Pydantic AI 支持 tools=[plain_function],从函数签名 + docstring 自动生成 JSON Schema
|
|
45
|
-
TOOLS = [read_file, write_file, run_command]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|