front/xhs-agent-desktop/engine/xhs_bridge/ai_client.py
zzh 5a26ae0048 feat: 小红书模块迁移、登录重构与 xhs-agent-desktop 桌面 Agent
- 新增小红书前端模块(启动服务、内容分析、爆款创作、发布记录、技能卡片、拟人设置、AI 助手)

- 新增 xhs-agent-desktop 独立桌面 Agent,对接 OMS 任务调度

- 登录页重构,整合微信登录与 OMS JWT 认证

- 订单、渠道、用户中心等页面与 API 增强

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 16:13:13 +08:00

545 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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