feat: 添加图片搜索功能和 Qwen 模型支持

图片搜索功能(以图搜图):
- Chatwoot webhook 检测图片搜索消息 (content_type="search_image")
- 从 content_attributes.url 提取图片 URL
- 调用 Mall API 图片搜索接口 (/mall/api/spu?searchImageUrl=...)
- 支持嵌套和顶层 URL 位置提取
- Product Agent 添加 fast path 直接调用图片搜索工具
- 防止无限循环(使用后清除 context.image_search_url)

Qwen 模型支持:
- 添加 LLM provider 选择(zhipu/qwen)
- 实现 QwenLLMClient 类(基于 DashScope SDK)
- 添加 dashscope>=1.14.0 依赖
- 修复 API key 设置(直接设置 dashscope.api_key)
- 更新 .env.example 和 docker-compose.yml 配置

其他优化:
- 重构 Chatwoot 集成代码(删除冗余)
- 优化 Product Agent prompt
- 增强 Customer Service Agent 多语言支持

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
wangliang
2026-01-27 19:10:06 +08:00
parent 754804219f
commit 965b11316e
12 changed files with 937 additions and 199 deletions

View File

@@ -1,8 +1,9 @@
"""
ZhipuAI LLM Client for B2B Shopping AI Assistant
LLM Client for B2B Shopping AI Assistant
Supports both ZhipuAI and Qwen (DashScope)
"""
import concurrent.futures
from typing import Any, Optional
from typing import Any, Optional, Union
from dataclasses import dataclass
from zhipuai import ZhipuAI
@@ -276,12 +277,168 @@ class ZhipuLLMClient:
raise
llm_client: Optional[ZhipuLLMClient] = None
llm_client: Optional[Union[ZhipuLLMClient, "QwenLLMClient"]] = None
def get_llm_client() -> ZhipuLLMClient:
"""Get or create global LLM client instance"""
def get_llm_client() -> Union[ZhipuLLMClient, "QwenLLMClient"]:
"""Get or create global LLM client instance based on provider setting"""
global llm_client
if llm_client is None:
llm_client = ZhipuLLMClient()
provider = settings.llm_provider.lower()
if provider == "qwen":
llm_client = QwenLLMClient()
else:
llm_client = ZhipuLLMClient()
return llm_client
# ============ Qwen (DashScope) LLM Client ============
try:
from dashscope import Generation
DASHSCOPE_AVAILABLE = True
except ImportError:
DASHSCOPE_AVAILABLE = False
logger.warning("DashScope SDK not installed. Qwen models will not be available.")
class QwenLLMClient:
"""Qwen (DashScope) LLM Client wrapper"""
DEFAULT_TIMEOUT = 60 # seconds
def __init__(
self,
api_key: Optional[str] = None,
model: Optional[str] = None,
timeout: Optional[int] = None
):
if not DASHSCOPE_AVAILABLE:
raise ImportError("DashScope SDK is not installed. Install it with: pip install dashscope")
self.api_key = api_key or settings.qwen_api_key
self.model = model or settings.qwen_model
self.timeout = timeout or self.DEFAULT_TIMEOUT
# 设置 API key 到 DashScope SDK
# 必须直接设置 dashscope.api_key环境变量可能不够
import dashscope
dashscope.api_key = self.api_key
logger.info(
"Qwen client initialized",
model=self.model,
timeout=self.timeout,
api_key_prefix=self.api_key[:10] + "..." if len(self.api_key) > 10 else self.api_key
)
async def chat(
self,
messages: list[Message],
temperature: float = 0.7,
max_tokens: int = 2048,
top_p: float = 0.9,
use_cache: bool = True,
**kwargs: Any
) -> LLMResponse:
"""Send chat completion request with caching support"""
formatted_messages = [
{"role": msg.role, "content": msg.content}
for msg in messages
]
# Try cache first
if use_cache:
try:
cache = get_response_cache()
cached_response = await cache.get(
model=self.model,
messages=formatted_messages,
temperature=temperature
)
if cached_response is not None:
logger.info(
"Returning cached response",
model=self.model,
response_length=len(cached_response)
)
return LLMResponse(
content=cached_response,
finish_reason="cache_hit",
usage={"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
)
except Exception as e:
logger.warning("Cache check failed", error=str(e))
logger.info(
"Sending chat request",
model=self.model,
message_count=len(messages),
temperature=temperature
)
def _make_request():
response = Generation.call(
model=self.model,
messages=formatted_messages,
temperature=temperature,
max_tokens=max_tokens,
top_p=top_p,
result_format="message",
**kwargs
)
return response
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(_make_request)
response = future.result(timeout=self.timeout)
# Qwen API 响应格式
if response.status_code != 200:
raise Exception(f"Qwen API error: {response.message}")
content = response.output.choices[0].message.content
finish_reason = response.output.choices[0].finish_reason
usage = response.usage
logger.info(
"Chat response received",
finish_reason=finish_reason,
content_length=len(content) if content else 0,
usage=usage
)
if not content:
logger.warning("LLM returned empty content")
# Cache the response
if use_cache and content:
try:
cache = get_response_cache()
await cache.set(
model=self.model,
messages=formatted_messages,
response=content,
temperature=temperature
)
except Exception as e:
logger.warning("Failed to cache response", error=str(e))
return LLMResponse(
content=content or "",
finish_reason=finish_reason,
usage={
"prompt_tokens": usage.input_tokens,
"completion_tokens": usage.output_tokens,
"total_tokens": usage.total_tokens
}
)
except concurrent.futures.TimeoutError:
logger.error("Chat request timed out", timeout=self.timeout)
raise TimeoutError(f"Request timed out after {self.timeout} seconds")
except Exception as e:
logger.error("Chat request failed", error=str(e))
raise