feat(config): 新增多 LLM 提供商支持与账号数据看板

- 新增多 LLM 提供商管理功能,支持添加、删除和切换不同 API 提供商
- 新增账号数据看板,支持可视化展示用户核心指标和笔记点赞排行
- 新增自动获取并保存 xsec_token 功能,提升登录体验
- 新增退出登录功能,支持重新扫码登录
- 新增用户 ID 验证和保存功能,确保账号信息准确性

♻️ refactor(config): 重构配置管理和 LLM 服务调用

- 重构配置管理器,支持多 LLM 提供商配置和兼容旧配置自动迁移
- 重构 LLM 服务调用逻辑,统一从配置管理器获取激活的提供商信息
- 重构 MCP 客户端,增加单例模式和自动重试机制,提升连接稳定性
- 重构数据看板页面,优化用户数据获取和可视化展示逻辑

🐛 fix(mcp): 修复 MCP 连接和登录状态检查问题

- 修复 MCP 客户端初始化问题,避免重复握手
- 修复登录状态检查逻辑,自动获取并保存 xsec_token
- 修复获取我的笔记列表功能,支持通过用户 ID 准确获取
- 修复 JSON-RPC 通知格式问题,确保与 MCP 服务兼容

📝 docs(config): 更新配置文件和代码注释

- 更新配置文件结构,新增多 LLM 提供商配置字段
- 更新代码注释,明确各功能模块的作用和调用方式
- 更新用户界面提示信息,提供更清晰的操作指引
This commit is contained in:
zhoujie 2026-02-08 21:52:29 +08:00
parent 88faca150d
commit 88dfc09e2a
7 changed files with 737 additions and 211 deletions

10
config copy.json Normal file
View File

@ -0,0 +1,10 @@
{
"api_key": "sk-d212b926f51f4f0f9297629cd2ab77b4",
"base_url": "https://api.deepseek.com/v1",
"sd_url": "http://127.0.0.1:7860",
"mcp_url": "http://localhost:18060/mcp",
"model": "deepseek-reasoner",
"persona": "温柔知性的时尚博主",
"auto_reply_enabled": false,
"schedule_enabled": false
}

View File

@ -1,10 +1,25 @@
{ {
"api_key": "sk-d212b926f51f4f0f9297629cd2ab77b4", "api_key": "sk-NPZECL5m3BmZv0S9YO9KOd179pepRH08iYeAn1Tk07Jux9Br",
"base_url": "https://api.deepseek.com/v1", "base_url": "https://wolfai.top/v1",
"sd_url": "http://127.0.0.1:7860", "sd_url": "http://127.0.0.1:7860",
"mcp_url": "http://localhost:18060/mcp", "mcp_url": "http://localhost:18060/mcp",
"model": "deepseek-reasoner", "model": "gemini-3-flash-preview",
"persona": "温柔知性的时尚博主", "persona": "温柔知性的时尚博主",
"auto_reply_enabled": false, "auto_reply_enabled": false,
"schedule_enabled": false "schedule_enabled": false,
"my_user_id": "69872540000000002303cc42",
"active_llm": "wolfai",
"llm_providers": [
{
"name": "默认",
"api_key": "sk-d212b926f51f4f0f9297629cd2ab77b4",
"base_url": "https://api.deepseek.com/v1"
},
{
"name": "wolfai",
"api_key": "sk-NPZECL5m3BmZv0S9YO9KOd179pepRH08iYeAn1Tk07Jux9Br",
"base_url": "https://wolfai.top/v1"
}
],
"xsec_token": "ABS1TagQqhCpZmeNlq0VoCfNEyI6Q83GJzjTGJvzEAq5I="
} }

View File

