trip-moments-skill 1.0.0
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/.agents/skills/trip-moments/SKILL.md +255 -0
- package/.agents/skills/trip-moments/assets/examples/editorial-bridge-gemini.png +0 -0
- package/.agents/skills/trip-moments/assets/photo-abstract-editorial/PROVENANCE.md +6 -0
- package/.agents/skills/trip-moments/assets/photo-abstract-editorial/prompt-zh.md +246 -0
- package/.agents/skills/trip-moments/references/ai-apis.md +153 -0
- package/.agents/skills/trip-moments/references/amap-geo.md +51 -0
- package/.agents/skills/trip-moments/references/api-config.md +129 -0
- package/.agents/skills/trip-moments/references/editorial-poster.md +44 -0
- package/.agents/skills/trip-moments/scripts/aiclient.py +547 -0
- package/.agents/skills/trip-moments/scripts/check_env.py +119 -0
- package/.agents/skills/trip-moments/scripts/crop.py +367 -0
- package/.agents/skills/trip-moments/scripts/enhance.py +79 -0
- package/.agents/skills/trip-moments/scripts/exif_gps.py +170 -0
- package/.agents/skills/trip-moments/scripts/film.py +106 -0
- package/.agents/skills/trip-moments/scripts/findphotos.py +173 -0
- package/.agents/skills/trip-moments/scripts/grid9.py +134 -0
- package/.agents/skills/trip-moments/scripts/imquality.py +203 -0
- package/.agents/skills/trip-moments/scripts/polaroid.py +94 -0
- package/.agents/skills/trip-moments/scripts/poster.py +281 -0
- package/.agents/skills/trip-moments/scripts/requirements.txt +3 -0
- package/.agents/skills/trip-moments/scripts/retouch.py +112 -0
- package/.agents/skills/trip-moments/scripts/review.py +527 -0
- package/.agents/skills/trip-moments/scripts/route_map.py +337 -0
- package/.agents/skills/trip-moments/scripts/select9.py +389 -0
- package/.agents/skills/trip-moments/scripts/selftest.py +225 -0
- package/.agents/skills/trip-moments/scripts/styles.py +147 -0
- package/README.md +163 -0
- package/bin/install.js +399 -0
- package/install.cmd +42 -0
- package/install.ps1 +11 -0
- package/install.sh +12 -0
- package/package.json +47 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
"""AI client adapters for the trip-moments skill.
|
|
2
|
+
|
|
3
|
+
Providers, all detected via environment variables:
|
|
4
|
+
|
|
5
|
+
Self-hosted / aggregated gateway (highest priority, all three abilities):
|
|
6
|
+
TRIP_AI_BASE_URL + TRIP_AI_API_KEY explicit trip gateway; beats SiliconFlow
|
|
7
|
+
OPENAI_BASE_URL + OPENAI_API_KEY generic OpenAI-style gateway
|
|
8
|
+
Routes by model name (TRIP_VL_MODEL / TRIP_EDIT_MODEL / TRIP_T2I_MODEL):
|
|
9
|
+
- vision: /chat/completions with image_url parts
|
|
10
|
+
- gpt-image series: native protocol, /images/edits multipart for editing,
|
|
11
|
+
/images/generations JSON with "size" for t2i
|
|
12
|
+
- gemini image models (nano-banana family): chat-only, image in -> image
|
|
13
|
+
out (data URI in reply); /images/* rejects them
|
|
14
|
+
- others: /images/generations JSON with image_size + data URL
|
|
15
|
+
SiliconFlow:
|
|
16
|
+
SILICONFLOW_API_KEY base https://api.siliconflow.cn/v1 (Qwen-Image-Edit,
|
|
17
|
+
GLM-4.5V vision, Kolors t2i)
|
|
18
|
+
Aliyun DashScope:
|
|
19
|
+
DASHSCOPE_API_KEY wanx2.1-imageedit presets (async task API)
|
|
20
|
+
Zhipu BigModel:
|
|
21
|
+
ZHIPU_API_KEY glm-4v vision + glm-image text-to-image (no editing)
|
|
22
|
+
|
|
23
|
+
Env values are read through _env_clean(): setx tends to store stray quotes
|
|
24
|
+
literally, which breaks Bearer auth. Size parameter auto-selects "size" for
|
|
25
|
+
gpt-image models, "image_size" otherwise; TRIP_SIZE_PARAM overrides.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import base64
|
|
30
|
+
import io
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
import time
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
import requests
|
|
38
|
+
|
|
39
|
+
if sys.platform == "win32":
|
|
40
|
+
try:
|
|
41
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
42
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
SILICONFLOW_BASE = "https://api.siliconflow.cn/v1"
|
|
47
|
+
ZHIPU_BASE = "https://open.bigmodel.cn/api/paas/v4"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AIError(RuntimeError):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _env_clean(name: str) -> str | None:
|
|
55
|
+
"""env value with whitespace and stray surrounding quotes removed.
|
|
56
|
+
|
|
57
|
+
setx / cmd `set VAR="value"` tend to store the quotes literally; sending
|
|
58
|
+
`Bearer "sk-..."` fails auth, so every key/base-url read goes through here.
|
|
59
|
+
"""
|
|
60
|
+
v = os.environ.get(name)
|
|
61
|
+
if v is None:
|
|
62
|
+
return None
|
|
63
|
+
v = v.strip().strip('"').strip("'").strip()
|
|
64
|
+
return v or None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def siliconflow_key() -> str | None:
|
|
68
|
+
return _env_clean("SILICONFLOW_API_KEY")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def zhipu_key() -> str | None:
|
|
72
|
+
return _env_clean("ZHIPU_API_KEY") or _env_clean("BIGMODEL_API_KEY")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def dashscope_key() -> str | None:
|
|
76
|
+
return _env_clean("DASHSCOPE_API_KEY")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def openai_gateway() -> tuple[str, str] | None:
|
|
80
|
+
"""Explicit trip gateway (highest priority), then the generic OPENAI_* one."""
|
|
81
|
+
base = _env_clean("TRIP_AI_BASE_URL")
|
|
82
|
+
key = _env_clean("TRIP_AI_API_KEY")
|
|
83
|
+
if base and key:
|
|
84
|
+
return base.rstrip("/"), key
|
|
85
|
+
base = _env_clean("OPENAI_BASE_URL")
|
|
86
|
+
key = _env_clean("OPENAI_API_KEY")
|
|
87
|
+
if base and key:
|
|
88
|
+
return base.rstrip("/"), key
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _data_url(image_path: str | Path, max_side: int = 1536, quality: int = 90) -> str:
|
|
93
|
+
"""Image as a JPEG data URL (downscaled so payloads stay small)."""
|
|
94
|
+
from imquality import downscale, load_rgb
|
|
95
|
+
|
|
96
|
+
img = downscale(load_rgb(image_path), max_side)
|
|
97
|
+
buf = io.BytesIO()
|
|
98
|
+
img.save(buf, format="JPEG", quality=quality)
|
|
99
|
+
return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _api_error(resp: requests.Response, tag: str) -> AIError:
|
|
103
|
+
try:
|
|
104
|
+
data = resp.json()
|
|
105
|
+
except ValueError:
|
|
106
|
+
return AIError(f"[{tag}] HTTP {resp.status_code}: {resp.text[:300]}")
|
|
107
|
+
msg = None
|
|
108
|
+
if isinstance(data, dict):
|
|
109
|
+
err = data.get("error")
|
|
110
|
+
if isinstance(err, dict):
|
|
111
|
+
msg = err.get("message")
|
|
112
|
+
msg = msg or data.get("message") or data.get("msg")
|
|
113
|
+
text = msg or str(data)[:300]
|
|
114
|
+
low = text.lower()
|
|
115
|
+
hint = ""
|
|
116
|
+
if "cooling down" in low or "cooldown" in low:
|
|
117
|
+
hint = "(网关账号池冷却中:稍等恢复,或用 TRIP_*_MODEL 换其他在售模型)"
|
|
118
|
+
elif "auth_unavailable" in low:
|
|
119
|
+
hint = "(网关侧凭据耗尽:需要到网关后台刷新该供应商授权后重试)"
|
|
120
|
+
elif resp.status_code == 403:
|
|
121
|
+
hint = "(当前 key 对该模型无权限:换模型或检查网关授权)"
|
|
122
|
+
return AIError(f"[{tag}] HTTP {resp.status_code}: {text[:300]}{hint}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _extract_image_bytes(data: dict, tag: str) -> bytes:
|
|
126
|
+
"""SiliconFlow returns images[].url; OpenAI-style may be data[].url/b64_json."""
|
|
127
|
+
for arr_key in ("images", "data"):
|
|
128
|
+
arr = data.get(arr_key)
|
|
129
|
+
if isinstance(arr, list) and arr:
|
|
130
|
+
item = arr[0]
|
|
131
|
+
if isinstance(item, dict):
|
|
132
|
+
if item.get("url"):
|
|
133
|
+
img = requests.get(item["url"], timeout=120)
|
|
134
|
+
img.raise_for_status()
|
|
135
|
+
return img.content
|
|
136
|
+
if item.get("b64_json"):
|
|
137
|
+
return base64.b64decode(item["b64_json"])
|
|
138
|
+
raise AIError(f"[{tag}] no image url/b64 in response: {str(data)[:300]}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class OpenAICompat:
|
|
142
|
+
"""Any OpenAI-style endpoint: SiliconFlow, one-api/new-api gateways, vLLM..."""
|
|
143
|
+
|
|
144
|
+
name = "openai-compat"
|
|
145
|
+
|
|
146
|
+
def __init__(self, base_url: str, api_key: str, size_param: str = "image_size",
|
|
147
|
+
multipart_edit: bool = False):
|
|
148
|
+
self.base = base_url.rstrip("/")
|
|
149
|
+
self.key = api_key
|
|
150
|
+
self.size_param = size_param # siliconflow: image_size, zhipu: size
|
|
151
|
+
# gpt-image series over a pass-through gateway: native OpenAI protocol,
|
|
152
|
+
# i.e. multipart file upload to /images/edits (JSON data-URL is not accepted)
|
|
153
|
+
self.multipart_edit = multipart_edit
|
|
154
|
+
|
|
155
|
+
# -- internals -----------------------------------------------------------
|
|
156
|
+
def _post(self, path: str, body: dict, tag: str, timeout: int = 180) -> dict:
|
|
157
|
+
last_exc: Exception | None = None
|
|
158
|
+
last_resp: requests.Response | None = None
|
|
159
|
+
for attempt in range(3):
|
|
160
|
+
try:
|
|
161
|
+
resp = requests.post(
|
|
162
|
+
f"{self.base}{path}",
|
|
163
|
+
headers={"Authorization": f"Bearer {self.key}", "Content-Type": "application/json"},
|
|
164
|
+
json=body,
|
|
165
|
+
timeout=timeout,
|
|
166
|
+
)
|
|
167
|
+
if resp.status_code != 200:
|
|
168
|
+
# 网关服务器瞬态错误(502/503/504)与网关限流 429 提示重试
|
|
169
|
+
if resp.status_code in (502, 503, 504) and attempt < 2:
|
|
170
|
+
last_resp = resp
|
|
171
|
+
time.sleep(4 * (attempt + 1))
|
|
172
|
+
continue
|
|
173
|
+
raise _api_error(resp, tag)
|
|
174
|
+
return resp.json()
|
|
175
|
+
except (requests.ConnectionError, requests.Timeout) as exc:
|
|
176
|
+
# 本地代理(如 Clash 系列)对大图上传偶尔 10054 重置时可自动重试
|
|
177
|
+
last_exc = exc
|
|
178
|
+
if attempt < 2:
|
|
179
|
+
time.sleep(4 * (attempt + 1))
|
|
180
|
+
# requests 失败时尝试 curl 兜底
|
|
181
|
+
try:
|
|
182
|
+
return self._post_curl(path, body, tag, timeout)
|
|
183
|
+
except AIError:
|
|
184
|
+
pass
|
|
185
|
+
if last_resp is not None:
|
|
186
|
+
raise _api_error(last_resp, tag)
|
|
187
|
+
raise AIError(f"[{tag}] 3次重试后连接依然失败: {last_exc}")
|
|
188
|
+
|
|
189
|
+
def _post_curl(self, path: str, body: dict, tag: str, timeout: int) -> dict:
|
|
190
|
+
"""curl transport fallback: same JSON POST via the curl binary."""
|
|
191
|
+
import shutil as _sh
|
|
192
|
+
import subprocess as _sp
|
|
193
|
+
if not _sh.which("curl"):
|
|
194
|
+
raise AIError(f"[{tag}] curl 不可用")
|
|
195
|
+
try:
|
|
196
|
+
r = _sp.run(
|
|
197
|
+
["curl", "-s", "-m", str(timeout), f"{self.base}{path}",
|
|
198
|
+
"-H", f"Authorization: Bearer {self.key}",
|
|
199
|
+
"-H", "Content-Type: application/json",
|
|
200
|
+
"--data-binary", "@-", "-w", "\n%{http_code}"],
|
|
201
|
+
input=json.dumps(body), capture_output=True, text=True,
|
|
202
|
+
timeout=timeout + 30)
|
|
203
|
+
except _sp.SubprocessError as exc:
|
|
204
|
+
raise AIError(f"[{tag}] curl 回退调用失败: {exc}") from exc
|
|
205
|
+
text = r.stdout.rstrip()
|
|
206
|
+
status, _, payload = text.rpartition("\n")
|
|
207
|
+
if status.strip() != "200":
|
|
208
|
+
raise AIError(f"[{tag}] curl 回退 HTTP {status.strip()}: {payload[:200]}")
|
|
209
|
+
try:
|
|
210
|
+
return json.loads(payload)
|
|
211
|
+
except ValueError as exc:
|
|
212
|
+
raise AIError(f"[{tag}] curl 回退返回非合规 JSON: {exc}") from exc
|
|
213
|
+
|
|
214
|
+
@staticmethod
|
|
215
|
+
def _save(content: bytes, out_path: str | Path) -> Path:
|
|
216
|
+
out = Path(out_path)
|
|
217
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
218
|
+
out.write_bytes(content)
|
|
219
|
+
return out
|
|
220
|
+
|
|
221
|
+
# -- public API ----------------------------------------------------------
|
|
222
|
+
def chat_vision(self, data_urls: list[str], prompt: str,
|
|
223
|
+
model: str | None = None, timeout: int = 180) -> str:
|
|
224
|
+
content = [{"type": "image_url", "image_url": {"url": u}} for u in data_urls]
|
|
225
|
+
content.append({"type": "text", "text": prompt})
|
|
226
|
+
data = self._post("/chat/completions", {
|
|
227
|
+
"model": model, "messages": [{"role": "user", "content": content}],
|
|
228
|
+
"temperature": 0.2,
|
|
229
|
+
}, "vision", timeout)
|
|
230
|
+
try:
|
|
231
|
+
return data["choices"][0]["message"]["content"]
|
|
232
|
+
except (KeyError, IndexError, TypeError):
|
|
233
|
+
raise AIError(f"[vision] 视觉接口返回异常响应: {str(data)[:300]}")
|
|
234
|
+
|
|
235
|
+
def text_to_image(self, prompt: str, out_path: str | Path, model: str | None = None,
|
|
236
|
+
image_size: str = "1024x1024") -> Path:
|
|
237
|
+
if model and "gemini" in model.lower() and "image" in model.lower():
|
|
238
|
+
# gemini image models are chat-only on gateways that reject /images/*
|
|
239
|
+
data = self._post("/chat/completions", {
|
|
240
|
+
"model": model, "messages": [{"role": "user", "content": prompt}],
|
|
241
|
+
}, "t2i", timeout=300)
|
|
242
|
+
return self._save_data_uri_image(data, "t2i", out_path)
|
|
243
|
+
body = {"model": model, "prompt": prompt, self.size_param: image_size}
|
|
244
|
+
data = self._post("/images/generations", body, "t2i")
|
|
245
|
+
return self._save(_extract_image_bytes(data, "t2i"), out_path)
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _save_data_uri_image(data: dict, tag: str, out_path: str | Path) -> Path:
|
|
249
|
+
"""Pull a data:image/...;base64 URI out of an arbitrary reply JSON."""
|
|
250
|
+
import re
|
|
251
|
+
|
|
252
|
+
m = re.search(r"data:image/[a-zA-Z.+-]+;base64,([A-Za-z0-9+/=]+)", json.dumps(data))
|
|
253
|
+
if not m:
|
|
254
|
+
raise AIError(f"[{tag}] 返回中未找到图像 Base64 数据: {str(data)[:300]}")
|
|
255
|
+
return OpenAICompat._save(base64.b64decode(m.group(1)), out_path)
|
|
256
|
+
|
|
257
|
+
def edit_image(self, image_path: str | Path, prompt: str, out_path: str | Path,
|
|
258
|
+
model: str | None = None, image_size: str | None = None) -> Path:
|
|
259
|
+
if self.multipart_edit:
|
|
260
|
+
return self._edit_multipart(image_path, prompt, out_path, model, image_size)
|
|
261
|
+
if model and "gemini" in model.lower() and "image" in model.lower():
|
|
262
|
+
# Gemini image models are chat-only: image in, image out (data URI inside reply).
|
|
263
|
+
return self._edit_gemini_chat(image_path, prompt, out_path, model)
|
|
264
|
+
body = {"model": model, "prompt": prompt, "image": _data_url(image_path)}
|
|
265
|
+
if image_size:
|
|
266
|
+
body[self.size_param] = image_size
|
|
267
|
+
data = self._post("/images/generations", body, "edit", timeout=300)
|
|
268
|
+
return self._save(_extract_image_bytes(data, "edit"), out_path)
|
|
269
|
+
|
|
270
|
+
def _edit_gemini_chat(self, image_path: str | Path, prompt: str, out_path: str | Path,
|
|
271
|
+
model: str | None) -> Path:
|
|
272
|
+
# 图片在前、提示词在后,并强调输出图片,防止部分 gemini 渠道只回复文字
|
|
273
|
+
content = [
|
|
274
|
+
{"type": "image_url", "image_url": {"url": _data_url(image_path)}},
|
|
275
|
+
{"type": "text", "text": prompt + "\n\n直接输出编辑后的高质量图片,禁止只回复文字说明。"},
|
|
276
|
+
]
|
|
277
|
+
data = self._post("/chat/completions", {
|
|
278
|
+
"model": model, "messages": [{"role": "user", "content": content}],
|
|
279
|
+
}, "gemini-edit", timeout=300)
|
|
280
|
+
return self._save_data_uri_image(data, "gemini-edit", out_path)
|
|
281
|
+
|
|
282
|
+
def _edit_multipart(self, image_path: str | Path, prompt: str, out_path: str | Path,
|
|
283
|
+
model: str | None, image_size: str | None) -> Path:
|
|
284
|
+
"""Native OpenAI gpt-image editing: multipart POST /images/edits."""
|
|
285
|
+
src = Path(image_path)
|
|
286
|
+
mime = "image/png" if src.suffix.lower() == ".png" else "image/jpeg"
|
|
287
|
+
fields = {"model": model or "", "prompt": prompt}
|
|
288
|
+
if image_size:
|
|
289
|
+
fields["size"] = image_size
|
|
290
|
+
last_exc = None
|
|
291
|
+
for attempt in range(3):
|
|
292
|
+
try:
|
|
293
|
+
resp = requests.post(
|
|
294
|
+
f"{self.base}/images/edits",
|
|
295
|
+
headers={"Authorization": f"Bearer {self.key}"},
|
|
296
|
+
files={"image": (src.name, src.read_bytes(), mime)},
|
|
297
|
+
data=fields,
|
|
298
|
+
timeout=300,
|
|
299
|
+
)
|
|
300
|
+
if resp.status_code != 200:
|
|
301
|
+
raise _api_error(resp, "edit")
|
|
302
|
+
return self._save(_extract_image_bytes(resp.json(), "edit"), out_path)
|
|
303
|
+
except (requests.ConnectionError, requests.Timeout) as exc:
|
|
304
|
+
last_exc = exc
|
|
305
|
+
if attempt < 2:
|
|
306
|
+
time.sleep(4 * (attempt + 1))
|
|
307
|
+
raise AIError(f"[edit] 3次重试后连接失败: {last_exc}")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class Zhipu(OpenAICompat):
|
|
311
|
+
"""BigModel platform: glm-4v vision (OpenAI-style) + glm-image t2i."""
|
|
312
|
+
|
|
313
|
+
name = "zhipu"
|
|
314
|
+
|
|
315
|
+
def __init__(self, api_key: str | None = None):
|
|
316
|
+
super().__init__(ZHIPU_BASE, api_key or zhipu_key() or "", size_param="size")
|
|
317
|
+
|
|
318
|
+
def edit_image(self, *args, **kwargs):
|
|
319
|
+
raise AIError(
|
|
320
|
+
"[zhipu edit] 智谱当前图像接口仅支持文生图(不支持输入图片进行修图)。\n"
|
|
321
|
+
"AI 修图请配置 SILICONFLOW_API_KEY (Qwen-Image-Edit) 或\n"
|
|
322
|
+
"DASHSCOPE_API_KEY (wanx2.1-imageedit)。详见 references/api-config.md。"
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class Dashscope:
|
|
327
|
+
"""Aliyun Bailian wanx2.1-imageedit: preset edit functions, async two-step API."""
|
|
328
|
+
|
|
329
|
+
BASE = "https://dashscope.aliyuncs.com/api/v1"
|
|
330
|
+
FUNCTIONS = {
|
|
331
|
+
"edit": "description_edit",
|
|
332
|
+
"erase": "erase_anything",
|
|
333
|
+
"expand": "expansion",
|
|
334
|
+
"style": "style_repaint",
|
|
335
|
+
"colorize": "colorization",
|
|
336
|
+
"upscale": "image_ultra_quality",
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
def __init__(self, key: str | None = None):
|
|
340
|
+
self.key = key or dashscope_key()
|
|
341
|
+
if not self.key:
|
|
342
|
+
raise AIError(
|
|
343
|
+
"[missing key] 请先配置 DASHSCOPE_API_KEY (bailian.console.aliyun.com)。\n"
|
|
344
|
+
"或使用 OpenAI 兼容服务商: 配置 SILICONFLOW_API_KEY (Qwen-Image-Edit)。\n"
|
|
345
|
+
"详见 references/api-config.md。"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
def edit_image(self, image_path: str | Path, prompt: str, out_path: str | Path,
|
|
349
|
+
function: str = "description_edit", model: str = "wanx2.1-imageedit",
|
|
350
|
+
timeout: int = 300) -> Path:
|
|
351
|
+
real = self.FUNCTIONS.get(function, function)
|
|
352
|
+
resp = requests.post(
|
|
353
|
+
f"{self.BASE}/services/aigc/image2image/image-synthesis",
|
|
354
|
+
headers={
|
|
355
|
+
"Authorization": f"Bearer {self.key}",
|
|
356
|
+
"Content-Type": "application/json",
|
|
357
|
+
"X-DashScope-Async": "enable",
|
|
358
|
+
},
|
|
359
|
+
json={
|
|
360
|
+
"model": model,
|
|
361
|
+
"input": {"function": real, "prompt": prompt, "base_image_url": _data_url(image_path)},
|
|
362
|
+
"parameters": {},
|
|
363
|
+
},
|
|
364
|
+
timeout=60,
|
|
365
|
+
)
|
|
366
|
+
data = resp.json()
|
|
367
|
+
if resp.status_code != 200 or "output" not in data:
|
|
368
|
+
raise _api_error(resp, "dashscope submit")
|
|
369
|
+
task_id = data["output"]["task_id"]
|
|
370
|
+
|
|
371
|
+
deadline = time.time() + timeout
|
|
372
|
+
while time.time() < deadline:
|
|
373
|
+
time.sleep(2.5)
|
|
374
|
+
poll = requests.get(
|
|
375
|
+
f"{self.BASE}/tasks/{task_id}",
|
|
376
|
+
headers={"Authorization": f"Bearer {self.key}"},
|
|
377
|
+
timeout=30,
|
|
378
|
+
).json()
|
|
379
|
+
status = poll.get("output", {}).get("task_status")
|
|
380
|
+
if status == "SUCCEEDED":
|
|
381
|
+
content = _extract_image_bytes(poll["output"], f"dashscope task {task_id}")
|
|
382
|
+
return self._save(content, out_path)
|
|
383
|
+
if status in ("FAILED", "CANCELED", "UNKNOWN"):
|
|
384
|
+
raise AIError(f"[dashscope task {task_id}] {status}: "
|
|
385
|
+
f"{poll.get('output', {}).get('message', poll)}")
|
|
386
|
+
raise AIError(f"[dashscope task {task_id}] 任务超过 {timeout} 秒超时")
|
|
387
|
+
|
|
388
|
+
_save = staticmethod(OpenAICompat._save)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# --- provider factories ------------------------------------------------------
|
|
392
|
+
MODEL_ENV = {"vision": "TRIP_VL_MODEL", "edit": "TRIP_EDIT_MODEL", "t2i": "TRIP_T2I_MODEL"}
|
|
393
|
+
DEFAULTS = {
|
|
394
|
+
"vision-sf": "zai-org/GLM-4.5V",
|
|
395
|
+
"vision-zp": "glm-4v-flash",
|
|
396
|
+
"edit-sf": "Qwen/Qwen-Image-Edit-2509",
|
|
397
|
+
"t2i-sf": "Kwai-Kolors/Kolors",
|
|
398
|
+
"t2i-zp": "glm-image",
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
# 网关模型自动挑选:TRIP_*_MODEL 未指定时,从网关 /models 在售列表中按偏好筛选
|
|
402
|
+
# 核心原则:OpenAI 兼容协议统一接入;对话模型本身能识图+生图(如 gemini 图像系/gpt-image 系)时,
|
|
403
|
+
# VL/EDIT/T2I 全部就地解决,不需要配置额外模型。
|
|
404
|
+
_VISION_PREF = ["gemini-3.8-flash-high", "gemini-3.7-flash-high", "gemini-3.6-flash-high",
|
|
405
|
+
"gemini-3-flash-preview", "claude-sonnet-4-6", "gpt-5.6-sol", "gpt-5.5"]
|
|
406
|
+
_IMAGE_PREF = ["gemini-3.1-flash-image", "gpt-image-2", "gpt-image-1.5"]
|
|
407
|
+
|
|
408
|
+
_MODELS_CACHE: dict[str, list[str]] = {}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _gateway_models(base: str, key: str) -> list[str]:
|
|
412
|
+
"""GET /models with a per-base cache; empty list on any failure."""
|
|
413
|
+
if base not in _MODELS_CACHE:
|
|
414
|
+
ids: list[str] = []
|
|
415
|
+
for attempt in range(3):
|
|
416
|
+
try:
|
|
417
|
+
resp = requests.get(f"{base.rstrip('/')}/models",
|
|
418
|
+
headers={"Authorization": f"Bearer {key}"}, timeout=10)
|
|
419
|
+
if resp.status_code == 200:
|
|
420
|
+
ids = [m.get("id", "") for m in resp.json().get("data", [])]
|
|
421
|
+
break
|
|
422
|
+
except requests.RequestException:
|
|
423
|
+
if attempt < 2:
|
|
424
|
+
time.sleep(2 * (attempt + 1))
|
|
425
|
+
_MODELS_CACHE[base] = ids
|
|
426
|
+
return _MODELS_CACHE[base]
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _pick_model(kind: str, base: str, key: str, fallback: str) -> str:
|
|
430
|
+
"""Auto-pick a model for kind (vision|edit|t2i) from the gateway's own list."""
|
|
431
|
+
override = _env_clean(MODEL_ENV[kind])
|
|
432
|
+
if override:
|
|
433
|
+
return override
|
|
434
|
+
pref = _VISION_PREF if kind == "vision" else _IMAGE_PREF
|
|
435
|
+
avail = set(_gateway_models(base, key))
|
|
436
|
+
for m in pref:
|
|
437
|
+
if m in avail:
|
|
438
|
+
return m
|
|
439
|
+
return pref[0] if not avail else fallback
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _size_param_for(model: str | None) -> str:
|
|
443
|
+
"""Name of the image-size request parameter for a given model."""
|
|
444
|
+
override = _env_clean("TRIP_SIZE_PARAM")
|
|
445
|
+
if override:
|
|
446
|
+
return override
|
|
447
|
+
if model and "gpt-image" in model.lower():
|
|
448
|
+
return "size"
|
|
449
|
+
return "image_size"
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _env_model(kind: str, fallback: str) -> str:
|
|
453
|
+
return _env_clean(MODEL_ENV[kind]) or fallback
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
MISSING_KEY_HELP = (
|
|
457
|
+
"[missing key] 未配置任何 AI 服务商 key。可选方案(任选其一即可):\n"
|
|
458
|
+
" 1. 自建网关: TRIP_AI_BASE_URL + TRIP_AI_API_KEY (推荐,识图+修图+海报全覆盖)\n"
|
|
459
|
+
" 2. 硅基流动: SILICONFLOW_API_KEY (siliconflow.cn - 识图+修图+生图全覆盖)\n"
|
|
460
|
+
" 3. 智谱 AI: ZHIPU_API_KEY (bigmodel.cn - 识图评分+生图)\n"
|
|
461
|
+
" 4. 阿里百炼: DASHSCOPE_API_KEY (bailian.console.aliyun.com - 独家扩图/超分)\n"
|
|
462
|
+
"详见 trip-moments 技能 references/api-config.md 指南。"
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def vision_client():
|
|
467
|
+
"""(client, model) for image scoring; raises AIError if nothing configured."""
|
|
468
|
+
gateway = openai_gateway()
|
|
469
|
+
if gateway:
|
|
470
|
+
return (OpenAICompat(gateway[0], gateway[1]),
|
|
471
|
+
_pick_model("vision", gateway[0], gateway[1], DEFAULTS["vision-sf"]))
|
|
472
|
+
if siliconflow_key():
|
|
473
|
+
return (OpenAICompat(SILICONFLOW_BASE, siliconflow_key()),
|
|
474
|
+
_env_model("vision", DEFAULTS["vision-sf"]))
|
|
475
|
+
if zhipu_key():
|
|
476
|
+
return (Zhipu(), _env_model("vision", DEFAULTS["vision-zp"]))
|
|
477
|
+
raise AIError(MISSING_KEY_HELP)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def t2i_client():
|
|
481
|
+
"""(client, model, image_size) for text-to-image (poster panel)."""
|
|
482
|
+
gateway = openai_gateway()
|
|
483
|
+
if gateway:
|
|
484
|
+
model = _pick_model("t2i", gateway[0], gateway[1], DEFAULTS["t2i-sf"])
|
|
485
|
+
return (OpenAICompat(gateway[0], gateway[1], size_param=_size_param_for(model)),
|
|
486
|
+
model, "1024x1024")
|
|
487
|
+
if siliconflow_key():
|
|
488
|
+
return (OpenAICompat(SILICONFLOW_BASE, siliconflow_key()),
|
|
489
|
+
_env_model("t2i", DEFAULTS["t2i-sf"]), "1024x1024")
|
|
490
|
+
if zhipu_key():
|
|
491
|
+
return (Zhipu(), _env_model("t2i", DEFAULTS["t2i-zp"]), "1280x1280")
|
|
492
|
+
raise AIError(MISSING_KEY_HELP)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def edit_client(prefer: str = "auto"):
|
|
496
|
+
"""(client, model, kind) for image editing; kind: dashscope|openai."""
|
|
497
|
+
gateway = openai_gateway()
|
|
498
|
+
if prefer in ("auto", "dashscope") and dashscope_key():
|
|
499
|
+
return Dashscope(), "wanx2.1-imageedit", "dashscope"
|
|
500
|
+
if prefer in ("auto", "openai") and gateway:
|
|
501
|
+
model = _pick_model("edit", gateway[0], gateway[1], DEFAULTS["edit-sf"])
|
|
502
|
+
return (OpenAICompat(gateway[0], gateway[1], size_param=_size_param_for(model),
|
|
503
|
+
multipart_edit="gpt-image" in model.lower()),
|
|
504
|
+
model, "openai")
|
|
505
|
+
if prefer in ("auto", "openai") and siliconflow_key():
|
|
506
|
+
return (OpenAICompat(SILICONFLOW_BASE, siliconflow_key()),
|
|
507
|
+
_env_model("edit", DEFAULTS["edit-sf"]), "openai")
|
|
508
|
+
raise AIError(MISSING_KEY_HELP)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def face_integrity_check(original: str | Path, edited: str | Path) -> dict | None:
|
|
512
|
+
"""Compare an AI-edited photo against its original with the vision model."""
|
|
513
|
+
try:
|
|
514
|
+
client, model = vision_client()
|
|
515
|
+
except AIError:
|
|
516
|
+
return None
|
|
517
|
+
prompt = ("第一张是原图,第二张是 AI 编辑后的成图。请仔细对比面部与人物,只输出 JSON:\n"
|
|
518
|
+
'{"has_person": 布尔值(原图是否有清晰人物), "same_person": 编辑后是否仍为同一人, '
|
|
519
|
+
'"distorted": 面部或肢体是否存在明显畸变/崩脸/假指, "note": "<=15字说明"}')
|
|
520
|
+
try:
|
|
521
|
+
reply = client.chat_vision(
|
|
522
|
+
[_data_url(original, max_side=768), _data_url(edited, max_side=768)],
|
|
523
|
+
prompt, model=model)
|
|
524
|
+
data = json_from_reply(reply)
|
|
525
|
+
return data if isinstance(data, dict) else None
|
|
526
|
+
except (AIError, ValueError):
|
|
527
|
+
return None
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def json_from_reply(text: str):
|
|
531
|
+
"""Best-effort JSON extraction from a model reply (handles ``` fences, <think>)."""
|
|
532
|
+
import json
|
|
533
|
+
import re
|
|
534
|
+
|
|
535
|
+
text = re.sub(r"<think>.*?</think>", "", text, flags=re.S)
|
|
536
|
+
text = re.sub(r"```(?:json)?", "", text).strip()
|
|
537
|
+
try:
|
|
538
|
+
return json.loads(text)
|
|
539
|
+
except json.JSONDecodeError:
|
|
540
|
+
for open_ch, close_ch in (("[", "]"), ("{", "}")):
|
|
541
|
+
start, end = text.find(open_ch), text.rfind(close_ch)
|
|
542
|
+
if start != -1 and end > start:
|
|
543
|
+
try:
|
|
544
|
+
return json.loads(text[start : end + 1])
|
|
545
|
+
except json.JSONDecodeError:
|
|
546
|
+
continue
|
|
547
|
+
raise AIError(f"[vision] 无法从模型返回中提取有效 JSON: {text[:300]}")
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Environment self-check for the trip-moments skill.
|
|
3
|
+
|
|
4
|
+
Run once per session (or after changing keys) BEFORE any AI feature:
|
|
5
|
+
|
|
6
|
+
python check_env.py
|
|
7
|
+
|
|
8
|
+
Checks, in order:
|
|
9
|
+
1. Python dependencies importable (numpy / PIL / requests)
|
|
10
|
+
2. Provider environment keys configuration
|
|
11
|
+
3. Live gateway model accessibility and validity:
|
|
12
|
+
- gateway (TRIP_AI_* / OPENAI_*): compares TRIP_VL_MODEL / TRIP_EDIT_MODEL /
|
|
13
|
+
TRIP_T2I_MODEL against live GET /v1/models list
|
|
14
|
+
- SiliconFlow: live model availability probe
|
|
15
|
+
4. AMAP_KEY presence (route-map only)
|
|
16
|
+
|
|
17
|
+
Exit code 0 = clean, 1 = at least one model mismatch or missing dependency.
|
|
18
|
+
Read-only: never calls billed endpoints.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
if sys.platform == "win32":
|
|
28
|
+
try:
|
|
29
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
30
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
31
|
+
except Exception:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
35
|
+
|
|
36
|
+
MODEL_ENVS = ("TRIP_VL_MODEL", "TRIP_EDIT_MODEL", "TRIP_T2I_MODEL")
|
|
37
|
+
KEY_ENVS = ("TRIP_AI_BASE_URL", "TRIP_AI_API_KEY", "OPENAI_BASE_URL", "OPENAI_API_KEY",
|
|
38
|
+
"SILICONFLOW_API_KEY", "ZHIPU_API_KEY", "DASHSCOPE_API_KEY", "AMAP_KEY")
|
|
39
|
+
ISSUES: list[str] = []
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _clean(v: str | None) -> str:
|
|
43
|
+
return (v or "").strip().strip('"').strip("'").strip()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_deps() -> None:
|
|
47
|
+
print("[运行环境依赖]")
|
|
48
|
+
for mod in ("numpy", "PIL", "requests"):
|
|
49
|
+
try:
|
|
50
|
+
__import__(mod)
|
|
51
|
+
print(f" [ok] {mod}")
|
|
52
|
+
except ImportError:
|
|
53
|
+
ISSUES.append(f"缺失必要依赖 {mod},请在终端执行: python -m pip install -r requirements.txt")
|
|
54
|
+
print(f" [MISSING] {mod}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def check_provider_models(base: str, key: str, model_envs: dict[str, str], label: str) -> None:
|
|
58
|
+
import requests
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
resp = requests.get(f"{base.rstrip('/')}/models",
|
|
62
|
+
headers={"Authorization": f"Bearer {key}"},
|
|
63
|
+
timeout=(8, 15))
|
|
64
|
+
resp.raise_for_status()
|
|
65
|
+
available = {m.get("id", "") for m in resp.json().get("data", [])}
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
print(f" [warn] {label} /models 查询失败 ({type(exc).__name__}),跳过动态在售比对")
|
|
68
|
+
return
|
|
69
|
+
print(f" [ok] {label} 连通正常,可用模型数: {len(available)}")
|
|
70
|
+
for env, model in model_envs.items():
|
|
71
|
+
if model in available:
|
|
72
|
+
print(f" [ok] {env} = {model}")
|
|
73
|
+
else:
|
|
74
|
+
ISSUES.append(f"{env}={model} 不在该 {label} 提供的模型列表中 -> setx {env} \"<有效模型名>\"")
|
|
75
|
+
print(f" [MISMATCH] {env} = {model} (不在 {label} 在售列表)")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main() -> int:
|
|
79
|
+
check_deps()
|
|
80
|
+
|
|
81
|
+
print("[API 密钥配置]")
|
|
82
|
+
env = {k: _clean(os.environ.get(k)) for k in KEY_ENVS}
|
|
83
|
+
for k in KEY_ENVS:
|
|
84
|
+
if env[k]:
|
|
85
|
+
masked = env[k][:6] + "..." + env[k][-4:] if len(env[k]) > 12 else "***"
|
|
86
|
+
print(f" [set] {k} = {masked}")
|
|
87
|
+
if not any(env[k] for k in KEY_ENVS[:-1]):
|
|
88
|
+
print(" [info] 未配置任何云端 AI 密钥(本地功能全可用,云端 AI 选图/修图/生图将跳过)")
|
|
89
|
+
|
|
90
|
+
print("[模型参数配置]")
|
|
91
|
+
model_envs = {e: _clean(os.environ.get(e)) for e in MODEL_ENVS if _clean(os.environ.get(e))}
|
|
92
|
+
|
|
93
|
+
gw_base, gw_key = env["TRIP_AI_BASE_URL"], env["TRIP_AI_API_KEY"]
|
|
94
|
+
if not (gw_base and gw_key):
|
|
95
|
+
gw_base, gw_key = env["OPENAI_BASE_URL"], env["OPENAI_API_KEY"]
|
|
96
|
+
if gw_base and gw_key:
|
|
97
|
+
check_provider_models(gw_base, gw_key, model_envs, "AI自建网关")
|
|
98
|
+
elif model_envs:
|
|
99
|
+
print(" [warn] 设定了 TRIP_*_MODEL 但未配置对应网关 Base URL")
|
|
100
|
+
|
|
101
|
+
if env["SILICONFLOW_API_KEY"] and not (gw_base and gw_key):
|
|
102
|
+
check_provider_models("https://api.siliconflow.cn/v1", env["SILICONFLOW_API_KEY"],
|
|
103
|
+
model_envs, "SiliconFlow")
|
|
104
|
+
|
|
105
|
+
print("[高德地图 API]")
|
|
106
|
+
if env["AMAP_KEY"]:
|
|
107
|
+
print(" [ok] AMAP_KEY 已配置 (支持高德静态旅行路线图生成)")
|
|
108
|
+
if ISSUES:
|
|
109
|
+
print("\n[待处理事项 / Issues]")
|
|
110
|
+
for i in ISSUES:
|
|
111
|
+
print(f" - {i}")
|
|
112
|
+
print("修改环境变量后请新开终端重试确认。")
|
|
113
|
+
return 1
|
|
114
|
+
print("\n[ok] 环境检查通过!")
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
raise SystemExit(main())
|