- 新增小红书前端模块(启动服务、内容分析、爆款创作、发布记录、技能卡片、拟人设置、AI 助手) - 新增 xhs-agent-desktop 独立桌面 Agent,对接 OMS 任务调度 - 登录页重构,整合微信登录与 OMS JWT 认证 - 订单、渠道、用户中心等页面与 API 增强 Co-authored-by: Cursor <cursoragent@cursor.com>
545 lines
17 KiB
Python
545 lines
17 KiB
Python
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
|