@ -20,6 +20,9 @@ DEFAULT_CONFIG = {
"persona": "温柔知性的时尚博主", "persona": "温柔知性的时尚博主",
"auto_reply_enabled": False, "auto_reply_enabled": False,
"schedule_enabled": False, "schedule_enabled": False,
"my_user_id": "",
"active_llm": "",
"llm_providers": [],
} }
@ -80,3 +83,79 @@ class ConfigManager:
def ensure_workspace(self): def ensure_workspace(self):
"""确保工作空间目录存在""" """确保工作空间目录存在"""
os.makedirs(OUTPUT_DIR, exist_ok=True) os.makedirs(OUTPUT_DIR, exist_ok=True)
# ---------- 多 LLM 提供商管理 ----------
def get_llm_providers(self) -> list[dict]:
"""获取所有 LLM 提供商配置"""
providers = self._config.get("llm_providers", [])
# 兼容旧配置: 如果 providers 为空但有 api_key自动迁移
if not providers and self._config.get("api_key"):
default_provider = {
"name": "默认",
"api_key": self._config["api_key"],
"base_url": self._config.get("base_url", "https://api.openai.com/v1"),
}
providers = [default_provider]
self._config["llm_providers"] = providers
self._config["active_llm"] = "默认"
self.save()
return providers
def get_llm_provider_names(self) -> list[str]:
"""获取所有提供商名称列表"""
return [p["name"] for p in self.get_llm_providers()]
def get_active_llm(self) -> dict | None:
"""获取当前激活的 LLM 提供商配置"""
active_name = self._config.get("active_llm", "")
for p in self.get_llm_providers():
if p["name"] == active_name:
return p
# 没找到就返回第一个
providers = self.get_llm_providers()
return providers[0] if providers else None
def add_llm_provider(self, name: str, api_key: str, base_url: str) -> str:
"""添加一个 LLM 提供商,返回状态消息"""
name = name.strip()
if not name:
return "❌ 名称不能为空"
if not api_key.strip():
return "❌ API Key 不能为空"
providers = self.get_llm_providers()
for p in providers:
if p["name"] == name:
return f"❌ 名称「{name}」已存在,请换一个"
providers.append({
"name": name,
"api_key": api_key.strip(),
"base_url": (base_url or "https://api.openai.com/v1").strip().rstrip("/"),
})
self._config["llm_providers"] = providers
if not self._config.get("active_llm"):
self._config["active_llm"] = name
self.save()
return f"✅ 已添加「{name}"
def remove_llm_provider(self, name: str) -> str:
"""删除一个 LLM 提供商"""
providers = self.get_llm_providers()
new_providers = [p for p in providers if p["name"] != name]
if len(new_providers) == len(providers):
return f"⚠️ 未找到「{name}"
self._config["llm_providers"] = new_providers
if self._config.get("active_llm") == name:
self._config["active_llm"] = new_providers[0]["name"] if new_providers else ""
self.save()
return f"✅ 已删除「{name}"
def set_active_llm(self, name: str):
"""切换当前激活的 LLM 提供商"""
self._config["active_llm"] = name
# 同步到兼容字段
p = self.get_active_llm()
if p:
self._config["api_key"] = p["api_key"]
self._config["base_url"] = p["base_url"]
self.save()

View File

