codebee 0.1.3 → 0.1.5
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.
- package/CHANGELOG.md +36 -0
- package/README.md +9 -0
- package/app/core/attachments.py +40 -0
- package/app/core/bookmeta.py +157 -52
- package/app/core/bookmeta_catalog.py +67 -90
- package/app/core/builtin_agent.py +564 -0
- package/app/core/flows.py +328 -328
- package/app/core/gitmod.py +82 -8
- package/app/core/history.py +8 -2
- package/app/core/jobs.py +448 -424
- package/app/core/manager.py +151 -1
- package/app/core/modelhub.py +86 -4
- package/app/core/pipeline.py +2326 -2164
- package/app/core/router.py +8 -3
- package/app/core/runner.py +185 -21
- package/app/core/selfupdate.py +54 -11
- package/app/core/store.py +4 -2
- package/app/main.py +117 -12
- package/app/ui/app.js +279 -47
- package/app/ui/i18n.js +21 -5
- package/app/ui/index.html +6 -2
- package/app/ui/style.css +3379 -2764
- package/package.json +2 -1
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""内置执行智能体:不经外部 CLI,直连供应商 API 完成直连(direct)任务。
|
|
3
|
+
|
|
4
|
+
为什么:direct 是快档位,典型诉求是问答/轻量改写。拉起 codex/claude CLI 要付
|
|
5
|
+
进程与框架开销,且 CLI 的事件流日志(启动命令、提示词回显、重连报错)会污染
|
|
6
|
+
对话视图。内置智能体直接打 chat API + 工具循环:
|
|
7
|
+
- 模型选择:编排者配置(resolve_orchestrator)优先;未启用时扫首个可用供应商;
|
|
8
|
+
- 工具:list_files / read_file / write_file,全部锁死在工作目录内;
|
|
9
|
+
- 协议:anthropic / openai 走原生工具循环;google 无工具协议(纯文本直答)。
|
|
10
|
+
密钥冷却/多 KEY 展开复用 modelhub 既有记账(note_key_ok / note_key_error)。
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import io
|
|
16
|
+
import json
|
|
17
|
+
import mimetypes
|
|
18
|
+
import os
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from . import modelhub, runner
|
|
22
|
+
|
|
23
|
+
MAX_TOOL_ITERS = 16 # 单步工具循环上限(防模型打转)
|
|
24
|
+
READ_MAX_BYTES = 64 * 1024 # read_file 单次读取上限
|
|
25
|
+
LIST_MAX_ENTRIES = 200
|
|
26
|
+
MAX_IMAGES = 8 # 单次最多随消息传几张图(请求体防爆)
|
|
27
|
+
IMAGE_MAX_EDGE = 1568 # 长边上限(视觉模型通行建议值,超出等比缩)
|
|
28
|
+
IMAGE_JPEG_BYTES = 3500 * 1024 # 缩放后仍超此大小 → 转 JPEG q85
|
|
29
|
+
IMAGE_MAX_BYTES = 5 * 1024 * 1024 # 单张最终字节硬上限(超出剔除并日志)
|
|
30
|
+
|
|
31
|
+
_SYSTEM_PROMPT = """你是 CodeBee 的内置执行智能体,直接完成用户交代的任务。用户的目标、背景与工作目录内的附件就是全部输入。
|
|
32
|
+
|
|
33
|
+
## 工作方式
|
|
34
|
+
- 需要读文件、看目录、写文件时调用工具;所有路径都是工作目录内的相对路径。
|
|
35
|
+
- read_file 只能读文本文件;图片、压缩包等二进制文件读不了,如实告知用户即可,不要反复尝试。
|
|
36
|
+
- 用户消息中的图片附件会直接出现在对话里,可直接看图作答,无需用工具读取。
|
|
37
|
+
- 产出文件一律 UTF-8 编码。
|
|
38
|
+
- 回答用户的语言与用户一致(默认中文)。直接给结论和内容,不要输出任何机器标记或协议行。"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolve():
|
|
42
|
+
"""选内置智能体可用的 (provider, model):编排者配置优先,否则首个可用供应商。
|
|
43
|
+
|
|
44
|
+
返回 {prov, model, provider_id, provider_name} 或 None(无可用供应商 → 调用方
|
|
45
|
+
回退 CLI 路径)。"""
|
|
46
|
+
orch = None
|
|
47
|
+
try:
|
|
48
|
+
orch = modelhub.resolve_orchestrator()
|
|
49
|
+
except Exception:
|
|
50
|
+
orch = None
|
|
51
|
+
if orch:
|
|
52
|
+
prov, model = orch
|
|
53
|
+
return {"prov": prov, "model": model,
|
|
54
|
+
"provider_id": prov.get("id") or "",
|
|
55
|
+
"provider_name": prov.get("name") or prov.get("id") or ""}
|
|
56
|
+
for p in modelhub.providers():
|
|
57
|
+
if not p.get("enabled", True) or not p.get("api_key"):
|
|
58
|
+
continue
|
|
59
|
+
m = (p.get("model") or "").strip()
|
|
60
|
+
if not m:
|
|
61
|
+
try:
|
|
62
|
+
names = modelhub._enabled_models(p)
|
|
63
|
+
except Exception:
|
|
64
|
+
names = []
|
|
65
|
+
m = names[0]["name"] if names else ""
|
|
66
|
+
if m:
|
|
67
|
+
return {"prov": p, "model": m, "provider_id": p.get("id") or "",
|
|
68
|
+
"provider_name": p.get("name") or p.get("id") or ""}
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------- 工作目录工具
|
|
73
|
+
# 模型给的相对路径不可信:逐段拒绝 .. 与盘符,resolve() 归一后确认仍位于
|
|
74
|
+
# 工作目录之下(base not in p.parents 即越界,同 skills/market 的守卫惯用法)。
|
|
75
|
+
|
|
76
|
+
def _tool_list_files(workdir, args):
|
|
77
|
+
rel = str(args.get("path") or "").replace("\\", "/").strip("/")
|
|
78
|
+
if ".." in Path(rel).parts or any(":" in seg for seg in Path(rel).parts):
|
|
79
|
+
return "(非法路径: %s)" % rel
|
|
80
|
+
base = Path(workdir or ".").resolve()
|
|
81
|
+
p = (base / rel).resolve() if rel else base
|
|
82
|
+
if base not in p.parents and p != base:
|
|
83
|
+
return "(路径越界: %s)" % rel
|
|
84
|
+
if not p.is_dir():
|
|
85
|
+
return "(目录不存在: %s)" % (rel or ".")
|
|
86
|
+
out = []
|
|
87
|
+
for root, dirs, files in os.walk(str(p)):
|
|
88
|
+
dirs[:] = [d for d in dirs if d not in (".git", "__pycache__", "node_modules")]
|
|
89
|
+
for f in files:
|
|
90
|
+
fp = os.path.join(root, f)
|
|
91
|
+
relp = os.path.relpath(fp, str(base)).replace(os.sep, "/")
|
|
92
|
+
try:
|
|
93
|
+
size = os.path.getsize(fp)
|
|
94
|
+
except OSError:
|
|
95
|
+
size = -1
|
|
96
|
+
out.append("%s (%d B)" % (relp, size))
|
|
97
|
+
if len(out) >= LIST_MAX_ENTRIES:
|
|
98
|
+
return "\n".join(out) + "\n…(截断,共 200+ 项)"
|
|
99
|
+
return "\n".join(out) or "(空目录)"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
_BIN_MAGIC = (
|
|
103
|
+
(b"\x89PNG\r\n\x1a\n", "PNG 图片"),
|
|
104
|
+
(b"\xff\xd8\xff", "JPEG 图片"),
|
|
105
|
+
(b"GIF8", "GIF 图片"),
|
|
106
|
+
(b"%PDF-", "PDF 文档"),
|
|
107
|
+
(b"PK\x03\x04", "ZIP 压缩包"),
|
|
108
|
+
(b"\x1f\x8b", "GZIP 压缩包"),
|
|
109
|
+
(b"7z\xbc\xaf\x27\x1c", "7z 压缩包"),
|
|
110
|
+
(b"Rar!\x1a\x07", "RAR 压缩包"),
|
|
111
|
+
(b"\x00\x00\x01\x00", "ICO 图标"),
|
|
112
|
+
(b"OggS", "OGG 音频"),
|
|
113
|
+
(b"ID3", "MP3 音频"),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _bin_format(head):
|
|
118
|
+
"""常见二进制魔数 → 人类可读格式名(写进给模型的提示,让它立刻止损)。"""
|
|
119
|
+
if len(head) >= 12 and head[:4] == b"RIFF":
|
|
120
|
+
return {"WEBP": "WEBP 图片", "WAVE": "WAV 音频",
|
|
121
|
+
"AVI ": "AVI 视频"}.get(head[8:12].decode("ascii", "replace"),
|
|
122
|
+
"RIFF 媒体")
|
|
123
|
+
if len(head) >= 8 and head[4:8] == b"ftyp":
|
|
124
|
+
return "MP4/MOV 视频"
|
|
125
|
+
for magic, name in _BIN_MAGIC:
|
|
126
|
+
if head.startswith(magic):
|
|
127
|
+
return name
|
|
128
|
+
return "二进制文件"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _looks_binary(head):
|
|
132
|
+
"""文本判定从宽:NUL 字节即判二进制(git 同款启发式),控制字符占比兜底
|
|
133
|
+
(部分二进制头 8KB 内无 NUL)。"""
|
|
134
|
+
if not head:
|
|
135
|
+
return False
|
|
136
|
+
if b"\x00" in head:
|
|
137
|
+
return True
|
|
138
|
+
sample = head[:8192]
|
|
139
|
+
ctrl = sum(1 for b in sample if b < 32 and b not in (9, 10, 12, 13))
|
|
140
|
+
return ctrl * 20 > len(sample) # >5% 视为二进制
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _trim_partial_utf8(chunk):
|
|
144
|
+
"""头部截断可能把多字节字符切成两半:剥掉尾部不完整序列,否则 UTF-8
|
|
145
|
+
strict 失败会误落 GBK 解出错字(runner.tail_decoded 的头截断对偶)。"""
|
|
146
|
+
i = len(chunk) - 1
|
|
147
|
+
while i >= 0 and i >= len(chunk) - 3 and (chunk[i] & 0xC0) == 0x80:
|
|
148
|
+
i -= 1
|
|
149
|
+
if 0 <= i < len(chunk):
|
|
150
|
+
b = chunk[i]
|
|
151
|
+
need = 4 if (b & 0xF8) == 0xF0 else 3 if (b & 0xF0) == 0xE0 \
|
|
152
|
+
else 2 if (b & 0xC0) == 0xC0 else 1
|
|
153
|
+
if len(chunk) - i < need:
|
|
154
|
+
return chunk[:i]
|
|
155
|
+
return chunk
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _guess_image_mime(path, raw=b""):
|
|
159
|
+
m = mimetypes.guess_type(str(path))[0]
|
|
160
|
+
if m:
|
|
161
|
+
return m
|
|
162
|
+
if raw.startswith(b"\x89PNG"):
|
|
163
|
+
return "image/png"
|
|
164
|
+
if raw.startswith(b"\xff\xd8"):
|
|
165
|
+
return "image/jpeg"
|
|
166
|
+
if raw.startswith(b"GIF8"):
|
|
167
|
+
return "image/gif"
|
|
168
|
+
if raw.startswith(b"RIFF"):
|
|
169
|
+
return "image/webp"
|
|
170
|
+
return "image/png"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _prep_images(paths, log=None):
|
|
174
|
+
"""图片路径 → [(mime, b64)]。Pillow 可用时:长边超 1568px 等比缩(万级像素
|
|
175
|
+
原图既烧 token 又常被网关拒),仍超 3.5MB 转 JPEG q85;Pillow 缺失或解码
|
|
176
|
+
失败时原图直传,单张超 5MB 剔除并日志。最多 MAX_IMAGES 张。"""
|
|
177
|
+
try:
|
|
178
|
+
from PIL import Image
|
|
179
|
+
except Exception:
|
|
180
|
+
Image = None
|
|
181
|
+
out = []
|
|
182
|
+
for p in (paths or [])[:MAX_IMAGES]:
|
|
183
|
+
try:
|
|
184
|
+
raw = Path(p).read_bytes()
|
|
185
|
+
except OSError:
|
|
186
|
+
if log:
|
|
187
|
+
log("[图片] 跳过(读取失败): %s" % os.path.basename(str(p)))
|
|
188
|
+
continue
|
|
189
|
+
mime = _guess_image_mime(p, raw)
|
|
190
|
+
data = raw
|
|
191
|
+
if Image is not None:
|
|
192
|
+
try:
|
|
193
|
+
im = Image.open(io.BytesIO(raw))
|
|
194
|
+
im.load()
|
|
195
|
+
w, h = im.size
|
|
196
|
+
edge = max(w, h, 1)
|
|
197
|
+
if edge > IMAGE_MAX_EDGE:
|
|
198
|
+
r = IMAGE_MAX_EDGE / float(edge)
|
|
199
|
+
im = im.resize((max(1, round(w * r)), max(1, round(h * r))),
|
|
200
|
+
Image.LANCZOS)
|
|
201
|
+
fmt = {"image/png": "PNG", "image/jpeg": "JPEG",
|
|
202
|
+
"image/gif": "GIF", "image/webp": "WEBP"}.get(mime, "PNG")
|
|
203
|
+
buf = io.BytesIO()
|
|
204
|
+
im.save(buf, format=fmt) # GIF 多帧仅存首帧,可接受
|
|
205
|
+
data = buf.getvalue()
|
|
206
|
+
except Exception:
|
|
207
|
+
data = raw # 解码/编码失败退回原始字节
|
|
208
|
+
if (Image is not None and mime != "image/jpeg"
|
|
209
|
+
and len(data) > IMAGE_JPEG_BYTES):
|
|
210
|
+
try: # 仍过大:转 JPEG q85(RGB 拍平 alpha)
|
|
211
|
+
im = Image.open(io.BytesIO(data)).convert("RGB")
|
|
212
|
+
buf = io.BytesIO()
|
|
213
|
+
im.save(buf, format="JPEG", quality=85)
|
|
214
|
+
data = buf.getvalue()
|
|
215
|
+
mime = "image/jpeg"
|
|
216
|
+
except Exception:
|
|
217
|
+
pass
|
|
218
|
+
if len(data) > IMAGE_MAX_BYTES:
|
|
219
|
+
if log:
|
|
220
|
+
log("[图片] 剔除(过大 %d MB): %s"
|
|
221
|
+
% (len(data) // (1024 * 1024), os.path.basename(str(p))))
|
|
222
|
+
continue
|
|
223
|
+
out.append((mime, base64.b64encode(data).decode("ascii")))
|
|
224
|
+
return out
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _tool_read_file(workdir, args):
|
|
228
|
+
rel = str(args.get("path") or "").replace("\\", "/").strip("/")
|
|
229
|
+
if not rel or ".." in Path(rel).parts or any(":" in seg for seg in Path(rel).parts):
|
|
230
|
+
return "(非法路径: %s)" % rel
|
|
231
|
+
base = Path(workdir or ".").resolve()
|
|
232
|
+
p = (base / rel).resolve()
|
|
233
|
+
if base not in p.parents:
|
|
234
|
+
return "(路径越界: %s)" % rel
|
|
235
|
+
if not p.is_file():
|
|
236
|
+
return "(文件不存在: %s)" % rel
|
|
237
|
+
data = p.read_bytes()[:READ_MAX_BYTES + 1]
|
|
238
|
+
truncated = len(data) > READ_MAX_BYTES
|
|
239
|
+
head = data[:READ_MAX_BYTES]
|
|
240
|
+
if not head:
|
|
241
|
+
return "(空文件)"
|
|
242
|
+
if _looks_binary(head):
|
|
243
|
+
try:
|
|
244
|
+
size = p.stat().st_size
|
|
245
|
+
except OSError:
|
|
246
|
+
size = len(head)
|
|
247
|
+
fmt = _bin_format(head)
|
|
248
|
+
if "图片" in fmt:
|
|
249
|
+
return ("(图片文件: %s — %s,%d B,无法按文本读取。用户以附件发来的图片会"
|
|
250
|
+
"直接出现在对话中,无需用工具读取;若需要目录里这张图的内容,"
|
|
251
|
+
"请提示用户把它作为附件发送。)" % (rel, fmt, size))
|
|
252
|
+
return ("(二进制文件,无法按文本读取: %s — %s,%d B。read_file 只支持文本文件;"
|
|
253
|
+
"请如实告知用户该文件内容无法以文本方式查看,不要反复重读。)"
|
|
254
|
+
% (rel, fmt, size))
|
|
255
|
+
if truncated:
|
|
256
|
+
head = _trim_partial_utf8(head)
|
|
257
|
+
text = runner.decode_output(head)
|
|
258
|
+
return ("…(超过 64KB 已截断)\n" if truncated else "") + text
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _tool_write_file(workdir, args):
|
|
262
|
+
rel = str(args.get("path") or "").replace("\\", "/").strip("/")
|
|
263
|
+
if not rel or ".." in Path(rel).parts or any(":" in seg for seg in Path(rel).parts):
|
|
264
|
+
return "(非法路径: %s)" % rel
|
|
265
|
+
content = str(args.get("content") if args.get("content") is not None else "")
|
|
266
|
+
base = Path(workdir or ".").resolve()
|
|
267
|
+
dest = (base / rel).resolve()
|
|
268
|
+
if base not in dest.parents:
|
|
269
|
+
return "(路径越界: %s)" % rel
|
|
270
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
271
|
+
dest.write_text(content, encoding="utf-8")
|
|
272
|
+
return "已写入 %s(%d 字符,UTF-8)" % (rel, len(content))
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
TOOLS_SPEC = [
|
|
276
|
+
{"name": "list_files", "description": "列出工作目录(或其子目录)下的文件",
|
|
277
|
+
"args": {"path": "子目录相对路径,留空=根目录"}},
|
|
278
|
+
{"name": "read_file", "description": "读取工作目录内一个文本文件(UTF-8/GBK 自动识别,超 64KB 截断;图片等二进制文件无法读取)",
|
|
279
|
+
"args": {"path": "文件相对路径"}},
|
|
280
|
+
{"name": "write_file", "description": "把文本内容写入工作目录内一个文件(UTF-8,父目录自动创建)",
|
|
281
|
+
"args": {"path": "文件相对路径", "content": "完整文本内容"}},
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
_TOOL_IMPL = {"list_files": _tool_list_files, "read_file": _tool_read_file,
|
|
285
|
+
"write_file": _tool_write_file}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _exec_tool(workdir, name, args):
|
|
289
|
+
fn = _TOOL_IMPL.get(name or "")
|
|
290
|
+
if fn is None:
|
|
291
|
+
return "(未知工具: %s)" % name
|
|
292
|
+
try:
|
|
293
|
+
return fn(workdir, args or {})
|
|
294
|
+
except Exception as e:
|
|
295
|
+
return "工具执行失败: %s" % (e)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ---------------------------------------------------------------- 协议适配
|
|
299
|
+
|
|
300
|
+
def _openai_tools():
|
|
301
|
+
return [{"type": "function", "function": {
|
|
302
|
+
"name": t["name"], "description": t["description"],
|
|
303
|
+
"parameters": {"type": "object",
|
|
304
|
+
"properties": {k: {"type": "string", "description": v}
|
|
305
|
+
for k, v in t["args"].items()},
|
|
306
|
+
"required": list(t["args"].keys())}}}
|
|
307
|
+
for t in TOOLS_SPEC]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _anthropic_tools():
|
|
311
|
+
return [{"name": t["name"], "description": t["description"],
|
|
312
|
+
"input_schema": {"type": "object",
|
|
313
|
+
"properties": {k: {"type": "string", "description": v}
|
|
314
|
+
for k, v in t["args"].items()},
|
|
315
|
+
"required": list(t["args"].keys())}}
|
|
316
|
+
for t in TOOLS_SPEC]
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _post_json(url, headers, body, allow_private, timeout):
|
|
320
|
+
"""传输层单点(测试在这里打桩)。"""
|
|
321
|
+
return modelhub._post_json_http(url, headers, body, allow_private, timeout=timeout)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _m_openai(m):
|
|
325
|
+
"""内部消息 → openai 消息。tool_results: [(call_id, 结果文本)]。"""
|
|
326
|
+
if m["role"] == "assistant":
|
|
327
|
+
out = {"role": "assistant", "content": m.get("content") or ""}
|
|
328
|
+
if m.get("tool_calls"):
|
|
329
|
+
out["tool_calls"] = [{"id": tc["id"], "type": "function",
|
|
330
|
+
"function": {"name": tc["name"],
|
|
331
|
+
"arguments": json.dumps(tc["args"], ensure_ascii=False)}}
|
|
332
|
+
for tc in m["tool_calls"]]
|
|
333
|
+
return out
|
|
334
|
+
if m["role"] == "tool_results":
|
|
335
|
+
return [{"role": "tool", "tool_call_id": cid, "content": text}
|
|
336
|
+
for cid, text in m["tool_results"]]
|
|
337
|
+
imgs = m.get("images") or []
|
|
338
|
+
if imgs: # 多模态:content 变数组,图跟在文本后(role=tool 只收文本,故图只走 user)
|
|
339
|
+
content = [{"type": "text", "text": m.get("content") or ""}]
|
|
340
|
+
content += [{"type": "image_url",
|
|
341
|
+
"image_url": {"url": "data:%s;base64,%s" % (mime, b64)}}
|
|
342
|
+
for mime, b64 in imgs]
|
|
343
|
+
return {"role": "user", "content": content}
|
|
344
|
+
return {"role": "user", "content": m.get("content") or ""}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _m_anthropic(m):
|
|
348
|
+
if m["role"] == "assistant":
|
|
349
|
+
blocks = []
|
|
350
|
+
if m.get("content"):
|
|
351
|
+
blocks.append({"type": "text", "text": m["content"]})
|
|
352
|
+
for tc in (m.get("tool_calls") or []):
|
|
353
|
+
blocks.append({"type": "tool_use", "id": tc["id"], "name": tc["name"],
|
|
354
|
+
"input": tc["args"]})
|
|
355
|
+
return {"role": "assistant", "content": blocks or [{"type": "text", "text": ""}]}
|
|
356
|
+
if m["role"] == "tool_results":
|
|
357
|
+
return {"role": "user", "content": [
|
|
358
|
+
{"type": "tool_result", "tool_use_id": cid, "content": text}
|
|
359
|
+
for cid, text in m["tool_results"]]}
|
|
360
|
+
imgs = m.get("images") or []
|
|
361
|
+
if imgs:
|
|
362
|
+
blocks = [{"type": "text", "text": m.get("content") or ""}]
|
|
363
|
+
blocks += [{"type": "image",
|
|
364
|
+
"source": {"type": "base64", "media_type": mime, "data": b64}}
|
|
365
|
+
for mime, b64 in imgs]
|
|
366
|
+
return {"role": "user", "content": blocks}
|
|
367
|
+
return {"role": "user", "content": [{"type": "text", "text": m.get("content") or ""}]}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _build_request(proto, base, use_key, model, system, msgs, with_tools):
|
|
371
|
+
"""按协议构造 (url, headers, body)。msgs 为内部统一形状。"""
|
|
372
|
+
base = (base or "").rstrip("/")
|
|
373
|
+
if proto == "google":
|
|
374
|
+
if base.endswith("/v1beta"):
|
|
375
|
+
url = base + "/models/%s:generateContent" % model
|
|
376
|
+
else:
|
|
377
|
+
url = base + "/v1beta/models/%s:generateContent" % model
|
|
378
|
+
headers = {"x-goog-api-key": use_key}
|
|
379
|
+
contents = []
|
|
380
|
+
for m in msgs:
|
|
381
|
+
if m["role"] not in ("user", "assistant"):
|
|
382
|
+
continue
|
|
383
|
+
parts = [{"text": m.get("content") or ""}]
|
|
384
|
+
parts += [{"inline_data": {"mime_type": mime, "data": b64}}
|
|
385
|
+
for mime, b64 in (m.get("images") or [])]
|
|
386
|
+
contents.append({"role": ("user" if m["role"] == "user" else "model"),
|
|
387
|
+
"parts": parts})
|
|
388
|
+
body = {"contents": contents,
|
|
389
|
+
"systemInstruction": {"parts": [{"text": system}]},
|
|
390
|
+
"generationConfig": {"maxOutputTokens": 8000}}
|
|
391
|
+
return url, headers, body
|
|
392
|
+
path = "/messages" if proto == "anthropic" else "/chat/completions"
|
|
393
|
+
url = (base + path) if base.endswith("/v1") else (base + "/v1" + path)
|
|
394
|
+
if proto == "anthropic":
|
|
395
|
+
headers = {"x-api-key": use_key, "anthropic-version": "2023-06-01"}
|
|
396
|
+
body = {"model": model, "max_tokens": 8000, "system": system,
|
|
397
|
+
"messages": [_m_anthropic(m) for m in msgs]}
|
|
398
|
+
if with_tools:
|
|
399
|
+
body["tools"] = _anthropic_tools()
|
|
400
|
+
return url, headers, body
|
|
401
|
+
headers = {"Authorization": "Bearer " + use_key}
|
|
402
|
+
# tool_results 消息展开成多条 role=tool(_m_openai 对它返回列表,不能嵌套)
|
|
403
|
+
msgs_wire = []
|
|
404
|
+
for m in msgs:
|
|
405
|
+
w = _m_openai(m)
|
|
406
|
+
if isinstance(w, list):
|
|
407
|
+
msgs_wire.extend(w)
|
|
408
|
+
else:
|
|
409
|
+
msgs_wire.append(w)
|
|
410
|
+
body = {"model": model, "max_tokens": 8000,
|
|
411
|
+
"messages": [{"role": "system", "content": system}] + msgs_wire}
|
|
412
|
+
if with_tools:
|
|
413
|
+
body["tools"] = _openai_tools()
|
|
414
|
+
return url, headers, body
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _parse_reply(proto, data):
|
|
418
|
+
"""从响应解析 → (文本, [tool_call], usage)。
|
|
419
|
+
tool_call = {id, name, args};usage = {input, output, cached, total}。"""
|
|
420
|
+
usage = {"input": 0, "output": 0, "cached": 0, "total": 0}
|
|
421
|
+
if proto == "google":
|
|
422
|
+
cand = (data.get("candidates") or [{}])[0]
|
|
423
|
+
parts = (cand.get("content") or {}).get("parts") or []
|
|
424
|
+
text = "\n".join(p.get("text", "") for p in parts if isinstance(p, dict))
|
|
425
|
+
um = data.get("usageMetadata") or {}
|
|
426
|
+
usage["input"] = int(um.get("promptTokenCount") or 0)
|
|
427
|
+
usage["output"] = int(um.get("candidatesTokenCount") or 0)
|
|
428
|
+
usage["total"] = int(um.get("totalTokenCount") or 0)
|
|
429
|
+
return text.strip(), [], usage
|
|
430
|
+
if proto == "anthropic":
|
|
431
|
+
blocks = data.get("content") or []
|
|
432
|
+
text = "\n".join(b.get("text", "") for b in blocks
|
|
433
|
+
if isinstance(b, dict) and b.get("type") == "text")
|
|
434
|
+
calls = [{"id": b.get("id") or "", "name": b.get("name") or "",
|
|
435
|
+
"args": b.get("input") or {}}
|
|
436
|
+
for b in blocks if isinstance(b, dict) and b.get("type") == "tool_use"]
|
|
437
|
+
u = data.get("usage") or {}
|
|
438
|
+
usage["input"] = int(u.get("input_tokens") or 0)
|
|
439
|
+
usage["output"] = int(u.get("output_tokens") or 0)
|
|
440
|
+
usage["cached"] = (int(u.get("cache_read_input_tokens") or 0)
|
|
441
|
+
+ int(u.get("cache_creation_input_tokens") or 0))
|
|
442
|
+
usage["total"] = usage["input"] + usage["output"] + usage["cached"]
|
|
443
|
+
return text.strip(), calls, usage
|
|
444
|
+
choice = (data.get("choices") or [{}])[0]
|
|
445
|
+
msg = choice.get("message") or {}
|
|
446
|
+
calls = []
|
|
447
|
+
for tc in (msg.get("tool_calls") or []):
|
|
448
|
+
fn = tc.get("function") or {}
|
|
449
|
+
try:
|
|
450
|
+
args = json.loads(fn.get("arguments") or "{}")
|
|
451
|
+
except Exception:
|
|
452
|
+
args = {}
|
|
453
|
+
if not isinstance(args, dict):
|
|
454
|
+
args = {}
|
|
455
|
+
calls.append({"id": tc.get("id") or ("call_%d" % (len(calls) + 1)),
|
|
456
|
+
"name": fn.get("name") or "", "args": args})
|
|
457
|
+
u = data.get("usage") or {}
|
|
458
|
+
usage["input"] = int(u.get("prompt_tokens") or 0)
|
|
459
|
+
usage["output"] = int(u.get("completion_tokens") or 0)
|
|
460
|
+
usage["total"] = int(u.get("total_tokens") or 0) or (usage["input"] + usage["output"])
|
|
461
|
+
return (msg.get("content") or "").strip(), calls, usage
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
# ---------------------------------------------------------------- 主循环
|
|
465
|
+
|
|
466
|
+
def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=None):
|
|
467
|
+
"""跑一次内置智能体(内部自带工具循环直到给出最终回答)。
|
|
468
|
+
|
|
469
|
+
bi: resolve() 的返回;prompt: 本轮完整输入(目标/续轮块由 pipeline 拼);
|
|
470
|
+
images: 用户随本轮消息提供的图片绝对路径列表(任务附件/对话追话);
|
|
471
|
+
log: 追加一行日志的回调(步骤日志)。返回 runner 风格统一结果:
|
|
472
|
+
{ok, text, usage, error, model, provider_name, provider_id, iterations, cost_usd}。
|
|
473
|
+
"""
|
|
474
|
+
prov = bi["prov"]
|
|
475
|
+
model = bi["model"]
|
|
476
|
+
allow_private = bool(prov.get("allow_private"))
|
|
477
|
+
system = _SYSTEM_PROMPT + "\n\n## 工作目录\n%s" % os.path.abspath(workdir or ".")
|
|
478
|
+
imgs = _prep_images(images, log) if images else []
|
|
479
|
+
if imgs and not modelhub._model_image_in(prov, model):
|
|
480
|
+
# 能力闸门:模型未声明图片输入就不塞图(网关多半会拒),改为显式告知
|
|
481
|
+
if log:
|
|
482
|
+
log("[图片] 模型 %s 未开启图片输入,%d 张图不随消息发送" % (model, len(imgs)))
|
|
483
|
+
prompt = ("(用户提供了 %d 张图片,但当前模型未开启图片输入;请在回答开头提示用户"
|
|
484
|
+
"到绑定页为该模型开启「图」开关,或改绑多模态模型。本条回答请基于"
|
|
485
|
+
"文字部分完成。)\n\n%s" % (len(imgs), prompt))
|
|
486
|
+
imgs = []
|
|
487
|
+
msgs = [{"role": "user", "content": prompt, "images": imgs}]
|
|
488
|
+
candidates = list(modelhub._protocol_candidates(prov))
|
|
489
|
+
tools_ok = all(proto != "google" for proto, _ in candidates) # google wire 无工具协议
|
|
490
|
+
total_usage = {"input": 0, "cached": 0, "output": 0, "total": 0}
|
|
491
|
+
text = ""
|
|
492
|
+
iters = 0
|
|
493
|
+
last_err = ""
|
|
494
|
+
ok = False
|
|
495
|
+
|
|
496
|
+
def _fail(err):
|
|
497
|
+
return {"ok": False, "text": "", "usage": dict(total_usage), "error": err,
|
|
498
|
+
"model": model, "provider_name": bi["provider_name"],
|
|
499
|
+
"provider_id": bi["provider_id"], "iterations": iters, "cost_usd": 0.0}
|
|
500
|
+
|
|
501
|
+
for it in range(1, MAX_TOOL_ITERS + 1):
|
|
502
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
503
|
+
return _fail("已取消")
|
|
504
|
+
done = False
|
|
505
|
+
for proto, pbase in candidates:
|
|
506
|
+
keys = modelhub._chain_keys(prov) or [{"key": prov.get("api_key") or "", "id": ""}]
|
|
507
|
+
for kk in keys:
|
|
508
|
+
url, headers, body = _build_request(proto, pbase, kk["key"], model,
|
|
509
|
+
system, msgs, tools_ok)
|
|
510
|
+
status, data, err = _post_json(url, headers, body, allow_private, timeout)
|
|
511
|
+
if status == 0 or not (200 <= status < 300):
|
|
512
|
+
msg = ""
|
|
513
|
+
if isinstance(data, dict):
|
|
514
|
+
e = data.get("error")
|
|
515
|
+
msg = e.get("message", "") if isinstance(e, dict) else str(e)
|
|
516
|
+
last_err = err or ("HTTP %s %s" % (status, str(msg)[:200]))
|
|
517
|
+
try:
|
|
518
|
+
modelhub.note_key_error(bi["provider_id"], kk.get("id") or "", last_err)
|
|
519
|
+
except Exception:
|
|
520
|
+
pass
|
|
521
|
+
continue
|
|
522
|
+
try:
|
|
523
|
+
modelhub.note_key_ok(bi["provider_id"], kk.get("id") or "")
|
|
524
|
+
except Exception:
|
|
525
|
+
pass
|
|
526
|
+
text, calls, usage = _parse_reply(proto, data)
|
|
527
|
+
for k in total_usage:
|
|
528
|
+
total_usage[k] += int(usage.get(k) or 0)
|
|
529
|
+
iters = it
|
|
530
|
+
if calls:
|
|
531
|
+
if log:
|
|
532
|
+
log("[迭代 %d] %s 请求工具: %s" % (
|
|
533
|
+
it, model, ", ".join(c["name"] for c in calls)))
|
|
534
|
+
results = []
|
|
535
|
+
for c in calls:
|
|
536
|
+
out = _exec_tool(workdir, c["name"], c["args"])
|
|
537
|
+
if log:
|
|
538
|
+
brief = out if len(out) <= 120 else out[:120] + "…"
|
|
539
|
+
log("[工具] %s → %s" % (c["name"], brief.replace("\n", " ⏎ ")))
|
|
540
|
+
results.append((c["id"], out))
|
|
541
|
+
msgs.append({"role": "assistant", "content": text, "tool_calls": calls})
|
|
542
|
+
msgs.append({"role": "tool_results", "tool_results": results})
|
|
543
|
+
done = True
|
|
544
|
+
break
|
|
545
|
+
if (text or "").strip():
|
|
546
|
+
if log:
|
|
547
|
+
log("[迭代 %d] 最终回答(%d 字)" % (it, len(text)))
|
|
548
|
+
ok = True
|
|
549
|
+
done = True
|
|
550
|
+
break
|
|
551
|
+
last_err = "模型未返回文本"
|
|
552
|
+
done = True
|
|
553
|
+
break
|
|
554
|
+
if done:
|
|
555
|
+
break
|
|
556
|
+
if not done:
|
|
557
|
+
break # 所有 wire/KEY 都失败
|
|
558
|
+
if ok:
|
|
559
|
+
break
|
|
560
|
+
if not ok and not text:
|
|
561
|
+
return _fail(last_err or "工具循环达上限仍无最终回答")
|
|
562
|
+
return {"ok": True, "text": (text or "").strip(), "usage": dict(total_usage),
|
|
563
|
+
"error": "", "model": model, "provider_name": bi["provider_name"],
|
|
564
|
+
"provider_id": bi["provider_id"], "iterations": iters, "cost_usd": 0.0}
|