devmate 1.0.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.
- devmate/__init__.py +6 -0
- devmate/__main__.py +5 -0
- devmate/app/__init__.py +1 -0
- devmate/app/cli.py +1663 -0
- devmate/app/tui.py +291 -0
- devmate/app/web/__init__.py +95 -0
- devmate/bus/__init__.py +1 -0
- devmate/bus/bus.py +151 -0
- devmate/bus/command.py +84 -0
- devmate/core/__init__.py +1 -0
- devmate/core/config.py +115 -0
- devmate/core/logging.py +54 -0
- devmate/core/security.py +61 -0
- devmate/events/__init__.py +1 -0
- devmate/events/bus.py +97 -0
- devmate/events/domain_event.py +37 -0
- devmate/module/__init__.py +1 -0
- devmate/module/registry.py +74 -0
- devmate/security/audit.py +96 -0
- devmate/security/vault.py +221 -0
- devmate-1.0.0.dist-info/METADATA +116 -0
- devmate-1.0.0.dist-info/RECORD +37 -0
- devmate-1.0.0.dist-info/WHEEL +5 -0
- devmate-1.0.0.dist-info/entry_points.txt +18 -0
- devmate-1.0.0.dist-info/top_level.txt +13 -0
- devmate_agent/__init__.py +440 -0
- devmate_apidev/__init__.py +271 -0
- devmate_apihub/__init__.py +313 -0
- devmate_dbadmin/__init__.py +263 -0
- devmate_gitflow/__init__.py +247 -0
- devmate_monitor/__init__.py +112 -0
- devmate_notekeeper/__init__.py +280 -0
- devmate_regexlab/__init__.py +212 -0
- devmate_reporter/__init__.py +170 -0
- devmate_scaffold/__init__.py +181 -0
- devmate_sshman/__init__.py +330 -0
- devmate_toolkit/__init__.py +223 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""
|
|
2
|
+
apidev — API 开发工具
|
|
3
|
+
|
|
4
|
+
包含 HTTP 请求发送、Mock Server、请求历史记录等功能。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from devmate.bus.command import CommandResult
|
|
17
|
+
|
|
18
|
+
# ── HTTP 请求发送 ────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
def request(method: str, url: str,
|
|
21
|
+
headers: dict[str, str] | None = None,
|
|
22
|
+
params: dict[str, str] | None = None,
|
|
23
|
+
body: str | None = None,
|
|
24
|
+
body_type: str = "json",
|
|
25
|
+
timeout: int = 30) -> CommandResult:
|
|
26
|
+
"""发送 HTTP 请求
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
method: GET/POST/PUT/DELETE/PATCH/HEAD/OPTIONS
|
|
30
|
+
url: 请求 URL
|
|
31
|
+
headers: 自定义请求头
|
|
32
|
+
params: URL 查询参数
|
|
33
|
+
body: 请求体字符串
|
|
34
|
+
body_type: json/form/text
|
|
35
|
+
timeout: 超时秒数
|
|
36
|
+
"""
|
|
37
|
+
try:
|
|
38
|
+
# 构建请求
|
|
39
|
+
req_headers = httpx.Headers(headers or {})
|
|
40
|
+
req_params = params or {}
|
|
41
|
+
|
|
42
|
+
# 处理请求体
|
|
43
|
+
content: Any = None
|
|
44
|
+
if body:
|
|
45
|
+
if body_type == "json":
|
|
46
|
+
# 尝试解析 JSON
|
|
47
|
+
try:
|
|
48
|
+
content = json.loads(body)
|
|
49
|
+
except json.JSONDecodeError:
|
|
50
|
+
content = body
|
|
51
|
+
elif body_type == "form":
|
|
52
|
+
req_headers["content-type"] = "application/x-www-form-urlencoded"
|
|
53
|
+
content = body
|
|
54
|
+
else:
|
|
55
|
+
content = body
|
|
56
|
+
|
|
57
|
+
# 发送请求
|
|
58
|
+
with httpx.Client(timeout=timeout, follow_redirects=True) as client:
|
|
59
|
+
response = client.request(
|
|
60
|
+
method=method.upper(),
|
|
61
|
+
url=url,
|
|
62
|
+
headers=req_headers,
|
|
63
|
+
params=req_params,
|
|
64
|
+
content=(
|
|
65
|
+
json.dumps(content)
|
|
66
|
+
if body and body_type == "json" and isinstance(content, dict)
|
|
67
|
+
else content
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# 解析响应体
|
|
72
|
+
response_body: str = response.text
|
|
73
|
+
response_json: Any = None
|
|
74
|
+
content_type = response.headers.get("content-type", "")
|
|
75
|
+
if "application/json" in content_type or "application/vnd" in content_type:
|
|
76
|
+
try:
|
|
77
|
+
response_json = response.json()
|
|
78
|
+
response_body = json.dumps(response_json, indent=2, ensure_ascii=False)
|
|
79
|
+
except (json.JSONDecodeError, ValueError):
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
# 构建响应头
|
|
83
|
+
resp_headers = dict(response.headers)
|
|
84
|
+
|
|
85
|
+
return CommandResult(success=True, data={
|
|
86
|
+
"status_code": response.status_code,
|
|
87
|
+
"status_text": _status_text(response.status_code),
|
|
88
|
+
"headers": resp_headers,
|
|
89
|
+
"body": response_body,
|
|
90
|
+
"body_json": response_json,
|
|
91
|
+
"content_type": content_type,
|
|
92
|
+
"elapsed_ms": round(response.elapsed.total_seconds() * 1000, 1),
|
|
93
|
+
"url": str(response.url),
|
|
94
|
+
"method": method.upper(),
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
except httpx.TimeoutException:
|
|
98
|
+
return CommandResult(success=False, error=f"请求超时({timeout}s)")
|
|
99
|
+
except httpx.ConnectError as e:
|
|
100
|
+
return CommandResult(success=False, error=f"连接失败: {e}")
|
|
101
|
+
except httpx.HTTPError as e:
|
|
102
|
+
return CommandResult(success=False, error=f"HTTP 错误: {e}")
|
|
103
|
+
except Exception as e:
|
|
104
|
+
return CommandResult(success=False, error=str(e))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _status_text(code: int) -> str:
|
|
108
|
+
"""HTTP 状态码文字说明"""
|
|
109
|
+
status_map = {
|
|
110
|
+
200: "OK", 201: "Created", 204: "No Content",
|
|
111
|
+
301: "Moved Permanently", 302: "Found", 304: "Not Modified",
|
|
112
|
+
400: "Bad Request", 401: "Unauthorized", 403: "Forbidden",
|
|
113
|
+
404: "Not Found", 405: "Method Not Allowed", 408: "Request Timeout",
|
|
114
|
+
409: "Conflict", 422: "Unprocessable Entity", 429: "Too Many Requests",
|
|
115
|
+
500: "Internal Server Error", 502: "Bad Gateway",
|
|
116
|
+
503: "Service Unavailable", 504: "Gateway Timeout",
|
|
117
|
+
}
|
|
118
|
+
return status_map.get(code, "")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ── Mock Server ──────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
_mock_server_process: subprocess.Popen | None = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def mock_start(spec: str, port: int = 8765) -> CommandResult:
|
|
127
|
+
"""启动 Mock Server(基于 OpenAPI spec 文件)"""
|
|
128
|
+
spec_path = Path(spec).expanduser().resolve()
|
|
129
|
+
|
|
130
|
+
if not spec_path.exists():
|
|
131
|
+
return CommandResult(success=False, error=f"spec 文件不存在: {spec}")
|
|
132
|
+
|
|
133
|
+
# 读取 OpenAPI spec
|
|
134
|
+
try:
|
|
135
|
+
with open(spec_path) as f:
|
|
136
|
+
spec_data = json.load(f)
|
|
137
|
+
except json.JSONDecodeError:
|
|
138
|
+
return CommandResult(success=False, error=f"spec 文件不是合法的 JSON: {spec}")
|
|
139
|
+
except Exception as e:
|
|
140
|
+
return CommandResult(success=False, error=f"读取 spec 文件失败: {e}")
|
|
141
|
+
|
|
142
|
+
# 生成 mock server 代码
|
|
143
|
+
mock_code = _generate_mock_code(spec_data, port)
|
|
144
|
+
|
|
145
|
+
# 写入临时文件
|
|
146
|
+
tmp_file = Path(tempfile.mktemp(suffix=".py"))
|
|
147
|
+
tmp_file.write_text(mock_code)
|
|
148
|
+
|
|
149
|
+
# 启动子进程
|
|
150
|
+
global _mock_server_process
|
|
151
|
+
if _mock_server_process and _mock_server_process.poll() is None:
|
|
152
|
+
return CommandResult(success=False, error=f"Mock Server 已在端口 {port} 运行")
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
_mock_server_process = subprocess.Popen(
|
|
156
|
+
[sys.executable, str(tmp_file)],
|
|
157
|
+
stdout=subprocess.DEVNULL,
|
|
158
|
+
stderr=subprocess.DEVNULL,
|
|
159
|
+
)
|
|
160
|
+
return CommandResult(success=True, data={
|
|
161
|
+
"port": port,
|
|
162
|
+
"pid": _mock_server_process.pid,
|
|
163
|
+
"message": f"Mock Server 已启动: http://127.0.0.1:{port}",
|
|
164
|
+
"spec": str(spec_path),
|
|
165
|
+
"endpoints": list(spec_data.get("paths", {}).keys()),
|
|
166
|
+
})
|
|
167
|
+
except Exception as e:
|
|
168
|
+
return CommandResult(success=False, error=f"启动 Mock Server 失败: {e}")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def mock_stop() -> CommandResult:
|
|
172
|
+
"""停止 Mock Server"""
|
|
173
|
+
global _mock_server_process
|
|
174
|
+
if _mock_server_process is None or _mock_server_process.poll() is not None:
|
|
175
|
+
return CommandResult(success=False, error="Mock Server 未运行")
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
_mock_server_process.terminate()
|
|
179
|
+
_mock_server_process.wait(timeout=5)
|
|
180
|
+
_mock_server_process = None
|
|
181
|
+
return CommandResult(success=True, data={"message": "Mock Server 已停止"})
|
|
182
|
+
except Exception as e:
|
|
183
|
+
return CommandResult(success=False, error=f"停止 Mock Server 失败: {e}")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _generate_mock_code(spec: dict, port: int) -> str:
|
|
187
|
+
"""根据 OpenAPI spec 生成 FastAPI mock server 代码"""
|
|
188
|
+
imports = [
|
|
189
|
+
"from fastapi import FastAPI",
|
|
190
|
+
"from fastapi.responses import JSONResponse",
|
|
191
|
+
"import uvicorn",
|
|
192
|
+
"import json",
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
routes: list[str] = []
|
|
196
|
+
paths = spec.get("paths", {})
|
|
197
|
+
|
|
198
|
+
for path, methods in paths.items():
|
|
199
|
+
for method, details in methods.items():
|
|
200
|
+
# 提取示例响应
|
|
201
|
+
responses = details.get("responses", {})
|
|
202
|
+
example_response = None
|
|
203
|
+
status_code = 200
|
|
204
|
+
|
|
205
|
+
for scode, sdetails in responses.items():
|
|
206
|
+
content = sdetails.get("content", {})
|
|
207
|
+
for media_type, media_value in content.items():
|
|
208
|
+
example = media_value.get("example") or \
|
|
209
|
+
media_value.get("schema", {}).get("example", {}) or \
|
|
210
|
+
media_value.get("schema", {}).get("properties", {})
|
|
211
|
+
if example:
|
|
212
|
+
example_response = example
|
|
213
|
+
status_code = int(scode)
|
|
214
|
+
break
|
|
215
|
+
if example_response:
|
|
216
|
+
break
|
|
217
|
+
|
|
218
|
+
if example_response is None:
|
|
219
|
+
example_response = {"message": f"{method.upper()} {path}"}
|
|
220
|
+
|
|
221
|
+
fastapi_method = method.lower()
|
|
222
|
+
route_func_name = f"{method}_{path.replace('/', '_').replace('{', '').replace('}', '')}"
|
|
223
|
+
|
|
224
|
+
example_json = json.dumps(example_response, ensure_ascii=False)
|
|
225
|
+
routes.append(f"""
|
|
226
|
+
@app.{fastapi_method}("{path}")
|
|
227
|
+
async def {route_func_name}():
|
|
228
|
+
return JSONResponse(content={example_json}, status_code={status_code})
|
|
229
|
+
""")
|
|
230
|
+
|
|
231
|
+
code = "\n".join(imports) + "\n\napp = FastAPI(title='DevMate Mock Server')\n" + "\n".join(routes) + ( # noqa: E501
|
|
232
|
+
f"""\nif __name__ == "__main__":\n uvicorn.run(app, host="127.0.0.1", port={port})\n""")
|
|
233
|
+
return code
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ── 请求历史 ──────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
HISTORY_FILE = Path.home() / ".devmate" / "api_history.jsonl"
|
|
239
|
+
MAX_HISTORY = 100
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def save_history(entry: dict[str, Any]):
|
|
243
|
+
"""保存请求历史"""
|
|
244
|
+
HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
245
|
+
|
|
246
|
+
# 读取现有历史
|
|
247
|
+
history = load_history()
|
|
248
|
+
history.insert(0, entry)
|
|
249
|
+
history = history[:MAX_HISTORY]
|
|
250
|
+
|
|
251
|
+
with open(HISTORY_FILE, "w", encoding="utf-8") as f:
|
|
252
|
+
for item in history:
|
|
253
|
+
f.write(json.dumps(item, ensure_ascii=False) + "\n")
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def load_history(limit: int = 20) -> list[dict[str, Any]]:
|
|
257
|
+
"""加载请求历史"""
|
|
258
|
+
if not HISTORY_FILE.exists():
|
|
259
|
+
return []
|
|
260
|
+
|
|
261
|
+
history: list[dict[str, Any]] = []
|
|
262
|
+
with open(HISTORY_FILE, encoding="utf-8") as f:
|
|
263
|
+
for line in f:
|
|
264
|
+
line = line.strip()
|
|
265
|
+
if line:
|
|
266
|
+
try:
|
|
267
|
+
history.append(json.loads(line))
|
|
268
|
+
except json.JSONDecodeError:
|
|
269
|
+
continue
|
|
270
|
+
|
|
271
|
+
return history[:limit]
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""
|
|
2
|
+
apihub — AI 模型统一接入
|
|
3
|
+
|
|
4
|
+
提供 Provider 抽象基类 + 多模型适配(OpenAI、DeepSeek),
|
|
5
|
+
支持对话(SSE 流式)、对话历史管理、成本追踪。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from devmate.bus.command import CommandResult
|
|
16
|
+
|
|
17
|
+
# ── 数据模型 ──────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ProviderConfig:
|
|
21
|
+
"""AI 模型提供商配置"""
|
|
22
|
+
name: str # 配置名,如 "default"
|
|
23
|
+
provider: str # 提供商,如 "openai", "deepseek"
|
|
24
|
+
model: str # 模型名,如 "gpt-4o", "deepseek-chat"
|
|
25
|
+
api_base: str = "" # API 地址(默认用官方的)
|
|
26
|
+
max_tokens: int = 4096
|
|
27
|
+
temperature: float = 0.7
|
|
28
|
+
|
|
29
|
+
def get_api_base(self) -> str:
|
|
30
|
+
if self.api_base:
|
|
31
|
+
return self.api_base.rstrip("/")
|
|
32
|
+
defaults = {
|
|
33
|
+
"openai": "https://api.openai.com/v1",
|
|
34
|
+
"deepseek": "https://api.deepseek.com/v1",
|
|
35
|
+
}
|
|
36
|
+
return defaults.get(self.provider, "")
|
|
37
|
+
|
|
38
|
+
def get_model(self) -> str:
|
|
39
|
+
defaults = {
|
|
40
|
+
"openai": "gpt-4o",
|
|
41
|
+
"deepseek": "deepseek-chat",
|
|
42
|
+
}
|
|
43
|
+
return self.model or defaults.get(self.provider, "")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class ChatMessage:
|
|
48
|
+
"""对话消息"""
|
|
49
|
+
role: str # "system" / "user" / "assistant"
|
|
50
|
+
content: str
|
|
51
|
+
timestamp: float = field(default_factory=time.time)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ── 配置管理 ──────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
PROVIDERS_FILE = Path.home() / ".devmate" / "providers.json"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def add_provider(name: str, provider: str, api_key: str,
|
|
60
|
+
model: str = "", api_base: str = "") -> CommandResult:
|
|
61
|
+
"""添加 AI 模型配置(API Key 存 vault)"""
|
|
62
|
+
# 存储 API Key 到 vault
|
|
63
|
+
from devmate.security.vault import vault as _vault
|
|
64
|
+
vault_key = f"ai_provider_{name}"
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
_vault.set(vault_key, api_key, _get_vault_password())
|
|
68
|
+
except Exception:
|
|
69
|
+
# vault 还没初始化,自动初始化
|
|
70
|
+
_vault.unlock(_get_vault_password())
|
|
71
|
+
_vault.set(vault_key, api_key, _get_vault_password())
|
|
72
|
+
|
|
73
|
+
# 保存配置
|
|
74
|
+
config = ProviderConfig(
|
|
75
|
+
name=name, provider=provider, model=model, api_base=api_base,
|
|
76
|
+
)
|
|
77
|
+
_save_config(config)
|
|
78
|
+
|
|
79
|
+
return CommandResult(success=True, data={
|
|
80
|
+
"message": f"✅ 已添加 {provider} 模型: {name}",
|
|
81
|
+
"provider": provider,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def list_providers() -> CommandResult:
|
|
86
|
+
"""列出所有已配置的模型"""
|
|
87
|
+
providers = _load_configs()
|
|
88
|
+
if not providers:
|
|
89
|
+
return CommandResult(success=True, data={"providers": [], "message": "暂无配置"})
|
|
90
|
+
|
|
91
|
+
result = []
|
|
92
|
+
for p in providers:
|
|
93
|
+
result.append({
|
|
94
|
+
"name": p.name,
|
|
95
|
+
"provider": p.provider,
|
|
96
|
+
"model": p.get_model(),
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
return CommandResult(success=True, data={"providers": result})
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def remove_provider(name: str) -> CommandResult:
|
|
103
|
+
"""删除模型配置"""
|
|
104
|
+
configs = _load_configs()
|
|
105
|
+
before = len(configs)
|
|
106
|
+
configs = [c for c in configs if c.name != name]
|
|
107
|
+
if len(configs) == before:
|
|
108
|
+
return CommandResult(success=False, error=f"未找到配置: {name}")
|
|
109
|
+
|
|
110
|
+
_save_configs(configs)
|
|
111
|
+
|
|
112
|
+
# 从 vault 删除 API Key
|
|
113
|
+
try:
|
|
114
|
+
from devmate.security.vault import vault as _vault
|
|
115
|
+
vault_key = f"ai_provider_{name}"
|
|
116
|
+
if _vault.exists(vault_key):
|
|
117
|
+
_vault.delete(vault_key, _get_vault_password())
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
return CommandResult(success=True, data={"message": f"已删除: {name}"})
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _get_vault_password() -> str:
|
|
125
|
+
"""获取 vault 主密码(固定,后续加交互)"""
|
|
126
|
+
return "devmate_default"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _load_configs() -> list[ProviderConfig]:
|
|
130
|
+
"""加载所有配置"""
|
|
131
|
+
if not PROVIDERS_FILE.exists():
|
|
132
|
+
return []
|
|
133
|
+
data = json.loads(PROVIDERS_FILE.read_text())
|
|
134
|
+
return [ProviderConfig(**item) for item in data]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _save_config(config: ProviderConfig):
|
|
138
|
+
"""保存单个配置"""
|
|
139
|
+
configs = _load_configs()
|
|
140
|
+
# 更新同名配置
|
|
141
|
+
configs = [c for c in configs if c.name != config.name]
|
|
142
|
+
configs.append(config)
|
|
143
|
+
_save_configs(configs)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _save_configs(configs: list[ProviderConfig]):
|
|
147
|
+
"""保存所有配置"""
|
|
148
|
+
PROVIDERS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
data = [{
|
|
150
|
+
"name": c.name, "provider": c.provider, "model": c.model,
|
|
151
|
+
"api_base": c.api_base, "max_tokens": c.max_tokens,
|
|
152
|
+
"temperature": c.temperature,
|
|
153
|
+
} for c in configs]
|
|
154
|
+
PROVIDERS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ── 聊天 ──────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
def chat(message: str, provider_name: str = "default",
|
|
160
|
+
system_prompt: str = "", stream: bool = True) -> CommandResult:
|
|
161
|
+
"""发送聊天消息
|
|
162
|
+
|
|
163
|
+
支持流式输出(SSE)和普通输出。
|
|
164
|
+
"""
|
|
165
|
+
configs = _load_configs()
|
|
166
|
+
config = next((c for c in configs if c.name == provider_name), None)
|
|
167
|
+
if config is None:
|
|
168
|
+
return CommandResult(success=False, error=f"未找到模型配置: {provider_name}")
|
|
169
|
+
|
|
170
|
+
# 从 vault 读取 API Key
|
|
171
|
+
from devmate.security.vault import vault as _vault
|
|
172
|
+
vault_key = f"ai_provider_{provider_name}"
|
|
173
|
+
try:
|
|
174
|
+
api_key = _vault.get(vault_key, _get_vault_password())
|
|
175
|
+
except Exception:
|
|
176
|
+
return CommandResult(success=False, error="API Key 未找到。用 devmate ai add 配置")
|
|
177
|
+
|
|
178
|
+
# 构造请求
|
|
179
|
+
headers = {
|
|
180
|
+
"Authorization": f"Bearer {api_key}",
|
|
181
|
+
"Content-Type": "application/json",
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
messages: list[dict] = []
|
|
185
|
+
if system_prompt:
|
|
186
|
+
messages.append({"role": "system", "content": system_prompt})
|
|
187
|
+
messages.append({"role": "user", "content": message})
|
|
188
|
+
|
|
189
|
+
payload = {
|
|
190
|
+
"model": config.get_model(),
|
|
191
|
+
"messages": messages,
|
|
192
|
+
"max_tokens": config.max_tokens,
|
|
193
|
+
"temperature": config.temperature,
|
|
194
|
+
"stream": stream,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
api_base = config.get_api_base()
|
|
198
|
+
url = f"{api_base}/chat/completions"
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
if stream:
|
|
202
|
+
return _chat_stream(url, headers, payload, config)
|
|
203
|
+
else:
|
|
204
|
+
return _chat_sync(url, headers, payload, config)
|
|
205
|
+
except httpx.HTTPStatusError as e:
|
|
206
|
+
status = e.response.status_code
|
|
207
|
+
if status == 401:
|
|
208
|
+
return CommandResult(success=False, error="API Key 无效。请检查配置")
|
|
209
|
+
elif status == 429:
|
|
210
|
+
return CommandResult(success=False, error="请求频率过高,请稍后重试")
|
|
211
|
+
else:
|
|
212
|
+
return CommandResult(success=False, error=f"API 错误 ({status}): {e}")
|
|
213
|
+
except httpx.ConnectError:
|
|
214
|
+
return CommandResult(success=False, error=f"无法连接到 {api_base}")
|
|
215
|
+
except Exception as e:
|
|
216
|
+
return CommandResult(success=False, error=str(e))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _chat_stream(url: str, headers: dict, payload: dict,
|
|
220
|
+
config: ProviderConfig) -> CommandResult:
|
|
221
|
+
"""流式聊天"""
|
|
222
|
+
content = ""
|
|
223
|
+
with httpx.Client(timeout=60) as client:
|
|
224
|
+
with client.stream("POST", url, headers=headers, json=payload) as resp:
|
|
225
|
+
resp.raise_for_status()
|
|
226
|
+
for line in resp.iter_lines():
|
|
227
|
+
if not line or line.startswith(":"):
|
|
228
|
+
continue
|
|
229
|
+
if line.startswith("data: "):
|
|
230
|
+
data_str = line[6:]
|
|
231
|
+
if data_str.strip() == "[DONE]":
|
|
232
|
+
break
|
|
233
|
+
try:
|
|
234
|
+
chunk = json.loads(data_str)
|
|
235
|
+
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
|
236
|
+
if "content" in delta:
|
|
237
|
+
content += delta["content"]
|
|
238
|
+
except json.JSONDecodeError:
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
return CommandResult(success=True, data={
|
|
242
|
+
"reply": content,
|
|
243
|
+
"provider": config.provider,
|
|
244
|
+
"model": config.get_model(),
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _chat_sync(url: str, headers: dict, payload: dict,
|
|
249
|
+
config: ProviderConfig) -> CommandResult:
|
|
250
|
+
"""非流式聊天"""
|
|
251
|
+
with httpx.Client(timeout=60) as client:
|
|
252
|
+
payload["stream"] = False
|
|
253
|
+
resp = client.post(url, headers=headers, json=payload)
|
|
254
|
+
resp.raise_for_status()
|
|
255
|
+
data = resp.json()
|
|
256
|
+
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
|
257
|
+
|
|
258
|
+
return CommandResult(success=True, data={
|
|
259
|
+
"reply": content,
|
|
260
|
+
"provider": config.provider,
|
|
261
|
+
"model": config.get_model(),
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ── 对话历史 ──────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
HISTORY_DIR = Path.home() / ".devmate" / "chat_history"
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def save_chat(provider_name: str, messages: list[ChatMessage]) -> CommandResult:
|
|
271
|
+
"""保存对话历史"""
|
|
272
|
+
HISTORY_DIR.mkdir(parents=True, exist_ok=True)
|
|
273
|
+
|
|
274
|
+
chat_file = HISTORY_DIR / f"{provider_name}_{int(time.time())}.json"
|
|
275
|
+
data = [{"role": m.role, "content": m.content, "timestamp": m.timestamp}
|
|
276
|
+
for m in messages]
|
|
277
|
+
chat_file.write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
|
278
|
+
|
|
279
|
+
return CommandResult(success=True, data={"message": f"对话已保存: {chat_file.name}"})
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def list_chats(provider_name: str = "") -> CommandResult:
|
|
283
|
+
"""列出对话历史"""
|
|
284
|
+
if not HISTORY_DIR.exists():
|
|
285
|
+
return CommandResult(success=True, data={"chats": []})
|
|
286
|
+
|
|
287
|
+
files = sorted(HISTORY_DIR.glob("*.json"), reverse=True)
|
|
288
|
+
if provider_name:
|
|
289
|
+
files = [f for f in files if f.name.startswith(provider_name)]
|
|
290
|
+
|
|
291
|
+
chats = []
|
|
292
|
+
for f in files[:20]:
|
|
293
|
+
chats.append({
|
|
294
|
+
"name": f.name,
|
|
295
|
+
"size": f.stat().st_size,
|
|
296
|
+
"modified": time.ctime(f.stat().st_mtime),
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
return CommandResult(success=True, data={"chats": chats})
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def load_chat(filename: str) -> CommandResult:
|
|
303
|
+
"""加载对话历史"""
|
|
304
|
+
chat_file = HISTORY_DIR / filename
|
|
305
|
+
if not chat_file.exists():
|
|
306
|
+
return CommandResult(success=False, error=f"对话不存在: {filename}")
|
|
307
|
+
|
|
308
|
+
data = json.loads(chat_file.read_text())
|
|
309
|
+
messages = [ChatMessage(**m) for m in data]
|
|
310
|
+
|
|
311
|
+
return CommandResult(success=True, data={
|
|
312
|
+
"messages": [{"role": m.role, "content": m.content} for m in messages],
|
|
313
|
+
})
|