from __future__ import annotations import json import re import uuid from pathlib import Path from typing import Any import requests from xhs_bridge.ai_provider_catalog import ( ZHIPU_CHAT_MODEL, ZHIPU_IMAGE_MODEL, ZHIPU_IMAGE_SIZE, ZHIPU_VISION_MODEL, default_model, is_vision_model, uses_zhipu_sdk, ) from xhs_bridge.ai_providers_store import AiProvider from xhs_bridge import zhipu_request_log as zlog _JSON_BLOCK_RE = re.compile(r"\{[\s\S]*\}", re.MULTILINE) _SYSTEM_NOTE = ( "你是 LIKE AI 小红书爆款内容创作助手。" "严格按用户要求输出 JSON,不要 markdown 代码块外的解释。" ) _SYSTEM_ANALYSIS = ( "你是 LIKE AI 内容分析助手。" "根据用户提供的爆款技能配方与笔记素材,输出清晰、可执行的中文分析(可用 Markdown 小标题)。" ) _SYSTEM_IMAGE_PROMPT = ( "你是小红书配图助手。" "根据笔记标题与正文,输出适合文生图模型使用的中文画面描述,简洁具体,单段,不超过 120 字。" ) _SYSTEM_VIDEO_SCRIPT = ( "你是小红书短视频策划助手。" "根据笔记信息输出视频分镜脚本(Markdown),包含镜头、画面、口播要点,便于用户拍摄或剪辑。" ) def _chat_url(base_url: str) -> str: base = (base_url or "").strip().rstrip("/") if not base: raise ValueError("请求地址为空") if base.endswith("/chat/completions"): return base if base.endswith("/v1"): return f"{base}/chat/completions" return f"{base}/v1/chat/completions" def _resolve_model(provider: AiProvider, *, model: str | None = None) -> str: if (model or "").strip(): return model.strip() secret = (provider.api_secret or "").strip() if uses_zhipu_sdk(provider.provider_type): if ( secret and secret not in (ZHIPU_IMAGE_MODEL, ZHIPU_VISION_MODEL) and not secret.startswith("sk-") ): return secret return default_model(provider.provider_type) if secret and not secret.startswith("sk-") and len(secret) < 80: return secret return default_model(provider.provider_type) def _zhipu_chat_completions_url(base_url: str) -> str: base = (base_url or "https://open.bigmodel.cn/api/paas/v4").strip().rstrip("/") if base.endswith("/chat/completions"): return base if base.endswith("/v4") or base.endswith("/v1"): return f"{base}/chat/completions" return f"{base}/chat/completions" def _build_vision_user_content(prompt: str, image_urls: list[str]) -> list[dict[str, Any]]: parts: list[dict[str, Any]] = [] for url in image_urls: parts.append({"type": "image_url", "image_url": {"url": url}}) parts.append({"type": "text", "text": prompt}) return parts def _invoke_zhipu_vision( provider: AiProvider, prompt: str, image_urls: list[str], *, system_prompt: str, timeout: int, model: str | None = None, ) -> str: api_key = (provider.api_key or "").strip() if not api_key: raise ValueError("「智谱」未配置 API Key") url = _zhipu_chat_completions_url(provider.base_url) headers = { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", } user_content: str | list[dict[str, Any]] if image_urls: user_content = _build_vision_user_content(prompt, image_urls) else: user_content = prompt resolved_model = _resolve_model(provider, model=model or ZHIPU_VISION_MODEL) payload = { "model": resolved_model, "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_content}, ], "temperature": 0.7, } title = zlog.log_request( provider, "chat/completions (图文)", { "url": url, "headers": { "Authorization": f"Bearer {zlog.mask_api_key(api_key)}", "Content-Type": "application/json", }, "body": { **payload, "messages": [ {"role": "system", "content": f"<{len(system_prompt)} 字符>"}, { "role": "user", "content": ( f"<图文 {len(image_urls)} 张 + 文本 {len(prompt)} 字符>" if image_urls else f"<文本 {len(prompt)} 字符>" ), }, ], }, "image_urls": image_urls, }, ) try: resp = requests.post(url, headers=headers, json=payload, timeout=timeout) if resp.status_code >= 400: zlog.log_error(title, f"HTTP {resp.status_code}: {resp.text[:500]}") raise ValueError(f"智谱图文分析失败 ({resp.status_code}): {resp.text[:500]}") data = resp.json() zlog.log_response(title, data) choices = data.get("choices") or [] if not choices: raise ValueError("智谱 AI 返回无 choices") message = choices[0].get("message") or {} content = str(message.get("content") or "").strip() if not content: raise ValueError("智谱 AI 返回内容为空") return content except ValueError: raise except Exception as exc: zlog.log_error(title, exc) raise def _invoke_zhipu( provider: AiProvider, prompt: str, *, system_prompt: str, timeout: int, model: str | None = None, ) -> str: try: from zai import ZhipuAiClient except ImportError as exc: raise ValueError("未安装 zai-sdk,请执行: pip install zai-sdk") from exc api_key = (provider.api_key or "").strip() if not api_key: raise ValueError("「智谱」未配置 API Key") kwargs: dict[str, Any] = {"api_key": api_key} base_url = (provider.base_url or "").strip() if base_url: kwargs["base_url"] = base_url.rstrip("/") client = ZhipuAiClient(**kwargs) resolved_model = _resolve_model(provider, model=model) messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": prompt}, ] title = zlog.log_request( provider, "chat/completions", { "model": resolved_model, "messages": messages, "temperature": 0.7, "timeout": timeout, }, ) try: response = client.chat.completions.create( model=resolved_model, messages=messages, temperature=0.7, timeout=timeout, ) zlog.log_response(title, response) choices = getattr(response, "choices", None) or [] if not choices: raise ValueError("智谱 AI 返回无 choices") message = getattr(choices[0], "message", None) content = str(getattr(message, "content", None) or "").strip() if message else "" if not content: raise ValueError("智谱 AI 返回内容为空") return content except ValueError: raise except Exception as exc: zlog.log_error(title, exc) raise def _invoke_openai_compatible( provider: AiProvider, prompt: str, *, system_prompt: str, timeout: int, model: str | None = None, ) -> str: url = _chat_url(provider.base_url) headers = { "Authorization": f"Bearer {provider.api_key.strip()}", "Content-Type": "application/json", } payload = { "model": _resolve_model(provider, model=model), "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": prompt}, ], "temperature": 0.7, } resp = requests.post(url, headers=headers, json=payload, timeout=timeout) if resp.status_code >= 400: raise ValueError(f"AI 请求失败 ({resp.status_code}): {resp.text[:500]}") data = resp.json() choices = data.get("choices") or [] if not choices: raise ValueError("AI 返回无 choices") message = choices[0].get("message") or {} content = str(message.get("content") or "").strip() if not content: raise ValueError("AI 返回内容为空") return content def _invoke_chat( provider: AiProvider, prompt: str, *, system_prompt: str, timeout: int = 120, model: str | None = None, image_urls: list[str] | None = None, ) -> str: resolved = _resolve_model(provider, model=model) if ( image_urls and uses_zhipu_sdk(provider.provider_type) and is_vision_model(resolved) ): return _invoke_zhipu_vision( provider, prompt, image_urls, system_prompt=system_prompt, timeout=timeout, model=resolved, ) if uses_zhipu_sdk(provider.provider_type): return _invoke_zhipu( provider, prompt, system_prompt=system_prompt, timeout=timeout, model=model ) if not (provider.base_url or "").strip(): raise ValueError(f"「{provider.type_label}」未配置请求地址") return _invoke_openai_compatible( provider, prompt, system_prompt=system_prompt, timeout=timeout, model=model ) def _parse_content_json(text: str) -> dict[str, Any]: text = (text or "").strip() if not text: raise ValueError("AI 返回内容为空") try: data = json.loads(text) if isinstance(data, dict): return data except json.JSONDecodeError: pass match = _JSON_BLOCK_RE.search(text) if match: data = json.loads(match.group(0)) if isinstance(data, dict): return data raise ValueError("无法解析 AI 返回的 JSON,请检查模型输出格式") def _normalize_note_payload(parsed: dict[str, Any]) -> dict[str, Any]: title = str(parsed.get("title") or "").strip() body = str(parsed.get("content") or parsed.get("body") or "").strip() tags_raw = parsed.get("tags") or [] tags: list[str] = [] if isinstance(tags_raw, list): tags = [str(t).strip().lstrip("#") for t in tags_raw if str(t).strip()] elif isinstance(tags_raw, str): tags = [t.strip().lstrip("#") for t in re.split(r"[,,\s]+", tags_raw) if t.strip()] if not title and not body: raise ValueError("AI 未返回有效的 title / content") return {"title": title, "content": body, "tags": tags} def generate_ai_text( provider: AiProvider, prompt: str, *, timeout: int = 180, system_prompt: str | None = None, model: str | None = None, image_urls: list[str] | None = None, ) -> str: """调用 AI 返回纯文本(用于分析等场景)。""" if not (provider.api_key or "").strip(): raise ValueError(f"「{provider.type_label}」未配置 API Key") return _invoke_chat( provider, prompt, system_prompt=system_prompt or _SYSTEM_ANALYSIS, timeout=timeout, model=model, image_urls=image_urls, ) def generate_note_content( provider: AiProvider, prompt: str, *, timeout: int = 120, model: str | None = None, ) -> dict[str, Any]: """按平台类型调用 AI 生成笔记 JSON。""" raw = generate_ai_text( provider, prompt, timeout=timeout, system_prompt=_SYSTEM_NOTE, model=model ) return _normalize_note_payload(_parse_content_json(raw)) def build_image_prompt_from_note(title: str, content: str) -> str: return ( f"标题:{title or '(无)'}\n" f"正文:{content or '(无)'}\n\n" "请生成 1 条小红书封面配图的画面描述。" ) def build_video_script_prompt(title: str, content: str) -> str: return ( f"标题:{title or '(无)'}\n" f"正文/要点:{content or '(无)'}\n\n" "请输出 30~60 秒小红书视频的分镜脚本。" ) def generate_image_description( provider: AiProvider, title: str, content: str, *, timeout: int = 120, model: str | None = None, ) -> str: prompt = build_image_prompt_from_note(title, content) text_model = model if uses_zhipu_sdk(provider.provider_type) and text_model == ZHIPU_IMAGE_MODEL: text_model = ZHIPU_CHAT_MODEL return generate_ai_text( provider, prompt, timeout=timeout, system_prompt=_SYSTEM_IMAGE_PROMPT, model=text_model, ).strip() def generate_video_script( provider: AiProvider, title: str, content: str, *, timeout: int = 180, model: str | None = None, ) -> str: prompt = build_video_script_prompt(title, content) return generate_ai_text( provider, prompt, timeout=timeout, system_prompt=_SYSTEM_VIDEO_SCRIPT, model=model, ).strip() def _images_api_url(base_url: str) -> str: base = (base_url or "https://open.bigmodel.cn/api/paas/v4").strip().rstrip("/") if base.endswith("/images/generations"): return base if base.endswith("/v4"): return f"{base}/images/generations" if base.endswith("/v1"): return f"{base}/images/generations" return f"{base}/v4/images/generations" def _download_image_url(url: str, dest: Path, *, timeout: int = 120) -> Path: resp = requests.get(url, timeout=timeout) resp.raise_for_status() dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(resp.content) return dest def generate_image_files( provider: AiProvider, image_prompt: str, output_dir: Path, *, count: int = 1, timeout: int = 180, model: str | None = None, ) -> list[str]: """文生图并保存到本地,返回绝对路径列表。智谱默认使用 glm-image。""" if not (provider.api_key or "").strip(): raise ValueError(f"「{provider.type_label}」未配置 API Key") output_dir.mkdir(parents=True, exist_ok=True) count = max(1, min(count, 4)) saved: list[str] = [] if uses_zhipu_sdk(provider.provider_type): try: from zai import ZhipuAiClient except ImportError as exc: raise ValueError("未安装 zai-sdk,请执行: pip install zai-sdk") from exc kwargs: dict[str, Any] = {"api_key": provider.api_key.strip()} base_url = (provider.base_url or "").strip() if base_url: kwargs["base_url"] = base_url.rstrip("/") client = ZhipuAiClient(**kwargs) image_model = (model or "").strip() or ZHIPU_IMAGE_MODEL for _ in range(count): title = zlog.log_request( provider, "images/generations", { "model": image_model, "prompt": image_prompt, "size": ZHIPU_IMAGE_SIZE, "timeout": timeout, }, ) try: response = client.images.generations( model=image_model, prompt=image_prompt, size=ZHIPU_IMAGE_SIZE, timeout=timeout, ) zlog.log_response(title, response) except Exception as exc: zlog.log_error(title, exc) raise ValueError(f"智谱文生图失败: {exc}") from exc data = getattr(response, "data", None) or [] if not data: raise ValueError("智谱文生图返回为空") item = data[0] url = str(getattr(item, "url", "") or "").strip() if not url: raise ValueError("智谱文生图未返回图片 URL") name = f"xhs_{uuid.uuid4().hex[:10]}.png" dl_title = zlog.log_request(provider, "下载生成图", {"url": url}) try: path = _download_image_url(url, output_dir / name, timeout=timeout) zlog.log_response( dl_title, {"saved_path": str(path.resolve()), "size_bytes": path.stat().st_size}, ) except Exception as exc: zlog.log_error(dl_title, exc) raise saved.append(str(path.resolve())) return saved url = _images_api_url(provider.base_url) headers = { "Authorization": f"Bearer {provider.api_key.strip()}", "Content-Type": "application/json", } image_model = (model or "").strip() or (provider.api_secret or "").strip() or "dall-e-3" for _ in range(count): payload = {"model": image_model, "prompt": image_prompt, "n": 1} resp = requests.post(url, headers=headers, json=payload, timeout=timeout) if resp.status_code >= 400: raise ValueError(f"文生图失败 ({resp.status_code}): {resp.text[:400]}") body = resp.json() data = body.get("data") or [] if not data: raise ValueError("文生图返回无 data") item = data[0] img_url = str(item.get("url") or "").strip() b64 = str(item.get("b64_json") or "").strip() name = f"xhs_{uuid.uuid4().hex[:10]}.png" dest = output_dir / name if img_url: path = _download_image_url(img_url, dest, timeout=timeout) elif b64: import base64 dest.write_bytes(base64.b64decode(b64)) path = dest else: raise ValueError("文生图结果无 url / b64_json") saved.append(str(path.resolve())) return saved