@ -127,6 +127,9 @@ class LLMService:
"Authorization": f"Bearer {self.api_key}", "Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json", "Content-Type": "application/json",
} }
if json_mode:
user_message = user_message + "\n请以json格式返回。"
payload = { payload = {
"model": self.model, "model": self.model,
"messages": [ "messages": [
@ -164,10 +167,18 @@ class LLMService:
"""获取可用模型列表""" """获取可用模型列表"""
url = f"{self.base_url}/models" url = f"{self.base_url}/models"
headers = {"Authorization": f"Bearer {self.api_key}"} headers = {"Authorization": f"Bearer {self.api_key}"}
resp = requests.get(url, headers=headers, timeout=10) try:
resp.raise_for_status() resp = requests.get(url, headers=headers, timeout=10)
data = resp.json() resp.raise_for_status()
return [item["id"] for item in data.get("data", [])] text = resp.text.strip()
if not text:
logger.warning("GET %s 返回空响应", url)
return []
data = resp.json()
return [item["id"] for item in data.get("data", [])]
except Exception as e:
logger.warning("获取模型列表失败 (%s): %s", url, e)
return []
def generate_copy(self, topic: str, style: str) -> dict: def generate_copy(self, topic: str, style: str) -> dict:
"""生成小红书文案""" """生成小红书文案"""

755
main.py

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,17 @@ logger = logging.getLogger(__name__)
MCP_DEFAULT_URL = "http://localhost:18060/mcp" MCP_DEFAULT_URL = "http://localhost:18060/mcp"
MCP_TIMEOUT = 60 # 秒 MCP_TIMEOUT = 60 # 秒
# 全局客户端缓存 —— 同一 URL 复用同一实例,避免反复 initialize
_client_cache: dict[str, "MCPClient"] = {}
def get_mcp_client(base_url: str = MCP_DEFAULT_URL) -> "MCPClient":
"""获取 MCP 客户端(单例),同一 URL 复用同一实例"""
if base_url not in _client_cache:
_client_cache[base_url] = MCPClient(base_url)
client = _client_cache[base_url]
return client
class MCPClient: class MCPClient:
"""小红书 MCP 服务的 HTTP 客户端封装""" """小红书 MCP 服务的 HTTP 客户端封装"""
@ -29,14 +40,22 @@ class MCPClient:
# ---------- 底层通信 ---------- # ---------- 底层通信 ----------
def _call(self, method: str, params: dict = None) -> dict: def _call(self, method: str, params: dict = None, *,
"""发送 JSON-RPC 请求到 MCP 服务""" is_notification: bool = False) -> dict:
"""发送 JSON-RPC 请求到 MCP 服务
Args:
is_notification: 若为 True 则不带 idJSON-RPC 通知
"""
payload = { payload = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"method": method, "method": method,
"params": params or {}, "params": params or {},
"id": str(uuid.uuid4()),
} }
# JSON-RPC 通知不带 id
if not is_notification:
payload["id"] = str(uuid.uuid4())
headers = {} headers = {}
if self._session_id: if self._session_id:
headers["mcp-session-id"] = self._session_id headers["mcp-session-id"] = self._session_id
@ -50,6 +69,11 @@ class MCPClient:
self._session_id = resp.headers["mcp-session-id"] self._session_id = resp.headers["mcp-session-id"]
resp.raise_for_status() resp.raise_for_status()
# 通知不一定有响应体
if is_notification:
return {"status": "notified"}
data = resp.json() data = resp.json()
if "error" in data: if "error" in data:
logger.error("MCP error: %s", data["error"]) logger.error("MCP error: %s", data["error"])
@ -74,19 +98,38 @@ class MCPClient:
"clientInfo": {"name": "xhs-autobot", "version": "2.0.0"} "clientInfo": {"name": "xhs-autobot", "version": "2.0.0"}
}) })
if "error" not in result: if "error" not in result:
# 发送 initialized 通知 # 发送 initialized 通知JSON-RPC 通知不带 id
self._call("notifications/initialized", {}) self._call("notifications/initialized", {},
is_notification=True)
self._initialized = True self._initialized = True
return result return result
return {"status": "already_initialized"} return {"status": "already_initialized"}
def _reset(self):
"""重置初始化状态(下次调用会重新握手)"""
self._initialized = False
self._session_id = None
def _call_tool(self, tool_name: str, arguments: dict = None) -> dict: def _call_tool(self, tool_name: str, arguments: dict = None) -> dict:
"""调用 MCP 工具""" """调用 MCP 工具400 错误时自动重试一次"""
self._ensure_initialized() self._ensure_initialized()
result = self._call("tools/call", { result = self._call("tools/call", {
"name": tool_name, "name": tool_name,
"arguments": arguments or {} "arguments": arguments or {}
}) })
# 如果返回 400 相关错误,重置并重试一次
if isinstance(result, dict) and "error" in result:
err_msg = str(result["error"])
if "400" in err_msg or "Bad Request" in err_msg:
logger.warning("MCP 400 错误,重置会话后重试: %s", tool_name)
self._reset()
self._ensure_initialized()
result = self._call("tools/call", {
"name": tool_name,
"arguments": arguments or {}
})
# 提取文本和图片内容 # 提取文本和图片内容
if isinstance(result, dict) and "content" in result: if isinstance(result, dict) and "content" in result:
texts = [] texts = []
@ -319,3 +362,9 @@ class MCPClient:
"user_id": user_id, "user_id": user_id,
"xsec_token": xsec_token, "xsec_token": xsec_token,
}) })
# ---------- 登录管理 ----------
def delete_cookies(self) -> dict:
"""删除 cookies重置登录状态"""
return self._call_tool("delete_cookies", {})

View File

@ -1,3 +1,4 @@
gradio>=4.0.0 gradio>=4.0.0
requests>=2.28.0 requests>=2.28.0
Pillow>=9.0.0 Pillow>=9.0.0
matplotlib>=3.5.0