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

@@ -6,6 +6,7 @@ import hashlib
from typing import Any, Optional
from fastapi import APIRouter, Request, HTTPException, BackgroundTasks
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from config import settings
@@ -75,6 +76,7 @@ class ChatwootWebhookPayload(BaseModel):
message_type: Optional[str] = None
content_type: Optional[str] = None
private: Optional[bool] = False
content_attributes: Optional[dict] = None # 图片搜索 URL 等额外属性
conversation: Optional[WebhookConversation] = None
sender: Optional[WebhookSender] = None
contact: Optional[WebhookContact] = None
@@ -132,7 +134,15 @@ async def handle_incoming_message(payload: ChatwootWebhookPayload, cookie_token:
conversation_id = str(conversation.id)
content = payload.content
if not content:
# 检查是否是图片搜索消息(在过滤空 content 之前)
# 图片搜索消息的 content 通常是空的,但需要处理
is_image_search = (
payload.content_type == "search_image" or
payload.content_type == 17
)
# 只有非图片搜索消息才检查 content 是否为空
if not content and not is_image_search:
logger.debug("Empty message content, skipping")
return
@@ -282,19 +292,7 @@ async def handle_incoming_message(payload: ChatwootWebhookPayload, cookie_token:
channel=conversation.channel if conversation else None
)
# 识别消息渠道(邮件、网站等)
message_channel = conversation.channel if conversation else "Channel"
is_email = message_channel == "Email"
# 邮件渠道特殊处理
if is_email:
logger.info(
"Email channel detected",
conversation_id=conversation_id,
sender_email=contact.email if contact else None
)
# Load conversation context from cache
# Load conversation context from cache (需要在图片搜索检测之前)
cache = get_cache_manager()
await cache.connect()
@@ -307,10 +305,125 @@ async def handle_incoming_message(payload: ChatwootWebhookPayload, cookie_token:
if mall_token:
context["mall_token"] = mall_token
# 图片 URL 可能在两个位置:
# 1. payload.content_attributes.url (顶层)
# 2. payload.conversation.messages[0].content_attributes.url (嵌套)
image_url = None
# 首先尝试从顶层获取
if payload.content_attributes:
image_url = payload.content_attributes.get("url")
# 如果顶层没有,尝试从 conversation.messages[0] 获取
if not image_url and conversation and hasattr(conversation, 'model_dump'):
conv_dict = conversation.model_dump()
messages = conv_dict.get('messages', [])
if messages and len(messages) > 0:
first_message = messages[0]
msg_content_attrs = first_message.get('content_attributes', {})
image_url = msg_content_attrs.get('url')
if is_image_search and image_url:
logger.info(
"Image search detected",
conversation_id=conversation_id,
image_url=image_url[:100] + "..." if len(image_url) > 100 else image_url
)
# 创建 Chatwoot client
from integrations.chatwoot import ChatwootClient
chatwoot = ChatwootClient(account_id=int(account_id))
# 开启 typing status
try:
await chatwoot.toggle_typing_status(
conversation_id=conversation.id,
typing_status="on"
)
except Exception as e:
logger.warning(
"Failed to enable typing status for image search",
conversation_id=conversation_id,
error=str(e)
)
# 直接调用图片搜索工具
# 修改 content 为图片搜索指令
search_content = f"[图片搜索] 请根据这张图片搜索相似的商品"
# 使用特殊标记让 Product Agent 知道这是图片搜索
context["image_search_url"] = image_url
final_state = await process_message(
conversation_id=conversation_id,
user_id=user_id,
account_id=account_id,
message=search_content,
history=history,
context=context,
user_token=user_token,
mall_token=mall_token
)
# 获取响应并发送
response = final_state.get("response", "")
if response:
await chatwoot.send_message(
conversation_id=conversation.id,
content=response
)
logger.info(
"Image search response sent",
conversation_id=conversation_id,
response_length=len(response)
)
# 关闭 typing status
await chatwoot.toggle_typing_status(
conversation_id=conversation.id,
typing_status="off"
)
await chatwoot.close()
# Update cache
await cache.add_message(conversation_id, "user", search_content)
await cache.add_message(conversation_id, "assistant", response)
# Save context
new_context = final_state.get("context", {})
new_context["last_intent"] = final_state.get("intent")
await cache.set_context(conversation_id, new_context)
logger.info(
"Image search message processed successfully",
conversation_id=conversation_id,
intent=final_state.get("intent")
)
return JSONResponse(
content={"status": "success"},
status_code=200
)
# 识别消息渠道(邮件、网站等)
message_channel = conversation.channel if conversation else "Channel"
is_email = message_channel == "Email"
# 添加渠道信息到 context让 Agent 知道是邮件还是网站)
context["channel"] = message_channel
context["is_email"] = is_email
# 邮件渠道特殊处理
if is_email:
logger.info(
"Email channel detected",
conversation_id=conversation_id,
sender_email=contact.email if contact else None
)
# 创建 Chatwoot client提前创建以便开启 typing status
from integrations.chatwoot import ChatwootClient
chatwoot = ChatwootClient(account_id=int(account_id))