diff --git a/CHATWOOT_INITIALIZATION.md b/CHATWOOT_INITIALIZATION.md
new file mode 100644
index 0000000..0736861
--- /dev/null
+++ b/CHATWOOT_INITIALIZATION.md
@@ -0,0 +1,249 @@
+# Chatwoot Widget 初始化流程说明
+
+## 🔍 当前的初始化流程
+
+### 1. 页面首次加载(游客模式)
+
+**服务器端 (Ruby ERB):**
+```erb
+<%
+ token_present = cookies[:token].present? # false
+ test_user_id = nil # 没有用户 ID
+ test_user_hash = nil
+%>
+```
+
+**前端 JavaScript:**
+```javascript
+// 服务器端渲染后
+const TEST_USER = {
+ userId: '', // 空字符串
+ userHash: '', // 空字符串
+ ...
+};
+
+const token = getCookie('token'); // null
+if (token) {
+ // 不执行
+}
+
+const widgetConfig = {
+ websiteToken: 'xxx',
+ baseUrl: '',
+ locale: 'zh_CN',
+ useBrowserLanguage: false
+ // 没有 userIdentifier
+};
+
+window.chatwootSDK.run(widgetConfig);
+// ✅ Widget 初始化成功(游客模式)
+```
+
+### 2. 点击登录(不刷新)
+
+```javascript
+function simulateLogin() {
+ setCookie('token', TEST_TOKEN); // 设置 cookie
+ isLoggedIn = true;
+ currentUser = TEST_USER;
+
+ // 调用 setUser 设置用户信息
+ window.$chatwoot.setUser(currentUser.userId, {
+ identifier_hash: currentUser.userHash,
+ email: currentUser.email,
+ name: currentUser.name,
+ ...
+ });
+ // ✅ 用户信息已设置
+ // ⚠️ 但 widget 仍然是游客模式初始化的!
+}
+```
+
+### 3. 刷新页面(登录后)
+
+**服务器端 (Ruby ERB):**
+```erb
+<%
+ token_present = cookies[:token].present? # ✅ true
+ test_user_id = '211845' # ✅ 有用户 ID
+ test_user_hash = OpenSSL::HMAC.hexdigest(...)
+%>
+```
+
+**前端 JavaScript:**
+```javascript
+// 服务器端渲染后
+const TEST_USER = {
+ userId: '211845', # ✅ 正确的用户 ID
+ userHash: 'xxx...', # ✅ 正确的 hash
+ ...
+};
+
+const token = getCookie('token'); // ✅ 存在
+if (token) {
+ const widgetConfig = {
+ websiteToken: 'xxx',
+ baseUrl: '',
+ locale: 'zh_CN',
+ useBrowserLanguage: false,
+ userIdentifier: '211845' # ✅ 设置用户标识
+ };
+
+ window.chatwootSDK.run(widgetConfig);
+ // ❌ 问题:window.$chatwoot 已经存在!
+ // ❌ SDK 内部会 return,不会重新初始化!
+}
+```
+
+## ⚠️ 核心问题
+
+**Chatwoot SDK 只能初始化一次!**
+
+```javascript
+// app/javascript/entrypoints/sdk.js:21-24
+runSDK({ baseUrl, websiteToken }) {
+ if (window.$chatwoot) {
+ return; // ❌ 已经初始化过,直接返回
+ }
+ // ... 初始化代码
+}
+```
+
+## 💡 解决方案
+
+### 方案 1:登录时强制刷新(推荐)
+
+```javascript
+function simulateLogin() {
+ setCookie('token', TEST_TOKEN);
+ isLoggedIn = true;
+ currentUser = TEST_USER;
+
+ // 立即刷新页面,让 SDK 正确初始化
+ addLog('💡 即将刷新页面以应用登录状态...');
+ setTimeout(() => {
+ location.reload();
+ }, 500);
+}
+```
+
+**优点:**
+- 简单可靠
+- SDK 会正确初始化
+- userIdentifier 在初始化时就设置
+
+**缺点:**
+- 需要刷新页面
+- 用户体验略差
+
+### 方案 2:在登录前先 reset
+
+```javascript
+function simulateLogin() {
+ // 先清除旧的 SDK 实例
+ if (window.$chatwoot && window.$chatwoot.reset) {
+ window.$chatwoot.reset();
+ }
+
+ // 等待 reset 完成
+ setTimeout(() => {
+ setCookie('token', TEST_TOKEN);
+ isLoggedIn = true;
+ currentUser = TEST_USER;
+
+ // 重新初始化
+ const widgetConfig = {
+ websiteToken: '<%= @web_widget.website_token %>',
+ baseUrl: '',
+ locale: 'zh_CN',
+ useBrowserLanguage: false,
+ userIdentifier: currentUser.userId
+ };
+
+ window.chatwootSDK.run(widgetConfig);
+ // ...
+ }, 100);
+}
+```
+
+**问题:**
+- SDK 已经初始化检查在 `run()` 函数内部
+- 无法绕过这个检查
+
+### 方案 3:使用 iframe 的方式(不推荐)
+
+每次都创建新的 iframe,但这样会失去状态同步。
+
+## ✅ 推荐的实现
+
+**登录时自动刷新页面:**
+
+```javascript
+function simulateLogin() {
+ addLog('🔐 开始模拟用户登录...');
+
+ // 设置 token cookie
+ setCookie('token', TEST_TOKEN);
+
+ // 验证设置成功
+ const tokenCheck = getCookie('token');
+ if (tokenCheck) {
+ addLog('✅ Token 已设置');
+ addLog('⏳ 即将刷新页面以完成登录...');
+
+ // 显示倒计时
+ let countdown = 2;
+ const countdownInterval = setInterval(() => {
+ countdown--;
+ if (countdown <= 0) {
+ clearInterval(countdownInterval);
+ location.reload();
+ }
+ }, 1000);
+ } else {
+ addLog('❌ Token 设置失败');
+ }
+}
+```
+
+## 📊 完整流程
+
+### 游客 → 登录:
+```
+1. 游客模式访问
+2. 点击登录
+3. 设置 token
+4. 自动刷新(2秒倒计时)
+5. Ruby 检测到 token,设置 user_id = '211845'
+6. SDK 初始化(带 userIdentifier)
+7. ✅ 登录成功
+```
+
+### 登录 → 退出:
+```
+1. 点击退出
+2. 删除 token
+3. 调用 reset() 清除所有数据
+4. ✅ 立即切换到游客模式
+```
+
+### 退出 → 登录:
+```
+1. 游客模式
+2. 点击登录
+3. 设置 token
+4. 自动刷新
+5. ✅ 恢复登录状态(新会话)
+```
+
+## 🎯 总结
+
+**Chatwoot SDK 的设计限制:**
+- SDK 只能初始化一次
+- 初始化后无法动态更改 userIdentifier
+- 必须刷新页面才能重新初始化
+
+**最佳实践:**
+- **登录/退出时都刷新页面**
+- 服务器端检查 token 来决定初始状态
+- 确保每次刷新都能正确初始化
diff --git a/TEST_SEARCH_IMAGE.md b/TEST_SEARCH_IMAGE.md
new file mode 100644
index 0000000..8c21819
--- /dev/null
+++ b/TEST_SEARCH_IMAGE.md
@@ -0,0 +1,118 @@
+# 测试 search_image 消息发送
+
+## 方法 1: 使用 Rails Console(推荐)
+
+在你的终端中执行:
+
+```bash
+cd /home/lxj/project/chatwoot-develop
+
+# 确保已初始化 rbenv
+eval "$(rbenv init -)"
+
+# 启动 Rails console
+bundle exec rails c
+```
+
+然后在 Rails console 中复制粘贴以下代码:
+
+```ruby
+# 配置参数
+ACCOUNT_ID = 2
+INBOX_ID = 1
+USER_IDENTIFIER = '211845'
+
+# 1. 查找联系人
+contact = Contact.find_by(identifier: USER_IDENTIFIER)
+
+if contact.nil?
+ puts "❌ 联系人不存在,请先通过 widget 创建会话"
+else
+ puts "✅ 联系人: #{contact.name} (ID: #{contact.id})"
+
+ # 2. 获取会话
+ conversation = Conversation.where(
+ contact_id: contact.id,
+ inbox_id: INBOX_ID
+ ).order(created_at: :desc).first
+
+ if conversation.nil?
+ puts "❌ 会话不存在"
+ else
+ puts "✅ 会话: #{conversation.display_id}"
+
+ # 3. 创建 search_image 消息
+ test_image_url = "https://img.gaia888.com/image/www/test_#{SecureRandom.uuid}.jpg"
+
+ message = Message.create!(
+ account_id: ACCOUNT_ID,
+ conversation_id: conversation.id,
+ inbox_id: INBOX_ID,
+ sender: contact,
+ sender_type: 'Contact',
+ sender_id: contact.id,
+ message_type: :incoming,
+ content_type: :search_image,
+ content: '',
+ content_attributes: {
+ url: test_image_url
+ },
+ status: :sent
+ )
+
+ puts "\n✅ 消息创建成功!"
+ puts " Message ID: #{message.id}"
+ puts " content_type: #{message.content_type}"
+ puts " URL: #{test_image_url}"
+ end
+end
+```
+
+## 方法 2: 使用 Rails Runner
+
+```bash
+cd /home/lxj/project/chatwoot-develop
+eval "$(rbenv init -)"
+bundle exec rails runner test_search_image_console.rb
+```
+
+## 预期结果
+
+成功后会看到:
+```
+✅ 联系人: 测试用户 211845 (ID: 24)
+✅ 会话: 16
+
+✅ 消息创建成功!
+ Message ID: 427
+ content_type: search_image
+ URL: https://img.gaia888.com/image/www/test/...
+```
+
+## 验证消息
+
+1. **Dashboard 查看**:
+ - 访问: http://localhost:3000/app/accounts/2/inbox/1/conversations/16
+ - 应该看到新发送的图片消息
+
+2. **Webhook 日志**:
+ - 查看 webhook 接收到的数据
+ - 应该看到 content_type: "search_image"
+
+3. **Widget 查看**:
+ - 刷新 /widget_tests 页面
+ - 应该看到图片显示在聊天界面
+
+## 错误排查
+
+### 错误: 联系人不存在
+**原因**: 还没有通过 widget 创建过会话
+**解决**: 先访问 /widget_tests 页面并发送一条消息
+
+### 错误: 会话不存在
+**原因**: 联系人没有在此 inbox 中的会话
+**解决**: 先通过 widget 发送消息创建会话
+
+### 错误: content_type 未知
+**原因**: 数据库没有 search_image 枚举值
+**解决**: 运行迁移添加 search_image 到 content_type 枚举
diff --git a/TEST_WEBHOOK_SEARCH_IMAGE.md b/TEST_WEBHOOK_SEARCH_IMAGE.md
new file mode 100644
index 0000000..28a392c
--- /dev/null
+++ b/TEST_WEBHOOK_SEARCH_IMAGE.md
@@ -0,0 +1,104 @@
+# 测试 search_image 消息发送到 Webhook
+
+## 方法 1: 使用测试脚本(推荐)
+
+```bash
+cd /home/lxj/project/chatwoot-develop
+./test_search_image_webhook.sh
+```
+
+## 方法 2: 直接执行 curl 命令
+
+复制以下命令到终端执行:
+
+```bash
+curl -X POST "http://localhost:3000/api/v1/widget/messages?website_token=9n9D3JFHBorFTZLD7cQ49TMg&cw_conversation=eyJhbGciOiJIUzI1NiJ9.eyJzb3VyY2VfaWQiOiI2Njc1ZGY3Ni1jM2MxLTQwMjktODUyNi0zN2UzMjQyMDFhMzAiLCJpbmJveF9pZCI6MSwiZXhwIjoxNzg1MDU3ODU5LCJpYXQiOjE3Njk1MDU4NTl9.SW4n7hnVjleWaVjKesdA60IZ5YAkAFn-3cUzH2f9F_M&locale=zh_CN" \
+ -H "Content-Type: application/json" \
+ -H "X-Auth-Token: eyJhbGciOiJIUzI1NiJ9.eyJzb3VyY2VfaWQiOiI2Njc1ZGY3Ni1jM2MxLTQwMjktODUyNi0zN2UzMjQyMDFhMzAiLCJpbmJveF9pZCI6MSwiZXhwIjoxNzg1MDU3ODU5LCJpYXQiOjE3Njk1MDU4NTl9.SW4n7hnVjleWaVjKesdA60IZ5YAkAFn-3cUzH2f9F_M" \
+ -d '{
+ "message": {
+ "content": "",
+ "content_type": "search_image",
+ "content_attributes": {
+ "url": "https://img.gaia888.com/image/www/test.jpg"
+ },
+ "timestamp": "Tue Jan 27 2026 17:41:08 GMT+0800 (中国标准时间)",
+ "referer_url": "http://localhost:3000/widget_tests"
+ }
+ }'
+```
+
+## 方法 3: 使用你提供的完整 curl 命令
+
+```bash
+curl "http://localhost:3000/api/v1/widget/messages?website_token=9n9D3JFHBorFTZLD7cQ49TMg&cw_conversation=eyJhbGciOiJIUzI1NiJ9.eyJzb3VyY2VfaWQiOiI2Njc1ZGY3Ni1jM2MxLTQwMjktODUyNi0zN2UzMjQyMDFhMzAiLCJpbmJveF9pZCI6MSwiZXhwIjoxNzg1MDU3ODU5LCJpYXQiOjE3Njk1MDU4NTl9.SW4n7hnVjleWaVjKesdA60IZ5YAkAFn-3cUzH2f9F_M&locale=zh_CN" \
+ -H "Accept: application/json, text/plain, */*" \
+ -H "Content-Type: application/json" \
+ -H "X-Auth-Token: eyJhbGciOiJIUzI1NiJ9.eyJzb3VyY2VfaWQiOiI2Njc1ZGY3Ni1jM2MxLTQwMjktODUyNi0zN2UzMjQyMDFhMzAiLCJpbmJveF9pZCI6MSwiZXhwIjoxNzg1MDU3ODU5LCJpYXQiOjE3Njk1MDU4NTl9.SW4n7hnVjleWaVjKesdA60IZ5YAkAFn-3cUzH2f9F_M" \
+ --data-raw '{"message":{"content":"","reply_to":null,"timestamp":"Tue Jan 27 2026 17:41:08 GMT+0800 (中国标准时间)","referer_url":"http://localhost:3000/widget_tests","content_type":"search_image","content_attributes":{"url":"https://img.gaia888.com/image/www/auto_202601/test.jpg"}}}'
+```
+
+## 预期响应
+
+成功时会返回类似:
+
+```json
+{
+ "id": 428,
+ "content": "",
+ "content_type": "search_image",
+ "inbox_id": 1,
+ "conversation_id": 16,
+ "message_type": 0,
+ "created_at": 1769504268,
+ "private": false,
+ "content_attributes": {
+ "url": "https://img.gaia888.com/image/www/test.jpg"
+ },
+ "sender": {
+ "id": 24,
+ "name": "测试用户 211845",
+ "email": "211845@example.com",
+ "thumbnail": "",
+ "type": "contact"
+ }
+}
+```
+
+## 验证步骤
+
+1. **检查 Dashboard**:
+ - 访问: http://localhost:3000/app/accounts/2/inbox/1
+ - 查看会话列表中是否有新消息
+
+2. **检查 Webhook 日志**:
+ - 查看你的 webhook 接收服务日志
+ - 应该看到 `content_type: "search_image"`
+
+3. **检查 Widget**:
+ - 刷新 /widget_tests 页面
+ - 应该看到图片显示在聊天界面
+
+## 错误排查
+
+### 401 Unauthorized
+**原因**: cw_conversation token 过期或无效
+**解决**: 从 /widget_tests 页面重新获取 cookie 中的 cw_conversation 值
+
+### 404 Not Found
+**原因**: website_token 不正确
+**解决**: 确认 website_token 与配置一致
+
+### 422 Unprocessable Entity
+**原因**: 请求参数格式错误
+**解决**: 检查 JSON 格式和必需字段
+
+## 参数说明
+
+| 参数 | 值 | 说明 |
+|------|-----|------|
+| `content_type` | `"search_image"` | 消息类型 |
+| `content` | `""` | 空字符串 |
+| `content_attributes.url` | 图片 URL | 图片地址 |
+| `cw_conversation` | JWT token | 会话标识 |
+| `website_token` | Widget token | 网站标识 |
diff --git a/TROUBLESHOOTING_WEBHOOK.md b/TROUBLESHOOTING_WEBHOOK.md
new file mode 100644
index 0000000..9bc52c0
--- /dev/null
+++ b/TROUBLESHOOTING_WEBHOOK.md
@@ -0,0 +1,234 @@
+# Webhook 不触发的排查步骤
+
+## 问题原因
+
+Webhook 不触发通常有以下几种原因:
+
+1. ❌ 没有配置 Webhook
+2. ❌ Webhook 未启用(active: false)
+3. ❌ 没有订阅 `message_created` 事件
+4. ❌ Webhook URL 不正确或无法访问
+
+---
+
+## 检查步骤
+
+### 方法 1: 运行检查脚本
+
+```bash
+cd /home/lxj/project/chatwoot-develop
+
+# 启动 Rails console
+bundle exec rails c
+
+# 加载检查脚本
+load 'check_webhook.rb'
+```
+
+### 方法 2: 在 Rails Console 中手动检查
+
+```bash
+bundle exec rails c
+```
+
+然后执行:
+
+```ruby
+# 1. 检查 Account
+account = Account.find(2)
+puts account.name
+
+# 2. 检查 Webhooks
+webhooks = account.webhooks
+puts "Webhooks 数量: #{webhooks.count}"
+
+# 3. 查看每个 Webhook 的配置
+webhooks.each do |webhook|
+ puts "ID: #{webhook.id}"
+ puts "URL: #{webhook.webhook_url}"
+ puts "启用: #{webhook.active}"
+ puts "订阅: #{webhook.subscriptions.inspect}"
+end
+
+# 4. 检查最近的调用日志
+logs = account.webhook_logs.order(created_at: :desc).limit(5)
+logs.each do |log|
+ puts "#{log.event_type} - #{log.status} - #{log.response_code}"
+end
+```
+
+---
+
+## 修复方法
+
+### 场景 1: 没有配置 Webhook
+
+```ruby
+account = Account.find(2)
+
+# 创建 Webhook
+webhook = account.webhooks.create!(
+ webhook_url: 'https://your-domain.com/webhook', # 替换为你的 URL
+ subscriptions: ['conversation_created', 'message_created', 'message_updated'],
+ active: true
+)
+
+puts "✅ Webhook 已创建 (ID: #{webhook.id})"
+```
+
+### 场景 2: Webhook 未启用
+
+```ruby
+webhook = Webhook.find(1) # 替换为实际的 ID
+webhook.update!(active: true)
+puts "✅ Webhook 已启用"
+```
+
+### 场景 3: 未订阅 message_created 事件
+
+```ruby
+webhook = Webhook.find(1) # 替换为实际的 ID
+
+# 添加 message_created 订阅
+current_subs = webhook.subscriptions
+webhook.update!(subscriptions: current_subs + ['message_created'])
+
+puts "✅ 已添加 message_created 订阅"
+```
+
+### 场景 4: Webhook URL 无法访问
+
+```ruby
+# 更新为正确的 URL
+webhook = Webhook.find(1)
+webhook.update!(webhook_url: 'https://correct-url.com/webhook')
+puts "✅ URL 已更新"
+```
+
+---
+
+## 完整配置示例
+
+```ruby
+# Account 2 的完整 Webhook 配置
+account = Account.find(2)
+
+# 删除旧的 webhooks(可选)
+# account.webhooks.destroy_all
+
+# 创建新的 Webhook
+webhook = account.webhooks.create!(
+ webhook_url: 'https://your-domain.com/chatwoot/webhook',
+ subscriptions: [
+ 'conversation_created',
+ 'conversation_status_changed',
+ 'message_created',
+ 'message_updated',
+ 'message_deleted'
+ ],
+ active: true,
+ retry_frequency: 3 # 失败重试次数
+)
+
+puts "✅ Webhook 配置完成"
+puts " ID: #{webhook.id}"
+puts " URL: #{webhook.webhook_url}"
+puts " 订阅事件: #{webhook.subscriptions}"
+```
+
+---
+
+## 验证 Webhook 是否工作
+
+### 方法 1: 检查日志
+
+```ruby
+# 查看最近的 Webhook 日志
+logs = WebhookLog.order(created_at: :desc).limit(10)
+
+logs.each do |log|
+ puts "事件: #{log.event_type}"
+ puts "状态: #{log.status}"
+ puts "响应码: #{log.response_code}"
+ puts "URL: #{log.webhook.webhook_url}"
+ puts "错误: #{log.error_message}"
+ puts "时间: #{log.created_at}"
+ puts "---"
+end
+```
+
+### 方法 2: 重新发送 Webhook
+
+```ruby
+# 找到之前发送的消息
+message = Message.find(455)
+
+# 手动触发 webhook
+WebhookService.new(message.account, message.conversation).trigger_webhook(message, 'message_created')
+
+puts "✅ Webhook 已重新发送"
+```
+
+### 方法 3: 使用 curl 发送测试消息
+
+```bash
+curl -X POST "http://localhost:3000/api/v1/widget/messages?website_token=9n9D3JFHBorFTZLD7cQ49TMg&cw_conversation=xxx&locale=zh_CN" \
+ -H "Content-Type: application/json" \
+ -d '{"message":{"content":"","content_type":"search_image","content_attributes":{"url":"https://test.jpg"}}}'
+```
+
+然后立即检查:
+
+```ruby
+# 查看最新日志
+WebhookLog.order(created_at: :desc).first
+```
+
+---
+
+## 常见问题
+
+### Q: Dashboard 能看到消息,但 Webhook 不触发?
+
+**A**: 检查以下几点:
+1. Webhook 是否启用(`active: true`)
+2. 是否订阅了 `message_created` 事件
+3. Webhook URL 是否可访问
+4. 查看是否有错误日志:`WebhookLog.where(status: 'failed')`
+
+### Q: 如何调试 Webhook 请求?
+
+**A**: 使用工具接收 Webhook 请求,例如:
+- https://webhook.site(免费的 Webhook 测试工具)
+- ngrok(将本地服务器暴露到公网)
+
+### Q: Webhook 调用失败会重试吗?
+
+**A**: 会。Chatwoot 会根据 `retry_frequency` 配置自动重试失败的 Webhook。
+
+---
+
+## 快速解决方案
+
+如果你只想快速测试 Webhook,使用 Webhook.site:
+
+1. 访问 https://webhook.site 获取一个临时 URL
+2. 在 Rails console 中配置:
+
+```ruby
+account = Account.find(2)
+webhook = account.webhooks.create!(
+ webhook_url: 'https://webhook.site/your-uuid', # 替换为你的 UUID
+ subscriptions: ['message_created'],
+ active: true
+)
+```
+
+3. 发送测试消息
+4. 在 webhook.site 查看是否收到请求
+
+---
+
+## 下一步
+
+运行检查脚本后,告诉我结果,我可以帮你修复具体的问题!
diff --git a/WEBHOOK_TROUBLESHOOTING.md b/WEBHOOK_TROUBLESHOOTING.md
new file mode 100644
index 0000000..af30821
--- /dev/null
+++ b/WEBHOOK_TROUBLESHOOTING.md
@@ -0,0 +1,291 @@
+# Webhook 不触发问题排查
+
+## 快速检查步骤
+
+### 1. 确认服务器已重启 ⚠️
+
+**重要**: 修改代码后必须重启服务器!
+
+```bash
+# 如果使用 overmind
+overmind restart web
+
+# 或直接重启 Rails server
+# Ctrl+C 然后重新启动
+bundle exec rails server
+```
+
+### 2. 运行调试脚本
+
+```bash
+cd /home/lxj/project/chatwoot-develop
+bundle exec rails c
+# 然后在 console 中执行:
+load 'debug_webhook.rb'
+```
+
+### 3. 检查 Webhook 配置
+
+在 Rails console 中执行:
+
+```ruby
+account = Account.find(2)
+webhooks = account.webhooks
+
+# 检查数量
+puts "Webhooks 数量: #{webhooks.count}"
+
+# 查看配置
+webhooks.each do |w|
+ puts "ID: #{w.id}"
+ puts "URL: #{w.webhook_url}"
+ puts "启用: #{w.active}"
+ puts "订阅: #{w.subscriptions.inspect}"
+end
+```
+
+**预期结果**:
+- `active: true` (必须启用)
+- `subscriptions` 包含 `'message_created'` (必须订阅)
+
+### 4. 手动触发 Webhook 测试
+
+```bash
+bundle exec rails c
+# 然后执行:
+load 'test_webhook_manual.rb'
+```
+
+这会:
+- 查找最近的 search_image 消息
+- 手动构建 webhook payload
+- 直接触发 webhook 发送
+- 显示结果
+
+### 5. 查看 Webhook 日志
+
+```ruby
+# 最近的日志
+logs = Account.find(2).webhook_logs.order(created_at: :desc).limit(10)
+
+logs.each do |log|
+ puts "#{log.created_at} - #{log.event_type} - #{log.status}"
+ puts "响应码: #{log.response_code}"
+ puts "错误: #{log.error_message}" if log.error_message
+ puts "---"
+end
+```
+
+### 6. 检查最近的消息
+
+```ruby
+# 查找 search_image 消息
+messages = Message.where(content_type: 'search_image').order(created_at: :desc).limit(5)
+
+messages.each do |msg|
+ puts "ID: #{msg.id}"
+ puts "content: '#{msg.content}'"
+ puts "content_type: #{msg.content_type}"
+ puts "创建时间: #{msg.created_at}"
+ puts "---"
+end
+```
+
+---
+
+## 常见问题和解决方案
+
+### 问题 1: 没有配置 Webhook
+
+**症状**: `webhooks.count == 0`
+
+**解决**:
+
+```ruby
+# 创建 Webhook (使用 webhook.site 测试)
+account = Account.find(2)
+
+# 1. 访问 https://webhook.site 获取一个临时 URL
+# 2. 创建 Webhook 指向该 URL
+account.webhooks.create!(
+ webhook_url: 'https://webhook.site/your-uuid-here', # 替换为实际 URL
+ subscriptions: ['conversation_created', 'message_created', 'message_updated'],
+ active: true
+)
+```
+
+### 问题 2: Webhook 未启用
+
+**症状**: `webhook.active == false`
+
+**解决**:
+
+```ruby
+webhook = Webhook.find(1)
+webhook.update!(active: true)
+```
+
+### 问题 3: 未订阅 message_created 事件
+
+**症状**: `webhook.subscriptions` 不包含 `'message_created'`
+
+**解决**:
+
+```ruby
+webhook = Webhook.find(1)
+webhook.update!(subscriptions: ['conversation_created', 'message_created', 'message_updated'])
+```
+
+### 问题 4: 代码修改未生效
+
+**症状**: 代码已修改但行为未改变
+
+**解决**: 重启服务器!
+
+```bash
+# 方法 1: overmind
+overmind restart web
+
+# 方法 2: 直接重启
+# Ctrl+C 停止服务器
+bundle exec rails server
+```
+
+### 问题 5: Webhook URL 无法访问
+
+**症状**: 日志显示连接错误
+
+**解决**:
+1. 检查 URL 是否正确
+2. 确认服务器可以从外网访问
+3. 使用 webhook.site 测试
+
+### 问题 6: content 过滤问题
+
+**症状**: search_image 消息不触发 webhook
+
+**验证代码已正确修改**:
+
+```ruby
+# 检查 WebhookListener
+File.read('app/listeners/webhook_listener.rb') =~ /search_image/
+# 应该返回匹配结果
+
+# 查看具体代码
+puts File.read('app/listeners/webhook_listener.rb')[/def message_created.*?end/m]
+```
+
+应该看到:
+
+```ruby
+def message_created(event)
+ message = extract_message_and_account(event)[0]
+ inbox = message.inbox
+
+ return unless message.webhook_sendable?
+
+ # For search_image type, allow webhook even if content is blank
+ return if message.content.blank? && message.content_type != 'search_image'
+
+ payload = message.webhook_data.merge(event: __method__.to_s)
+ deliver_webhook_payloads(payload, inbox)
+end
+```
+
+---
+
+## 完整测试流程
+
+### 步骤 1: 重启服务器
+
+```bash
+overmind restart web
+# 或
+bundle exec rails server
+```
+
+### 步骤 2: 确认 Webhook 配置
+
+```ruby
+# Rails console
+account = Account.find(2)
+webhooks = account.webhooks
+
+# 如果没有,创建一个
+if webhooks.count == 0
+ account.webhooks.create!(
+ webhook_url: 'https://webhook.site/your-uuid',
+ subscriptions: ['message_created'],
+ active: true
+ )
+end
+```
+
+### 步骤 3: 发送测试消息
+
+使用 curl:
+
+```bash
+curl -X POST "http://localhost:3000/api/v1/widget/messages?website_token=9n9D3JFHBorFTZLD7cQ49TMg&cw_conversation=xxx&locale=zh_CN" \
+ -H "Content-Type: application/json" \
+ -d '{"message":{"content":"","content_type":"search_image","content_attributes":{"url":"https://test.jpg"}}}'
+```
+
+或在 /widget_tests 页面上传图片。
+
+### 步骤 4: 立即检查日志
+
+```ruby
+# 查看最新消息
+message = Message.last
+puts "content_type: #{message.content_type}"
+puts "content: '#{message.content}'"
+
+# 查看 webhook 日志
+logs = Account.find(2).webhook_logs.order(created_at: :desc).limit(5)
+logs.each { |log| puts "#{log.event_type} - #{log.status}" }
+```
+
+### 步骤 5: 检查 Webhook 接收
+
+- 如果使用 webhook.site,刷新页面查看是否收到请求
+- 查看请求内容是否包含 search_image 数据
+
+---
+
+## 调试命令汇总
+
+```bash
+# 1. 重启服务器
+overmind restart web
+
+# 2. 启动 Rails console
+bundle exec rails c
+
+# 3. 在 console 中依次执行:
+load 'debug_webhook.rb' # 全面诊断
+load 'test_webhook_manual.rb' # 手动触发测试
+
+# 4. 发送测试消息
+# (另开一个终端)
+curl -X POST "http://localhost:3000/api/v1/widget/messages?..." \
+ -H "Content-Type: application/json" \
+ -d '{"message":{"content":"","content_type":"search_image","content_attributes":{"url":"https://test.jpg"}}}'
+
+# 5. 检查结果 (在 Rails console 中)
+Message.last
+Account.find(2).webhook_logs.order(created_at: :desc).first
+```
+
+---
+
+## 预期结果
+
+成功的情况下,你应该看到:
+
+1. ✅ 消息创建成功
+2. ✅ Dashboard 显示消息
+3. ✅ Webhook 日志有新记录 (`status: 'success'` 或 `'failed'`)
+4. ✅ Webhook 接收端收到请求
+
+如果仍然不工作,请运行 `debug_webhook.rb` 并把输出发给我!
diff --git a/WIDGET_TESTS_GUIDE.md b/WIDGET_TESTS_GUIDE.md
new file mode 100644
index 0000000..870426b
--- /dev/null
+++ b/WIDGET_TESTS_GUIDE.md
@@ -0,0 +1,200 @@
+# /widget_tests 页面使用指南
+
+## 📋 功能说明
+
+测试页面用于模拟 Chatwoot Widget 的用户登录/退出流程。
+
+## 🔄 新的行为逻辑
+
+### 1. 页面初始加载(游客模式)
+
+**无论 cookie 中是否有 token,都以游客模式启动:**
+- ✅ 不清除任何数据
+- ✅ 不设置 userIdentifier
+- ✅ 保留现有会话
+
+**原因:** 让用户通过手动点击登录按钮来测试登录流程。
+
+### 2. 点击"模拟用户登录"(不刷新页面)
+
+**执行步骤:**
+1. 设置 token cookie
+2. 调用 `window.$chatwoot.reset()` 清除之前的聊天内容
+3. 调用 `window.$chatwoot.setUser()` 设置用户信息
+4. ✅ **不刷新页面**
+
+**优点:**
+- 用户体验更好,无页面闪烁
+- 切换快速
+
+**注意:**
+- SDK 仍然以游客模式初始化(没有 userIdentifier)
+- 但通过 `setUser()` 设置了用户身份
+
+### 3. 点击"用户退出"(刷新页面)
+
+**执行步骤:**
+1. 删除 token cookie
+2. 调用 `window.$chatwoot.reset()` 清除所有数据
+3. 清除 localStorage
+4. ✅ **刷新页面(2秒倒计时)**
+
+**原因:**
+- 确保完全清除用户状态
+- 让 SDK 重新以游客模式初始化
+
+## 🎯 完整流程示例
+
+### 场景 1:游客 → 登录 → 退出
+
+```
+1. 访问页面
+ → 游客模式启动
+ → 可以发送游客消息
+
+2. 点击"模拟用户登录"
+ → reset() 清除聊天
+ → setUser() 设置用户 211845
+ → 不刷新页面
+ → 可以发送已登录用户消息
+
+3. 点击"用户退出"
+ → reset() 清除数据
+ → 刷新页面
+ → 回到游客模式
+```
+
+### 场景 2:刷新页面(有 token)
+
+```
+1. 已登录状态(token 存在)
+2. 刷新页面
+3. → 仍然以游客模式启动
+4. → 需要重新点击登录按钮
+```
+
+**重要:** 页面加载时不会自动检查 token 并设置用户,必须手动点击登录按钮。
+
+## 🔍 关键代码
+
+### 登录函数(不刷新)
+
+```javascript
+function simulateLogin() {
+ // 1. 设置 token
+ setCookie('token', TEST_TOKEN);
+
+ // 2. 清除之前的聊天
+ window.$chatwoot.reset();
+
+ // 3. 设置用户信息
+ setTimeout(() => {
+ window.$chatwoot.setUser('211845', {
+ identifier_hash: userHash,
+ email: '211845@example.com',
+ name: '测试用户 211845',
+ custom_attributes: {
+ jwt_token: token,
+ login_status: 'logged_in'
+ }
+ });
+ }, 500);
+}
+```
+
+### 退出函数(刷新页面)
+
+```javascript
+function simulateLogout() {
+ // 1. 删除 token
+ deleteCookie('token');
+
+ // 2. 清除数据
+ window.$chatwoot.reset();
+
+ // 3. 清除 localStorage
+
+ // 4. 刷新页面(2秒倒计时)
+ location.reload();
+}
+```
+
+## 🎨 界面说明
+
+### 状态卡片
+
+- **游客模式**:黄色边框
+- **已登录**:绿色边框
+
+显示内容:
+- User ID
+- Token 状态
+- 登录状态
+
+### 操作按钮
+
+- **🔐 模拟用户登录**:设置用户并清除聊天
+- **🚪 用户退出**:清除数据并刷新页面
+- **🔄 刷新页面**:手动刷新
+- **💬 打开聊天**:打开 Widget 聊天窗口
+- **🗑️ 清除所有数据**:完全重置并刷新页面
+
+### 日志输出
+
+实时显示所有操作日志,包括:
+- SDK 加载状态
+- Token 设置/删除
+- 用户信息设置
+- 会话清除操作
+
+## ⚠️ 注意事项
+
+1. **页面加载不自动登录**:即使有 token,也需要手动点击登录按钮
+2. **登录不刷新页面**:使用 `reset()` + `setUser()` 实现
+3. **退出会刷新页面**:确保完全清除状态
+4. **会话数据**:`reset()` 会清除 `cw_conversation` cookie,退出后会创建新会话
+
+## 🧪 测试建议
+
+### 测试 1:游客模式
+
+1. 访问页面
+2. 发送一条消息
+3. 验证:消息以游客身份发送
+
+### 测试 2:登录流程
+
+1. 点击"模拟用户登录"
+2. 观察日志:reset() → setUser()
+3. 发送一条消息
+4. 验证:消息以用户 211845 身份发送
+
+### 测试 3:退出流程
+
+1. 在已登录状态
+2. 点击"用户退出"
+3. 观察倒计时和刷新
+4. 验证:回到游客模式
+
+### 测试 4:会话隔离
+
+1. 游客模式发送消息
+2. 登录(reset 清除)
+3. 发送消息
+4. 退出(刷新)
+5. 验证:之前的消息已被清除
+
+## 📊 与之前实现的对比
+
+| 操作 | 之前的行为 | 现在的行为 |
+|------|----------|----------|
+| 页面加载(有 token) | 自动设置 userIdentifier | 游客模式,不做处理 |
+| 点击登录 | 刷新页面(2秒倒计时) | reset() + setUser()(不刷新) |
+| 点击退出 | reset()(不刷新) | reset() + 刷新页面(2秒倒计时) |
+
+## 🎯 设计理念
+
+**新实现的目标:**
+- 更好的用户体验(登录不刷新)
+- 更清晰的状态管理(退出刷新确保清理)
+- 手动控制登录流程(便于测试)
diff --git a/app/controllers/api/v1/widget/base_controller.rb b/app/controllers/api/v1/widget/base_controller.rb
index 5b87e2d..41423e6 100644
--- a/app/controllers/api/v1/widget/base_controller.rb
+++ b/app/controllers/api/v1/widget/base_controller.rb
@@ -79,9 +79,8 @@ class Api::V1::Widget::BaseController < ApplicationController
sender: @contact,
content: permitted_params[:message][:content],
inbox_id: conversation.inbox_id,
- content_attributes: {
- in_reply_to: permitted_params[:message][:reply_to]
- },
+ content_type: permitted_params[:message][:content_type],
+ content_attributes: permitted_params[:message][:content_attributes] || { in_reply_to: permitted_params[:message][:reply_to] },
echo_id: permitted_params[:message][:echo_id],
message_type: :incoming
}
diff --git a/app/controllers/api/v1/widget/conversations_controller.rb b/app/controllers/api/v1/widget/conversations_controller.rb
index fe5facc..ed1385d 100644
--- a/app/controllers/api/v1/widget/conversations_controller.rb
+++ b/app/controllers/api/v1/widget/conversations_controller.rb
@@ -10,7 +10,10 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
ActiveRecord::Base.transaction do
process_update_contact
@conversation = create_conversation
- conversation.messages.create!(message_params)
+ Rails.logger.info "Widget API - message_params: #{message_params.inspect}"
+ Rails.logger.info "Widget API - content_type: #{message_params[:message][:content_type]}"
+ message = conversation.messages.create!(message_params)
+ Rails.logger.info "Widget API - Created message content_type: #{message.content_type}"
# TODO: Temporary fix for message type cast issue, since message_type is returning as string instead of integer
conversation.reload
end
@@ -87,7 +90,7 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
def permitted_params
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number],
- message: [:content, :referer_url, :timestamp, :echo_id],
+ message: [:content, :content_type, :referer_url, :timestamp, :echo_id],
custom_attributes: {})
end
end
diff --git a/app/controllers/api/v1/widget/messages_controller.rb b/app/controllers/api/v1/widget/messages_controller.rb
index a51b4c2..843f47c 100644
--- a/app/controllers/api/v1/widget/messages_controller.rb
+++ b/app/controllers/api/v1/widget/messages_controller.rb
@@ -64,7 +64,7 @@ class Api::V1::Widget::MessagesController < Api::V1::Widget::BaseController
def permitted_params
# timestamp parameter is used in create conversation method
- params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id, :reply_to])
+ params.permit(:id, :before, :after, :website_token, contact: [:name, :email], message: [:content, :content_type, :referer_url, :timestamp, :echo_id, :reply_to, content_attributes: {}])
end
def set_message
diff --git a/app/controllers/widget_tests_controller.rb b/app/controllers/widget_tests_controller.rb
index caa2c69..96bd6ed 100644
--- a/app/controllers/widget_tests_controller.rb
+++ b/app/controllers/widget_tests_controller.rb
@@ -28,7 +28,7 @@ class WidgetTestsController < ActionController::Base
end
def inbox_id
- @inbox_id ||= params[:inbox_id] || Channel::WebWidget.find(2).inbox.id
+ @inbox_id ||= params[:inbox_id] || Channel::WebWidget.first&.inbox&.id
end
def ensure_web_widget
diff --git a/app/javascript/dashboard/components-next/message/Message.vue b/app/javascript/dashboard/components-next/message/Message.vue
index 44f6229..b347fb6 100644
--- a/app/javascript/dashboard/components-next/message/Message.vue
+++ b/app/javascript/dashboard/components-next/message/Message.vue
@@ -42,6 +42,8 @@ import TableBubble from './bubbles/Table.vue';
import OrderListBubble from './bubbles/OrderList.vue';
import OrderDetailBubble from './bubbles/OrderDetail.vue';
import LogisticsBubble from './bubbles/Logistics.vue';
+import ProductListBubble from './bubbles/ProductList.vue';
+import SearchImageBubble from './bubbles/SearchImage.vue';
import MessageError from './MessageError.vue';
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
@@ -308,6 +310,14 @@ const componentToRender = computed(() => {
return LogisticsBubble;
}
+ if (props.contentType === CONTENT_TYPES.PRODUCT_LIST) {
+ return ProductListBubble;
+ }
+
+ if (props.contentType === CONTENT_TYPES.SEARCH_IMAGE) {
+ return SearchImageBubble;
+ }
+
if (props.contentType === CONTENT_TYPES.INCOMING_EMAIL) {
return EmailBubble;
}
@@ -404,6 +414,8 @@ const shouldRenderMessage = computed(() => {
const isOrderList = props.contentType === CONTENT_TYPES.ORDER_LIST;
const isOrderDetail = props.contentType === CONTENT_TYPES.ORDER_DETAIL;
const isLogistics = props.contentType === CONTENT_TYPES.LOGISTICS;
+ const isProductList = props.contentType === CONTENT_TYPES.PRODUCT_LIST;
+ const isSearchImage = props.contentType === CONTENT_TYPES.SEARCH_IMAGE;
return (
hasAttachments ||
@@ -414,7 +426,9 @@ const shouldRenderMessage = computed(() => {
isDataTable ||
isOrderList ||
isOrderDetail ||
- isLogistics
+ isLogistics ||
+ isProductList ||
+ isSearchImage
);
});
diff --git a/app/javascript/dashboard/components-next/message/bubbles/ProductList.vue b/app/javascript/dashboard/components-next/message/bubbles/ProductList.vue
new file mode 100644
index 0000000..181c9f6
--- /dev/null
+++ b/app/javascript/dashboard/components-next/message/bubbles/ProductList.vue
@@ -0,0 +1,156 @@
+
+
+
+
+
+
{{ title }}
+
+
+
+
+
+
![]()
+
+
+
{{ product.name }}
+
{{ product.price }}
+
+ 🔗 {{ product.url }}
+
+
+
+
+
+
+
+ {{ action.text }}{{ index < actions.length - 1 ? ' · ' : '' }}
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/message/bubbles/SearchImage.vue b/app/javascript/dashboard/components-next/message/bubbles/SearchImage.vue
new file mode 100644
index 0000000..26578f3
--- /dev/null
+++ b/app/javascript/dashboard/components-next/message/bubbles/SearchImage.vue
@@ -0,0 +1,37 @@
+
+
+
+ No image URL
+
+
+
![Search Image]()
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/message/constants.js b/app/javascript/dashboard/components-next/message/constants.js
index b6f4ba8..73dd529 100644
--- a/app/javascript/dashboard/components-next/message/constants.js
+++ b/app/javascript/dashboard/components-next/message/constants.js
@@ -72,6 +72,8 @@ export const CONTENT_TYPES = {
ORDER_LIST: 'order_list',
ORDER_DETAIL: 'order_detail',
LOGISTICS: 'logistics',
+ PRODUCT_LIST: 'product_list',
+ SEARCH_IMAGE: 'search_image',
};
export const MEDIA_TYPES = [
diff --git a/app/javascript/shared/components/Logistics.vue b/app/javascript/shared/components/Logistics.vue
index b1928d2..f6dddc4 100644
--- a/app/javascript/shared/components/Logistics.vue
+++ b/app/javascript/shared/components/Logistics.vue
@@ -20,12 +20,9 @@
-
-
@@ -70,10 +67,12 @@
:key="index"
@click="onActionClick(action)"
class="flex-1 py-3 text-sm transition-colors"
- :class="{
- 'text-n-slate-11 hover:bg-n-slate-2 dark:hover:bg-n-solid-2 border-r border-n-weak': action.style === 'default',
- 'text-n-blue-10 font-semibold hover:bg-n-blue-1 dark:hover:bg-n-solid-blue': action.style === 'primary'
- }"
+ :class="[
+ action.style === 'primary'
+ ? 'text-n-blue-10 font-semibold hover:bg-n-blue-1 dark:hover:bg-n-solid-blue'
+ : 'text-n-slate-11 hover:bg-n-slate-2 dark:hover:bg-n-solid-2',
+ index < actions.length - 1 ? 'border-r border-n-weak' : ''
+ ]"
>
{{ action.text }}
@@ -112,8 +111,10 @@ const currentStatusText = computed(() => {
});
const progressWidth = computed(() => {
+ // 限制 currentStep 在有效范围内
+ const validStep = Math.min(props.currentStep, props.steps.length - 1);
// 计算进度条长度:(当前步骤 / 总步数间隔) * 100%
- return `${(props.currentStep / (props.steps.length - 1)) * 100}%`;
+ return `${(validStep / (props.steps.length - 1)) * 100}%`;
});
const statusBadgeClass = computed(() => {
@@ -128,14 +129,23 @@ const copyTrackingNumber = () => {
};
const onActionClick = (action) => {
- // 如果有 URL,在父窗口中打开
+ // 如果有 URL,根据 target 属性决定打开方式
if (action && action.url) {
+ const target = action.target || '_self'; // 默认在当前窗口打开
if (window.parent !== window) {
- // 在 iframe 中,直接设置父窗口 location
- window.parent.location.href = action.url;
+ // 在 iframe 中
+ if (target === '_blank') {
+ window.parent.open(action.url, '_blank');
+ } else {
+ window.parent.location.href = action.url;
+ }
} else {
- // 不在 iframe 中,直接使用当前窗口
- window.location.href = action.url;
+ // 不在 iframe 中
+ if (target === '_blank') {
+ window.open(action.url, '_blank');
+ } else {
+ window.location.href = action.url;
+ }
}
return;
}
@@ -151,5 +161,15 @@ const onActionClick = (action) => {
diff --git a/app/javascript/shared/components/OrderDetail.vue b/app/javascript/shared/components/OrderDetail.vue
index 883ecf1..3bedfc5 100644
--- a/app/javascript/shared/components/OrderDetail.vue
+++ b/app/javascript/shared/components/OrderDetail.vue
@@ -61,14 +61,23 @@ export default {
return color;
},
onActionClick(action) {
- // 如果有 URL,在父窗口中打开
+ // 如果有 URL,根据 target 属性决定打开方式
if (action && action.url) {
+ const target = action.target || '_self'; // 默认在当前窗口打开
if (window.parent !== window) {
- // 在 iframe 中,直接设置父窗口 location
- window.parent.location.href = action.url;
+ // 在 iframe 中
+ if (target === '_blank') {
+ window.parent.open(action.url, '_blank');
+ } else {
+ window.parent.location.href = action.url;
+ }
} else {
- // 不在 iframe 中,直接使用当前窗口
- window.location.href = action.url;
+ // 不在 iframe 中
+ if (target === '_blank') {
+ window.open(action.url, '_blank');
+ } else {
+ window.location.href = action.url;
+ }
}
return;
}
@@ -141,10 +150,12 @@ export default {
:key="index"
@click="onActionClick(action)"
class="flex-1 py-3 text-sm transition-colors"
- :class="{
- 'text-n-slate-11 hover:bg-n-slate-2 dark:hover:bg-n-solid-2 border-r border-n-weak': action.style === 'default' || !action.style,
- 'text-n-blue-10 font-semibold hover:bg-n-blue-1 dark:hover:bg-n-solid-blue': action.style === 'primary'
- }"
+ :class="[
+ action.style === 'primary'
+ ? 'text-n-blue-10 font-semibold hover:bg-n-blue-1 dark:hover:bg-n-solid-blue'
+ : 'text-n-slate-11 hover:bg-n-slate-2 dark:hover:bg-n-solid-2',
+ index < actions.length - 1 ? 'border-r border-n-weak' : ''
+ ]"
>
{{ action.text }}
diff --git a/app/javascript/shared/components/OrderList.vue b/app/javascript/shared/components/OrderList.vue
index 9c6f74b..eb4cd39 100644
--- a/app/javascript/shared/components/OrderList.vue
+++ b/app/javascript/shared/components/OrderList.vue
@@ -114,8 +114,8 @@ export default {
.status-badge {
display: inline-block;
- background-color: #f0f0f0;
- color: #888;
+ background-color: #000000;
+ color: #ffffff;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
@@ -124,8 +124,8 @@ export default {
}
.dark .status-badge {
- background-color: #374151;
- color: #d1d5db;
+ background-color: #000000;
+ color: #ffffff;
}
.product-list {
diff --git a/app/javascript/shared/components/ProductList.vue b/app/javascript/shared/components/ProductList.vue
new file mode 100644
index 0000000..00ebe14
--- /dev/null
+++ b/app/javascript/shared/components/ProductList.vue
@@ -0,0 +1,141 @@
+
+
+
+
{{ title }}
+
+
+
+
+
+
+
![]()
+
+
+
+ {{ product.name }}
+
+
+ {{ product.price }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/shared/components/SearchImage.vue b/app/javascript/shared/components/SearchImage.vue
new file mode 100644
index 0000000..a43d6fe
--- /dev/null
+++ b/app/javascript/shared/components/SearchImage.vue
@@ -0,0 +1,47 @@
+
+
+
+ No image URL found. Attributes: {{ JSON.stringify(messageContentAttributes) }}
+
+
![搜索图片]()
+
+
+
+
diff --git a/app/javascript/widget/App.vue b/app/javascript/widget/App.vue
index 6b9d605..f1c8757 100755
--- a/app/javascript/widget/App.vue
+++ b/app/javascript/widget/App.vue
@@ -375,9 +375,20 @@ export default {
if (token) {
console.log('[Widget] Found token in cookie:', token.substring(0, 20) + '...');
+ // 从 JWT token 中提取用户 ID
+ let userId = '123'; // 默认值
+ try {
+ const payload = JSON.parse(atob(token.split('.')[1]));
+ userId = payload.user_id || payload.sub || '123';
+ } catch (e) {
+ console.warn('[Widget] Failed to decode token, using default userId:', e.message);
+ }
+
+ console.log('[Widget] Extracted userId:', userId);
+
// 调用 setUser API 保存到 contact
this.$store.dispatch('contacts/setUser', {
- identifier: token,
+ identifier: userId,
user: {
custom_attributes: {
jwt_token: token,
diff --git a/app/javascript/widget/api/conversation.js b/app/javascript/widget/api/conversation.js
index 1060b28..0490d1d 100755
--- a/app/javascript/widget/api/conversation.js
+++ b/app/javascript/widget/api/conversation.js
@@ -6,8 +6,11 @@ const createConversationAPI = async content => {
return API.post(urlData.url, urlData.params);
};
-const sendMessageAPI = async (content, replyTo = null) => {
- const urlData = endPoints.sendMessage(content, replyTo);
+const sendMessageAPI = async (content, replyTo = null, contentType = null, contentAttributes = null) => {
+ const urlData = endPoints.sendMessage(content, replyTo, contentType, contentAttributes);
+ console.log('[sendMessageAPI] Sending to:', urlData.url, 'with params:', urlData.params);
+ console.log('[sendMessageAPI] contentType:', contentType);
+ console.log('[sendMessageAPI] contentAttributes:', contentAttributes);
return API.post(urlData.url, urlData.params);
};
diff --git a/app/javascript/widget/api/endPoints.js b/app/javascript/widget/api/endPoints.js
index b595fdf..855507f 100755
--- a/app/javascript/widget/api/endPoints.js
+++ b/app/javascript/widget/api/endPoints.js
@@ -22,18 +22,28 @@ const createConversation = params => {
};
};
-const sendMessage = (content, replyTo) => {
+const sendMessage = (content, replyTo, contentType = null, contentAttributes = null) => {
const referrerURL = window.referrerURL || '';
const search = buildSearchParamsWithLocale(window.location.search);
+ const messageParams = {
+ content,
+ reply_to: replyTo,
+ timestamp: new Date().toString(),
+ referer_url: referrerURL,
+ };
+
+ if (contentType) {
+ messageParams.content_type = contentType;
+ }
+
+ if (contentAttributes) {
+ messageParams.content_attributes = contentAttributes;
+ }
+
return {
url: `/api/v1/widget/messages${search}`,
params: {
- message: {
- content,
- reply_to: replyTo,
- timestamp: new Date().toString(),
- referer_url: referrerURL,
- },
+ message: messageParams,
},
};
};
diff --git a/app/javascript/widget/assets/scss/views/_conversation.scss b/app/javascript/widget/assets/scss/views/_conversation.scss
index 76fb1c1..84a25fe 100644
--- a/app/javascript/widget/assets/scss/views/_conversation.scss
+++ b/app/javascript/widget/assets/scss/views/_conversation.scss
@@ -4,7 +4,7 @@
.conversation-wrap {
.agent-message {
- @apply items-end flex flex-row justify-start mt-0 ltr:mr-0 rtl:mr-2 mb-0.5 ltr:ml-2 rtl:ml-0 max-w-[88%];
+ @apply items-end flex flex-row justify-start mt-0 ltr:mr-0 rtl:mr-2 mb-0.5 ltr:ml-2 rtl:ml-0 max-w-[98%];
.avatar-wrap {
@apply flex-shrink-0 h-6 w-6;
diff --git a/app/javascript/widget/components/AgentMessage.vue b/app/javascript/widget/components/AgentMessage.vue
index d3d2181..3a3cc47 100755
--- a/app/javascript/widget/components/AgentMessage.vue
+++ b/app/javascript/widget/components/AgentMessage.vue
@@ -53,8 +53,8 @@ export default {
) {
return false;
}
- // Table, order_list, order_detail and logistics messages can have empty content, so check content_type as well
- if (this.contentType === 'data_table' || this.contentType === 'order_list' || this.contentType === 'order_detail' || this.contentType === 'logistics') {
+ // Table, order_list, order_detail, logistics and product_list messages can have empty content, so check content_type as well
+ if (this.contentType === 'data_table' || this.contentType === 'order_list' || this.contentType === 'order_detail' || this.contentType === 'logistics' || this.contentType === 'product_list') {
return true;
}
return this.message.content;
@@ -168,7 +168,7 @@ export default {
}"
>
-
+
@@ -202,5 +207,11 @@ export default {
:steps="messageContentAttributes.steps"
:actions="messageContentAttributes.actions"
/>
+
diff --git a/app/javascript/widget/components/AgentTypingBubble.vue b/app/javascript/widget/components/AgentTypingBubble.vue
index e90bee7..a13eb27 100644
--- a/app/javascript/widget/components/AgentTypingBubble.vue
+++ b/app/javascript/widget/components/AgentTypingBubble.vue
@@ -7,7 +7,7 @@ export default {
-
+
@@ -149,8 +174,12 @@ export default {
@blur="onBlur"
/>
+
diff --git a/app/javascript/widget/components/ImageUploadButton.vue b/app/javascript/widget/components/ImageUploadButton.vue
new file mode 100644
index 0000000..30eec6a
--- /dev/null
+++ b/app/javascript/widget/components/ImageUploadButton.vue
@@ -0,0 +1,185 @@
+
+
+
+
+
diff --git a/app/javascript/widget/components/MessageReplyButton.vue b/app/javascript/widget/components/MessageReplyButton.vue
index f484f2e..976942f 100644
--- a/app/javascript/widget/components/MessageReplyButton.vue
+++ b/app/javascript/widget/components/MessageReplyButton.vue
@@ -10,6 +10,7 @@ export default {
diff --git a/app/javascript/widget/components/UserMessage.vue b/app/javascript/widget/components/UserMessage.vue
index a920c50..54c3f12 100755
--- a/app/javascript/widget/components/UserMessage.vue
+++ b/app/javascript/widget/components/UserMessage.vue
@@ -5,6 +5,7 @@ import ImageBubble from 'widget/components/ImageBubble.vue';
import VideoBubble from 'widget/components/VideoBubble.vue';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import FileBubble from 'widget/components/FileBubble.vue';
+import SearchImage from 'shared/components/SearchImage.vue';
import { messageStamp } from 'shared/helpers/timeHelper';
import messageMixin from '../mixins/messageMixin';
import ReplyToChip from 'widget/components/ReplyToChip.vue';
@@ -21,6 +22,7 @@ export default {
ImageBubble,
VideoBubble,
FileBubble,
+ SearchImage,
FluentIcon,
ReplyToChip,
DragWrapper,
@@ -51,6 +53,13 @@ export default {
const { status = '' } = this.message;
return status === 'in_progress';
},
+ contentType() {
+ const { content_type: contentType = 'text' } = this.message;
+ return contentType;
+ },
+ isSearchImage() {
+ return this.contentType === 'search_image' || this.contentType === 17;
+ },
showTextBubble() {
const { message } = this;
return !!message.content;
@@ -128,6 +137,11 @@ export default {
:status="message.status"
:widget-color="widgetColor"
/>
+
{
- const { content, replyTo } = params;
- const message = createTemporaryMessage({ content, replyTo });
+ const { content, replyTo, content_type: contentType, content_attributes: contentAttributes } = params;
+ console.log('[actions] sendMessage called with:', { content, replyTo, contentType, contentAttributes });
+ const message = createTemporaryMessage({ content, replyTo, contentType, contentAttributes });
+ console.log('[actions] Created message:', message);
dispatch('sendMessageWithData', message);
},
sendMessageWithData: async ({ commit }, message) => {
- const { id, content, replyTo, meta = {} } = message;
+ const { id, content, replyTo, content_type: contentType, content_attributes: contentAttributes, meta = {} } = message;
+ console.log('[sendMessageWithData] Sending message with contentType:', contentType);
+ console.log('[sendMessageWithData] contentAttributes:', contentAttributes);
commit('pushMessageToConversation', message);
commit('updateMessageMeta', { id, meta: { ...meta, error: '' } });
try {
- const { data } = await sendMessageAPI(content, replyTo);
+ const { data } = await sendMessageAPI(content, replyTo, contentType, contentAttributes);
+ console.log('[sendMessageWithData] Response:', data);
+ console.log('[sendMessageWithData] Response content_type:', data?.content_type);
// [VITE] Don't delete this manually, since `pushMessageToConversation` does the replacement for us anyway
// commit('deleteMessage', message.id);
commit('pushMessageToConversation', { ...data, status: 'sent' });
} catch (error) {
+ console.error('[sendMessageWithData] Error:', error);
commit('pushMessageToConversation', { ...message, status: 'failed' });
commit('updateMessageMeta', {
id,
diff --git a/app/javascript/widget/store/modules/conversation/helpers.js b/app/javascript/widget/store/modules/conversation/helpers.js
index 61cbc9a..be3ec88 100644
--- a/app/javascript/widget/store/modules/conversation/helpers.js
+++ b/app/javascript/widget/store/modules/conversation/helpers.js
@@ -2,11 +2,13 @@ import { MESSAGE_TYPE } from 'widget/helpers/constants';
import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
import getUuid from '../../../helpers/uuid';
-export const createTemporaryMessage = ({ attachments, content, replyTo }) => {
+export const createTemporaryMessage = ({ attachments, content, replyTo, contentType, contentAttributes }) => {
const timestamp = new Date().getTime() / 1000;
return {
id: getUuid(),
content,
+ content_type: contentType,
+ content_attributes: contentAttributes,
attachments,
status: 'in_progress',
replyTo,
diff --git a/app/listeners/webhook_listener.rb b/app/listeners/webhook_listener.rb
index 7573351..fb70909 100644
--- a/app/listeners/webhook_listener.rb
+++ b/app/listeners/webhook_listener.rb
@@ -28,6 +28,9 @@ class WebhookListener < BaseListener
return unless message.webhook_sendable?
+ # For search_image type, allow webhook even if content is blank
+ return if message.content.blank? && message.content_type != 'search_image'
+
payload = message.webhook_data.merge(event: __method__.to_s)
deliver_webhook_payloads(payload, inbox)
end
@@ -38,6 +41,9 @@ class WebhookListener < BaseListener
return unless message.webhook_sendable?
+ # For search_image type, allow webhook even if content is blank
+ return if message.content.blank? && message.content_type != 'search_image'
+
payload = message.webhook_data.merge(event: __method__.to_s)
deliver_webhook_payloads(payload, inbox)
end
diff --git a/app/models/message.rb b/app/models/message.rb
index 852dc12..3d225b3 100644
--- a/app/models/message.rb
+++ b/app/models/message.rb
@@ -100,7 +100,9 @@ class Message < ApplicationRecord
data_table: 13,
order_list: 14,
order_detail: 15,
- logistics: 16
+ logistics: 16,
+ search_image: 17,
+ product_list: 18
}
enum status: { sent: 0, delivered: 1, read: 2, failed: 3 }
# [:submitted_email, :items, :submitted_values] : Used for bot message types
diff --git a/app/views/api/v1/widget/messages/create.json.jbuilder b/app/views/api/v1/widget/messages/create.json.jbuilder
index fbd6ab5..4b45d9f 100644
--- a/app/views/api/v1/widget/messages/create.json.jbuilder
+++ b/app/views/api/v1/widget/messages/create.json.jbuilder
@@ -1,5 +1,6 @@
json.id @message.id
json.content @message.content
+json.content_type @message.content_type
json.inbox_id @message.inbox_id
json.conversation_id @message.conversation.display_id
json.message_type @message.message_type_before_type_cast
diff --git a/app/views/widget_tests/index.html.erb b/app/views/widget_tests/index.html.erb
index 5bcd0e9..8d265c7 100644
--- a/app/views/widget_tests/index.html.erb
+++ b/app/views/widget_tests/index.html.erb
@@ -1,18 +1,222 @@
+
+
+
+
🧪 Chatwoot Widget 测试页面
+
+ 测试流程:
+ 1. 页面加载时以游客模式启动(不做任何处理)
+ 2. 点击"模拟用户登录":使用 reset() 清除聊天 + setUser() 设置用户(不刷新页面)
+ 3. 点击"用户退出":使用 reset() 清除内容并刷新页面
+
+
+
+
👤 当前状态:游客模式
+
+ User ID:
+ 无
+
+
+ Token:
+ 不存在
+
+
+ 登录状态:
+ 未登录
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ⏳ 页面将在 2 秒后自动刷新...
+
+
+
+
<%
- # 使用动态的 user identifier,生成对应的 hash
- user_id = '123'
- user_hash = OpenSSL::HMAC.hexdigest(
- 'sha256',
- @web_widget.hmac_token,
- user_id.to_s
- )
+ # 测试用的用户信息
+ # 检查 cookie 中是否有 token
+ token_present = cookies[:token].present?
+ if token_present
+ test_user_id = '211845'
+ test_user_hash = OpenSSL::HMAC.hexdigest(
+ 'sha256',
+ @web_widget.hmac_token,
+ test_user_id.to_s
+ )
+ else
+ test_user_id = nil
+ test_user_hash = nil
+ end
%>
+
diff --git a/app/views/widgets/show.html.erb b/app/views/widgets/show.html.erb
index 21e03cd..db1138e 100644
--- a/app/views/widgets/show.html.erb
+++ b/app/views/widgets/show.html.erb
@@ -28,6 +28,7 @@
timezone: '<%= @web_widget.inbox.timezone %>',
allowMessagesAfterResolved: <%= @web_widget.inbox.allow_messages_after_resolved %>,
disableBranding: <%= @web_widget.inbox.account.feature_enabled?('disable_branding') %>,
+ mallUrl: '<%= ENV.fetch('MALL_URL', 'https://apicn.qa1.gaia888.com') %>',
}
window.chatwootPubsubToken = '<%= @contact_inbox.pubsub_token %>'
window.authToken = '<%= @token %>'
diff --git a/check_webhook.rb b/check_webhook.rb
new file mode 100644
index 0000000..43e15a2
--- /dev/null
+++ b/check_webhook.rb
@@ -0,0 +1,79 @@
+#!/usr/bin/env ruby
+# 检查 Webhook 配置
+
+require_relative 'config/environment'
+
+puts "=== Webhook 配置检查 ==="
+puts ""
+
+# 1. 检查 Account 2
+account = Account.find(2)
+puts "✅ Account ID: #{account.id}"
+puts " Name: #{account.name}"
+puts ""
+
+# 2. 检查 Webhooks
+webhooks = account.webhooks
+puts "📊 Webhooks 数量: #{webhooks.count}"
+puts ""
+
+if webhooks.count == 0
+ puts "⚠️ 没有配置 Webhook!"
+ puts ""
+ puts "💡 创建 Webhook 的方法:"
+ puts " 在 Rails console 中执行:"
+ puts ""
+ puts " account = Account.find(2)"
+ puts " account.webhooks.create!("
+ puts " webhook_url: 'https://your-domain.com/webhook',"
+ puts " subscriptions: ['conversation_created', 'message_created'],"
+ puts " active: true"
+ puts " )"
+else
+ webhooks.each do |webhook|
+ puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ puts "Webhook ID: #{webhook.id}"
+ puts "URL: #{webhook.webhook_url}"
+ puts "启用: #{webhook.active ? '✅ 是' : '❌ 否'}"
+ puts "订阅事件: #{webhooks.subscriptions.inspect}"
+ puts "重试次数: #{webhook.retry_frequency}"
+ puts ""
+ end
+end
+
+# 3. 检查最近的 Webhook 调用
+puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+puts "📋 最近的 Webhook 调用日志:"
+puts ""
+
+recent_logs = account.webhook_logs.order(created_at: :desc).limit(5)
+
+if recent_logs.count == 0
+ puts "⚠️ 没有 Webhook 调用记录"
+else
+ recent_logs.each do |log|
+ puts "ID: #{log.id}"
+ puts "事件: #{log.event_type}"
+ puts "状态: #{log.status}"
+ puts "响应码: #{log.response_code}"
+ puts "创建时间: #{log.created_at}"
+ puts "错误: #{log.error_message || '无'}"
+ puts ""
+ end
+end
+
+# 4. 检查是否有 message_created 事件订阅
+puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+puts "🔍 检查 message_created 事件订阅:"
+
+has_subscription = webhooks.any? { |w| w.subscriptions.include?('message_created') }
+
+if has_subscription
+ puts "✅ 已订阅 message_created 事件"
+else
+ puts "❌ 未订阅 message_created 事件"
+ puts ""
+ puts "💡 添加订阅的方法:"
+ puts " webhook = Webhook.find(1)"
+ puts " webhook.update(subscriptions: ['conversation_created', 'message_created', 'message_updated'])"
+end
diff --git a/debug_webhook.rb b/debug_webhook.rb
new file mode 100644
index 0000000..404adcd
--- /dev/null
+++ b/debug_webhook.rb
@@ -0,0 +1,128 @@
+#!/usr/bin/env ruby
+# Webhook 调试脚本
+
+require_relative 'config/environment'
+
+puts "=== Webhook 调试 ==="
+puts ""
+
+# 1. 检查最近的消息
+puts "📋 1. 最近的 5 条消息:"
+puts ""
+
+recent_messages = Message.order(created_at: :desc).limit(5)
+
+recent_messages.each do |msg|
+ puts "ID: #{msg.id}"
+ puts "content_type: #{msg.content_type}"
+ puts "content: '#{msg.content}'"
+ puts "content_attributes: #{msg.content_attributes.inspect}"
+ puts "conversation_id: #{msg.conversation_id}"
+ puts "created_at: #{msg.created_at}"
+ puts "---"
+end
+
+# 2. 检查 Account 2 的 Webhook 配置
+puts ""
+puts "📋 2. Webhook 配置:"
+puts ""
+
+account = Account.find(2)
+webhooks = account.webhooks
+
+if webhooks.count == 0
+ puts "❌ 没有配置 Webhook!"
+else
+ webhooks.each do |webhook|
+ puts "Webhook ID: #{webhook.id}"
+ puts "URL: #{webhook.webhook_url}"
+ puts "启用: #{webhook.active}"
+ puts "订阅事件: #{webhook.subscriptions.inspect}"
+ puts ""
+ end
+end
+
+# 3. 检查最近的 Webhook 日志
+puts "📋 3. 最近的 Webhook 调用日志:"
+puts ""
+
+logs = account.webhook_logs.order(created_at: :desc).limit(10)
+
+if logs.count == 0
+ puts "⚠️ 没有 Webhook 调用记录"
+else
+ logs.each do |log|
+ puts "ID: #{log.id}"
+ puts "事件类型: #{log.event_type}"
+ puts "状态: #{log.status}"
+ puts "响应码: #{log.response_code}"
+ puts "创建时间: #{log.created_at}"
+
+ if log.error_message.present?
+ puts "错误信息: #{log.error_message}"
+ end
+
+ # 检查是否是 search_image 消息
+ if log.payload.present?
+ payload = JSON.parse(log.payload) rescue {}
+ if payload['content_type'] == 'search_image'
+ puts "✅ 这是 search_image 消息!"
+ puts "content: '#{payload['content']}'"
+ puts "content_attributes: #{payload['content_attributes'].inspect}"
+ end
+ end
+
+ puts "---"
+ end
+end
+
+# 4. 检查特定消息
+puts ""
+puts "📋 4. 检查 search_image 消息:"
+puts ""
+
+search_image_messages = Message.where(content_type: 'search_image').order(created_at: :desc).limit(5)
+
+if search_image_messages.count == 0
+ puts "⚠️ 没有 search_image 类型的消息"
+else
+ search_image_messages.each do |msg|
+ puts "ID: #{msg.id}"
+ puts "content: '#{msg.content}'"
+ puts "content_type: #{msg.content_type}"
+ puts "content_attributes: #{msg.content_attributes.inspect}"
+ puts "created_at: #{msg.created_at}"
+
+ # 检查是否有对应的 webhook 日志
+ log = account.webhook_logs.where("payload LIKE '%#{msg.id}%'").order(created_at: :desc).first
+ if log
+ puts "✅ 找到对应的 Webhook 日志"
+ puts " 状态: #{log.status}"
+ puts " 响应码: #{log.response_code}"
+ else
+ puts "❌ 没有找到对应的 Webhook 日志"
+ end
+
+ puts "---"
+ end
+end
+
+puts ""
+puts "📋 5. 检查 message_created 事件是否触发:"
+puts ""
+
+# 检查 Rails 配置
+puts "Dispatcher 配置:"
+puts Rails.configuration.dispatcher.inspect
+
+# 6. 检查 webhook_listener 配置
+puts ""
+puts "📋 6. 检查 Listener 配置:"
+puts ""
+
+listeners_listeners = Rails.configuration.dispatcher.listeners
+
+puts "注册的 Listeners: #{listeners_listeners.count}"
+listeners_listeners.each do |listener|
+ puts " - #{listener.class.name}"
+end
diff --git a/test_search_image_console.rb b/test_search_image_console.rb
new file mode 100644
index 0000000..2e5d9d6
--- /dev/null
+++ b/test_search_image_console.rb
@@ -0,0 +1,69 @@
+# Rails Console 测试脚本
+# 使用方法:rails c
+# 然后复制粘贴以下代码
+
+# ===== 配置参数 =====
+ACCOUNT_ID = 2
+INBOX_ID = 1
+USER_IDENTIFIER = '211845'
+
+# ===== 1. 查找联系人 =====
+contact = Contact.find_by(identifier: USER_IDENTIFIER)
+
+if contact.nil?
+ puts "❌ 联系人不存在"
+ exit
+end
+
+puts "✅ 联系人: #{contact.name} (ID: #{contact.id})"
+
+# ===== 2. 获取 contact_inbox =====
+contact_inbox = ContactInbox.where(
+ contact_id: contact.id,
+ inbox_id: INBOX_ID
+).first
+
+if contact_inbox.nil?
+ puts "❌ contact_inbox 不存在"
+ exit
+end
+
+puts "✅ contact_inbox ID: #{contact_inbox.id}"
+
+# ===== 3. 获取会话 =====
+conversation = Conversation.where(
+ contact_id: contact.id,
+ inbox_id: INBOX_ID
+).order(created_at: :desc).first
+
+if conversation.nil?
+ puts "❌ 会话不存在"
+ exit
+end
+
+puts "✅ 会话: #{conversation.display_id}"
+
+# ===== 4. 创建 search_image 消息 =====
+test_image_url = "https://img.gaia888.com/image/www/auto_202601/test_#{SecureRandom.uuid}.jpg"
+
+message = Message.create!(
+ account_id: ACCOUNT_ID,
+ conversation_id: conversation.id,
+ inbox_id: INBOX_ID,
+ sender: contact,
+ sender_type: 'Contact',
+ sender_id: contact.id,
+ message_type: :incoming,
+ content_type: :search_image,
+ content: '',
+ content_attributes: {
+ url: test_image_url
+ },
+ status: :sent
+)
+
+puts "\n✅ 消息创建成功!"
+puts "ID: #{message.id}"
+puts "content_type: #{message.content_type}"
+puts "content_attributes: #{message.content_attributes.inspect}"
+puts "创建时间: #{message.created_at}"
diff --git a/test_search_image_message.rb b/test_search_image_message.rb
new file mode 100755
index 0000000..a7b3550
--- /dev/null
+++ b/test_search_image_message.rb
@@ -0,0 +1,108 @@
+#!/usr/bin/env ruby
+# 模拟发送 search_image 类型消息的测试脚本
+
+require_relative 'config/environment'
+
+puts "🚀 开始模拟发送 search_image 消息..."
+
+# 配置参数
+ACCOUNT_ID = 2 # 根据你的实际情况调整
+INBOX_ID = 1 # 根据你的实际情况调整
+USER_IDENTIFIER = '211845'
+
+# 1. 查找或创建联系人
+puts "\n📋 步骤 1: 查找联系人..."
+contact = Contact.find_by(identifier: USER_IDENTIFIER)
+
+if contact.nil?
+ puts "❌ 联系人不存在,请先通过 widget 创建一个会话"
+ exit 1
+end
+
+puts "✅ 找到联系人: #{contact.name} (ID: #{contact.id})"
+
+# 2. 获取联系人的 inbox
+puts "\n📋 步骤 2: 查找 contact_inbox..."
+contact_inbox = ContactInbox.where(
+ contact_id: contact.id,
+ inbox_id: INBOX_ID
+).first
+
+if contact_inbox.nil?
+ puts "❌ contact_inbox 不存在,请先通过 widget 创建一个会话"
+ exit 1
+end
+
+puts "✅ 找到 contact_inbox (ID: #{contact_inbox.id})"
+
+# 3. 查找或创建会话
+puts "\n📋 步骤 3: 查找或创建会话..."
+conversation = Conversation.where(
+ contact_id: contact.id,
+ inbox_id: INBOX_ID,
+ account_id: ACCOUNT_ID
+).order(created_at: :desc).first
+
+if conversation.nil?
+ puts "📝 创建新会话..."
+ conversation = Conversation.create!(
+ account_id: ACCOUNT_ID,
+ inbox_id: INBOX_ID,
+ contact_id: contact.id,
+ contact_inbox_id: contact_inbox.id,
+ status: 'open'
+ )
+end
+
+puts "✅ 使用会话: #{conversation.display_id} (ID: #{conversation.id})"
+
+# 4. 创建 search_image 消息
+puts "\n📋 步骤 4: 创建 search_image 消息..."
+
+test_image_url = "https://img.gaia888.com/image/www/auto_202601/#{SecureRandom.uuid}.jpg"
+
+message = Message.new(
+ account_id: ACCOUNT_ID,
+ conversation_id: conversation.id,
+ inbox_id: INBOX_ID,
+ sender: contact,
+ sender_type: 'Contact',
+ sender_id: contact.id,
+ message_type: :incoming, # 0
+ content_type: :search_image, # 17
+ content: '',
+ content_attributes: {
+ url: test_image_url
+ },
+ status: :sent
+)
+
+if message.save
+ puts "✅ 消息创建成功!"
+ puts " - Message ID: #{message.id}"
+ puts " - content_type: #{message.content_type}"
+ puts " - content_attributes.url: #{message.content_attributes['url']}"
+else
+ puts "❌ 消息创建失败:"
+ puts message.errors.full_messages.inspect
+ exit 1
+end
+
+# 5. 触发 webhook 事件
+puts "\n📋 步骤 5: 触发 Webhook 事件..."
+puts "💡 消息已创建,Chatwoot 会自动触发以下事件:"
+puts " - message_created"
+puts " - conversation_updated (如果需要)"
+puts " - Webhook 通知到配置的 URL"
+
+puts "\n✅ 模拟完成!"
+puts "\n📊 消息详情:"
+puts " ID: #{message.id}"
+puts " 会话: #{conversation.display_id}"
+puts " 发送者: #{contact.name} (#{contact.identifier})"
+puts " 类型: search_image"
+puts " 图片 URL: #{test_image_url}"
+puts " 创建时间: #{message.created_at}"
+
+puts "\n🔗 你可以在以下位置查看:"
+puts " Dashboard: http://localhost:3000/app/accounts/#{ACCOUNT_ID}/inbox/#{INBOX_ID}/conversations/#{conversation.display_id}"
diff --git a/test_search_image_webhook.sh b/test_search_image_webhook.sh
new file mode 100755
index 0000000..31a9007
--- /dev/null
+++ b/test_search_image_webhook.sh
@@ -0,0 +1,42 @@
+#!/bin/bash
+
+# 模拟发送 search_image 消息到 Widget API
+# 使用方法: ./test_search_image_webhook.sh
+
+# 配置参数
+WEBSITE_TOKEN="9n9D3JFHBorFTZLD7cQ49TMg"
+BASE_URL="http://localhost:3000"
+CW_CONVERSATION="eyJhbGciOiJIUzI1NiJ9.eyJzb3VyY2VfaWQiOiI2Njc1ZGY3Ni1jM2MxLTQwMjktODUyNi0zN2UzMjQyMDFhMzAiLCJpbmJveF9pZCI6MSwiZXhwIjoxNzg1MDU3ODU5LCJpYXQiOjE3Njk1MDU4NTl9.SW4n7hnVjleWaVjKesdA60IZ5YAkAFn-3cUzH2f9F_M"
+
+# 测试图片 URL
+TEST_IMAGE_URL="https://img.gaia888.com/image/www/auto_202601/test-$(date +%s).jpg"
+
+echo "🚀 发送 search_image 消息到 Widget API..."
+echo "📸 图片 URL: $TEST_IMAGE_URL"
+echo ""
+
+# 发送请求
+curl -X POST "${BASE_URL}/api/v1/widget/messages?website_token=${WEBSITE_TOKEN}&cw_conversation=${CW_CONVERSATION}&locale=zh_CN" \
+ -H "Content-Type: application/json" \
+ -H "X-Auth-Token: ${CW_CONVERSATION}" \
+ -d "{
+ \"message\": {
+ \"content\": \"\",
+ \"content_type\": \"search_image\",
+ \"content_attributes\": {
+ \"url\": \"${TEST_IMAGE_URL}\"
+ },
+ \"timestamp\": \"$(date -u '+%a %b %d %Y %H:%M:%S GMT+0800 (中国标准时间)')\",
+ \"referer_url\": \"http://localhost:3000/widget_tests\"
+ }
+ }" \
+ -v
+
+echo ""
+echo ""
+echo "✅ 请求已发送"
+echo ""
+echo "📊 验证方法:"
+echo " 1. 检查 Dashboard: ${BASE_URL}/app/accounts/2/inbox/1"
+echo " 2. 查看 Webhook 接收日志"
+echo " 3. 刷新 Widget 页面查看消息"
diff --git a/test_webhook_manual.rb b/test_webhook_manual.rb
new file mode 100644
index 0000000..d855129
--- /dev/null
+++ b/test_webhook_manual.rb
@@ -0,0 +1,112 @@
+#!/usr/bin/env ruby
+# 手动触发 webhook 测试
+
+require_relative 'config/environment'
+
+puts "=== 手动触发 Webhook 测试 ==="
+puts ""
+
+# 获取最近的消息
+message = Message.where(content_type: 'search_image').order(created_at: :desc).first
+
+if message.nil?
+ puts "❌ 没有找到 search_image 消息"
+ puts "请先通过 widget 上传一张图片"
+ exit 1
+end
+
+puts "📋 消息信息:"
+puts "ID: #{message.id}"
+puts "content_type: #{message.content_type}"
+puts "content: '#{message.content}'"
+puts "content_attributes: #{message.content_attributes.inspect}"
+puts ""
+
+# 检查 webhook 配置
+account = message.account
+webhooks = account.webhooks
+
+if webhooks.count == 0
+ puts "❌ 没有配置 Webhook!"
+ puts ""
+ puts "💡 创建测试 Webhook:"
+ puts ""
+ puts " account = Account.find(2)"
+ puts " account.webhooks.create!("
+ puts " webhook_url: 'https://webhook.site/your-uuid',"
+ puts " subscriptions: ['message_created', 'conversation_created'],"
+ puts " active: true"
+ puts " )"
+ exit 1
+end
+
+puts "✅ 找到 #{webhooks.count} 个 Webhook 配置"
+puts ""
+
+webhooks.each do |webhook|
+ puts "━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ puts "Webhook ID: #{webhook.id}"
+ puts "URL: #{webhook.webhook_url}"
+ puts "启用: #{webhook.active}"
+ puts "订阅: #{webhook.subscriptions.inspect}"
+ puts ""
+
+ # 检查是否订阅了 message_created
+ unless webhook.subscriptions.include?('message_created')
+ puts "⚠️ 此 Webhook 没有订阅 message_created 事件"
+ next
+ end
+
+ puts "✅ 已订阅 message_created 事件"
+ puts ""
+
+ # 手动构建 payload
+ payload = message.webhook_data.merge(event: 'message_created')
+
+ puts "📤 即将发送的 Payload:"
+ puts JSON.pretty_generate(payload)
+ puts ""
+
+ # 手动触发 webhook
+ puts "🚀 手动触发 Webhook..."
+
+ begin
+ response = Webhooks::Trigger.execute(
+ webhook.webhook_url,
+ payload,
+ :account_webhook,
+ webhook.user_token
+ )
+
+ puts "✅ Webhook 发送成功!"
+ puts "响应: #{response.inspect}"
+ rescue => e
+ puts "❌ Webhook 发送失败: #{e.message}"
+ puts e.backtrace.first(5).join("\n")
+ end
+
+ puts ""
+end
+
+puts ""
+puts "📊 检查 Webhook 日志:"
+puts ""
+
+logs = account.webhook_logs.where(event_type: 'message_created').order(created_at: :desc).limit(3)
+
+if logs.count == 0
+ puts "⚠️ 没有 message_created 的日志记录"
+else
+ logs.each do |log|
+ puts "ID: #{log.id}"
+ puts "状态: #{log.status}"
+ puts "响应码: #{log.response_code}"
+ puts "时间: #{log.created_at}"
+
+ if log.error_message.present?
+ puts "错误: #{log.error_message}"
+ end
+
+ puts ""
+ end
+end