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.
Files changed (32) hide show
  1. package/.agents/skills/trip-moments/SKILL.md +255 -0
  2. package/.agents/skills/trip-moments/assets/examples/editorial-bridge-gemini.png +0 -0
  3. package/.agents/skills/trip-moments/assets/photo-abstract-editorial/PROVENANCE.md +6 -0
  4. package/.agents/skills/trip-moments/assets/photo-abstract-editorial/prompt-zh.md +246 -0
  5. package/.agents/skills/trip-moments/references/ai-apis.md +153 -0
  6. package/.agents/skills/trip-moments/references/amap-geo.md +51 -0
  7. package/.agents/skills/trip-moments/references/api-config.md +129 -0
  8. package/.agents/skills/trip-moments/references/editorial-poster.md +44 -0
  9. package/.agents/skills/trip-moments/scripts/aiclient.py +547 -0
  10. package/.agents/skills/trip-moments/scripts/check_env.py +119 -0
  11. package/.agents/skills/trip-moments/scripts/crop.py +367 -0
  12. package/.agents/skills/trip-moments/scripts/enhance.py +79 -0
  13. package/.agents/skills/trip-moments/scripts/exif_gps.py +170 -0
  14. package/.agents/skills/trip-moments/scripts/film.py +106 -0
  15. package/.agents/skills/trip-moments/scripts/findphotos.py +173 -0
  16. package/.agents/skills/trip-moments/scripts/grid9.py +134 -0
  17. package/.agents/skills/trip-moments/scripts/imquality.py +203 -0
  18. package/.agents/skills/trip-moments/scripts/polaroid.py +94 -0
  19. package/.agents/skills/trip-moments/scripts/poster.py +281 -0
  20. package/.agents/skills/trip-moments/scripts/requirements.txt +3 -0
  21. package/.agents/skills/trip-moments/scripts/retouch.py +112 -0
  22. package/.agents/skills/trip-moments/scripts/review.py +527 -0
  23. package/.agents/skills/trip-moments/scripts/route_map.py +337 -0
  24. package/.agents/skills/trip-moments/scripts/select9.py +389 -0
  25. package/.agents/skills/trip-moments/scripts/selftest.py +225 -0
  26. package/.agents/skills/trip-moments/scripts/styles.py +147 -0
  27. package/README.md +163 -0
  28. package/bin/install.js +399 -0
  29. package/install.cmd +42 -0
  30. package/install.ps1 +11 -0
  31. package/install.sh +12 -0
  32. 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())