[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill-21941ef2-d31d-45e9-a787-0dbedbaf445c":3,"$fOmerFQDhe0evHtNiBLY3Ls4zWhs0ji3aylirnJu9K0c":43},{"id":4,"title":5,"description":6,"categoryId":7,"moduleId":8,"tags":9,"prompt":10,"icon":11,"source":12,"sourceUrl":13,"authorId":14,"authorName":15,"isPublic":16,"stars":17,"runs":18,"createdAt":19,"updatedAt":19,"module":20,"category":27,"packages":34},"21941ef2-d31d-45e9-a787-0dbedbaf445c","chat-widget","构建一个带有浮动小部件的用户实时支持聊天系统和支持人员的管理仪表板。当用户需要实时聊天、客户支持聊天、实时消息或在应用内支持时使用。","cat_life_career","mod_other","sickn33,other","---\nname: chat-widget\ndescription: Build a real-time support chat system with a floating widget for users and an admin dashboard for support staff. Use when the user wants live chat, customer support chat, real-time messaging, or in-app support.\nrisk: unknown\nsource: community\n---\n\n# Live Support Chat Widget\n\nBuild a real-time support chat system with a floating widget for users and an admin dashboard for support staff.\n\n## When to Use This Skill\n\nUse when the user wants to:\n- Add a live chat widget to their app\n- Build customer support chat functionality\n- Create real-time messaging between users and admins\n- Add an in-app support channel\n\n## Architecture Overview\n\n```\n┌─────────────────────────────────────────────────────────────────┐\n│                        FRONTEND                                 │\n├─────────────────────────────┬───────────────────────────────────┤\n│   User Widget               │   Admin Dashboard                 │\n│   - Floating chat button    │   - Chat list (active\u002Farchived)   │\n│   - Message panel           │   - Conversation view             │\n│   - Unread badge            │   - Archive\u002Frestore controls      │\n│   - Connection indicator    │   - User info display             │\n└─────────────┬───────────────┴───────────────┬───────────────────┘\n              │                               │\n              │     WebSocket + REST API      │\n              ▼                               ▼\n┌─────────────────────────────────────────────────────────────────┐\n│                        BACKEND                                  │\n├─────────────────────────────────────────────────────────────────┤\n│   Channels                  │   Controllers                     │\n│   - ChatChannel (per chat)  │   - User: get\u002Fcreate chat         │\n│   - AdminChannel (global)   │   - Admin: list, view, archive    │\n├─────────────────────────────┼───────────────────────────────────┤\n│   Models                    │   Jobs                            │\n│   - Chat (1 per user)       │   - Email notification (delayed)  │\n│   - Message (many per chat) │                                   │\n└─────────────────────────────────────────────────────────────────┘\n```\n\n## Implementation Guide\n\n### Step 1: Data Models\n\nCreate two tables: `support_chats` and `support_messages`.\n\n**support_chats**\n```\nid              - primary key (UUID recommended)\nuser_id         - foreign key to users (UNIQUE - one chat per user)\nlast_message_at - timestamp (for sorting chats by recency)\nadmin_viewed_at - timestamp (tracks when admin last viewed)\narchived_at     - timestamp (null = active, set = archived)\ncreated_at\nupdated_at\n```\n\n**support_messages**\n```\nid              - primary key (UUID recommended)\nchat_id         - foreign key to support_chats\ncontent         - text (required)\nsender_type     - enum: 'user' | 'admin'\nread_at         - timestamp (null = unread)\ncreated_at\nupdated_at\n```\n\n**Key indexes:**\n- `support_chats.user_id` (unique)\n- `support_chats.last_message_at` (for sorting)\n- `support_chats.archived_at` (for filtering)\n- `support_messages.chat_id`\n- `support_messages.(chat_id, created_at)` (composite, for ordering)\n\n**Model relationships:**\n```\nUser has_one SupportChat\nSupportChat belongs_to User\nSupportChat has_many SupportMessages\nSupportMessage belongs_to SupportChat\n```\n\n**Model methods to implement:**\n\nChat model:\n```pseudo\nfunction touch_last_message()\n  update last_message_at = now()\n\nfunction unread_for_admin?()\n  return exists message where sender_type = 'user'\n    and created_at > admin_viewed_at\n\nfunction mark_viewed_by_admin()\n  update admin_viewed_at = now()\n\nfunction archive()\n  update archived_at = now()\n\nfunction unarchive()\n  update archived_at = null\n\nfunction archived?()\n  return archived_at != null\n```\n\nMessage model:\n```pseudo\nafter_create:\n  chat.touch_last_message()\n  if sender_type == 'user' and chat.archived?:\n    chat.unarchive()  \u002F\u002F Auto-reactivate on new user message\n\nafter_create_commit:\n  broadcast_to_chat_channel(message_data)\n  if sender_type == 'user':\n    broadcast_to_admin_notification_channel(message_data, chat_info)\n  if sender_type == 'admin':\n    schedule_email_notification(delay: 5.minutes)\n```\n\n### Step 2: API Endpoints\n\n**User-facing:**\n```\nGET  \u002Fsupport_chat       - Get or create user's chat with messages\nPATCH \u002Fsupport_chat\u002Fmark_read - Mark admin messages as read\n```\n\n**Admin-facing:**\n```\nGET  \u002Fadmin\u002Fchats              - List chats (query: archived=true\u002Ffalse)\nGET  \u002Fadmin\u002Fchats\u002F:id          - Get chat with messages\nPOST \u002Fadmin\u002Fchats\u002F:id\u002Farchive  - Archive chat\nPOST \u002Fadmin\u002Fchats\u002F:id\u002Funarchive - Restore chat\n```\n\n**Controller logic:**\n\nUser GET \u002Fsupport_chat:\n```pseudo\nfunction show()\n  chat = current_user.support_chat || create_chat(user: current_user)\n  return {\n    id: chat.id,\n    messages: chat.messages.map(m => serialize_message(m))\n  }\n```\n\nAdmin GET \u002Fadmin\u002Fchats:\n```pseudo\nfunction index()\n  chats = SupportChat\n    .where(archived_at: params.archived ? not_null : null)\n    .includes(:user, :messages)\n    .order(last_message_at: desc)\n\n  return chats.map(c => {\n    id: c.id,\n    user_email: c.user.email,\n    last_message_preview: c.messages.last?.content.truncate(100),\n    last_message_sender: c.messages.last?.sender_type,\n    message_count: c.messages.count,\n    unread: c.unread_for_admin?,\n    archived: c.archived?\n  })\n```\n\n### Step 3: WebSocket Channels\n\nCreate two channels for real-time communication.\n\n**ChatChannel** (specific to each chat):\n```pseudo\nclass ChatChannel\n  on_subscribe(chat_id):\n    chat = find_chat(chat_id)\n    if not authorized(chat):\n      reject()\n      return\n    stream_from \"support_chat:#{chat_id}\"\n\n  function authorized(chat):\n    return chat.user_id == current_user.id OR current_user.is_admin\n\n  action send_message(content):\n    if content.blank: return\n    sender_type = current_user.is_admin ? 'admin' : 'user'\n    chat.messages.create(content: content, sender_type: sender_type)\n```\n\n**AdminNotificationChannel** (global for all admins):\n```pseudo\nclass AdminNotificationChannel\n  on_subscribe:\n    if not current_user.is_admin:\n      reject()\n      return\n    stream_from \"admin_support_notifications\"\n```\n\n**Broadcasting (from Message model):**\n```pseudo\nfunction broadcast_message():\n  message_data = {\n    id: id,\n    content: content,\n    sender_type: sender_type,\n    read_at: read_at,\n    created_at: created_at\n  }\n\n  \u002F\u002F Broadcast to chat subscribers (user + any viewing admins)\n  broadcast(\"support_chat:#{chat.id}\", {\n    type: \"new_message\",\n    message: message_data\n  })\n\n  \u002F\u002F Notify all admins when user sends message\n  if sender_type == 'user':\n    broadcast(\"admin_support_notifications\", {\n      type: \"new_user_message\",\n      chat_id: chat.id,\n      user_email: chat.user.email,\n      message: message_data\n    })\n```\n\n### Step 4: Frontend - User Widget\n\nCreate a floating chat widget with these components:\n\n**Component structure:**\n```\nChatWidget (root container)\n├── ChatButton (fixed position, bottom-right)\n│   ├── Icon (message bubble when closed, X when open)\n│   └── UnreadBadge (shows count, caps at \"9+\")\n└── ChatPanel (slides up when open)\n    ├── Header (title + connection status dot)\n    ├── MessageList (scrollable)\n    │   └── MessageBubble (styled by sender_type)\n    └── InputArea\n        ├── Textarea (auto-expanding)\n        └── SendButton\n```\n\n**State management hook:**\n```pseudo\nfunction useSupportChat():\n  state:\n    chat: Chat | null\n    connected: boolean\n    loading: boolean\n\n  refs:\n    consumer: WebSocketConsumer\n    subscription: ChannelSubscription\n    seenMessageIds: Set\u003Cstring>  \u002F\u002F For deduplication\n\n  on_mount:\n    fetch('\u002Fsupport_chat')\n      .then(data => {\n        chat = data\n        seenMessageIds.addAll(data.messages.map(m => m.id))\n      })\n\n  when chat.id changes:\n    subscription = consumer.subscribe('ChatChannel', { chat_id: chat.id })\n    subscription.on_received(data => {\n      if data.type == 'new_message':\n        if seenMessageIds.has(data.message.id): return  \u002F\u002F Dedupe\n        seenMessageIds.add(data.message.id)\n        chat.messages.push(data.message)\n        if data.message.sender_type == 'admin':\n          play_notification_sound()\n    })\n    subscription.on_connected(() => connected = true)\n    subscription.on_disconnected(() => connected = false)\n\n  on_unmount:\n    subscription.unsubscribe()\n\n  function sendMessage(content):\n    subscription.perform('send_message', { content: content.trim() })\n\n  function markAsRead():\n    fetch('\u002Fsupport_chat\u002Fmark_read', { method: 'PATCH' })\n    \u002F\u002F Update local state to mark admin messages as read\n\n  return { chat, connected, loading, sendMessage, markAsRead }\n```\n\n**Widget behavior:**\n- Show floating button at bottom-right corner (fixed position)\n- Display unread count badge (count messages where sender_type='admin' and read_at=null)\n- Toggle panel open\u002Fclosed on button click\n- Auto-call markAsRead() when panel opens\n- Auto-scroll to bottom when new messages arrive\n- Show connection status indicator (green dot = connected)\n- Keyboard: Enter to send, Shift+Enter for newline\n\n**Message styling:**\n- User messages: right-aligned, primary color background\n- Admin messages: left-aligned, secondary\u002Fmuted background\n- Show timestamp on each message\n\n### Step 5: Frontend - Admin Dashboard\n\nCreate two pages: chat list and chat detail.\n\n**Chat List Page:**\n```\nHeader: \"Support Chats\"\nTabs: [Active] [Archived]\n\nChat cards (sorted by last_message_at desc):\n┌─────────────────────────────────────────┐\n│ [Unread indicator] user@example.com     │\n│ Last message preview text...            │\n│ 5 messages · 2 minutes ago              │\n└─────────────────────────────────────────┘\n```\n\nFeatures:\n- Tab filtering (active vs archived)\n- Unread indicator (highlight border or badge)\n- Click to navigate to detail\n- Show \"You: \" prefix if last message was from admin\n\n**Chat Detail Page:**\n```\nHeader: user@example.com [Archive\u002FRestore button]\nBack link\n\nMessages (grouped by date):\n──── Monday, January 29 ────\n[User bubble]  Message content\n               10:30 AM\n\n          [Admin bubble] Reply content\n                         10:35 AM\n\nInput area (same as widget)\n```\n\nFeatures:\n- Group messages by date with dividers\n- User messages left, admin messages right (opposite of user widget)\n- Show sender label (\"You\" for admin, user email\u002Fname for user)\n- Archive\u002Frestore toggle button\n- Same WebSocket subscription as user widget for real-time updates\n- Call mark_viewed_by_admin() when page loads (server-side)\n\n### Step 6: Email Notifications\n\nSend email to user when admin replies and user hasn't seen it.\n\n**Job\u002Fworker:**\n```pseudo\nclass SupportReplyNotificationJob\n  perform(message):\n    if message.sender_type != 'admin': return\n    if message.read_at != null: return  \u002F\u002F Already read, skip\n\n    send_email(\n      to: message.chat.user.email,\n      subject: \"New reply from Support\",\n      body: \"You have a new message from our support team...\"\n    )\n```\n\n**Scheduling:**\n- Schedule job with 5-minute delay when admin sends message\n- This gives user time to see message in-app before email\n- Job checks if still unread before sending\n\n### Step 7: TypeScript Types\n\n```typescript\ninterface SupportMessage {\n  id: string\n  content: string\n  sender_type: 'user' | 'admin'\n  read_at: string | null  \u002F\u002F ISO8601\n  created_at: string      \u002F\u002F ISO8601\n}\n\ninterface SupportChat {\n  id: string\n  messages: SupportMessage[]\n}\n\ninterface SupportChatListItem {\n  id: string\n  user_id: string\n  user_email: string\n  last_message_at: string | null\n  last_message_preview: string | null\n  last_message_sender: 'user' | 'admin' | null\n  message_count: number\n  unread: boolean\n  archived: boolean\n}\n\ninterface AdminSupportChat {\n  id: string\n  user_id: string\n  user_email: string\n  archived: boolean\n  messages: SupportMessage[]\n}\n\n\u002F\u002F WebSocket message types\ninterface ChatChannelMessage {\n  type: 'new_message'\n  message: SupportMessage\n}\n\ninterface AdminNotificationMessage {\n  type: 'new_user_message'\n  chat_id: string\n  user_email: string\n  message: SupportMessage\n}\n```\n\n## Key Design Decisions\n\n1. **One chat per user** - Simplifies UX, user always has same conversation history\n2. **Soft-delete via archiving** - Preserves history, allows restore\n3. **Auto-unarchive** - When user sends message to archived chat, reactivate it\n4. **Delayed email notifications** - 5 min delay prevents spam for rapid replies\n5. **Message deduplication** - Track seen IDs to prevent duplicates from send + broadcast echo\n6. **Separate admin channel** - Allows future features like global unread count, desktop notifications\n\n## Testing Checklist\n\nAfter implementation:\n- [ ] User can open widget and send message\n- [ ] Admin sees message in real-time on dashboard\n- [ ] Admin can reply and user sees it instantly\n- [ ] Unread badge shows correct count\n- [ ] Badge clears when widget opens\n- [ ] Connection indicator reflects actual status\n- [ ] Archive\u002Frestore works correctly\n- [ ] Auto-unarchive triggers on user message\n- [ ] Email sends after 5 min if message unread\n- [ ] Email does NOT send if user already read message\n- [ ] Messages appear in chronological order\n- [ ] No duplicate messages appear\n\n## Common Pitfalls\n\n1. **Forgetting deduplication** - Messages sent by current user echo back via broadcast\n2. **Race conditions on read status** - Use database transactions\n3. **WebSocket auth** - Verify user can access the specific chat\n4. **Stale connection status** - Handle reconnection gracefully\n5. **Missing indexes** - Add composite index on (chat_id, created_at)\n6. **Email timing** - Use background job, not synchronous send\n\n---\n\n## Framework-Specific Guidance\n\n### Ruby on Rails\n\n**Models:**\n```ruby\n# app\u002Fmodels\u002Fsupport_chat.rb\nclass SupportChat \u003C ApplicationRecord\n  belongs_to :user\n  has_many :support_messages, dependent: :destroy\n\n  scope :active, -> { where(archived_at: nil) }\n  scope :archived, -> { where.not(archived_at: nil) }\n  scope :recent_first, -> { order(last_message_at: :desc) }\n\n  def touch_last_message\n    update_column(:last_message_at, Time.current)\n  end\n\n  def unread_for_admin?\n    support_messages.where(sender_type: :user)\n      .where(\"created_at > ?\", admin_viewed_at || Time.at(0)).exists?\n  end\n\n  def archive!\n    update_column(:archived_at, Time.current)\n  end\n\n  def unarchive!\n    update_column(:archived_at, nil)\n  end\nend\n\n# app\u002Fmodels\u002Fsupport_message.rb\nclass SupportMessage \u003C ApplicationRecord\n  belongs_to :support_chat\n  enum :sender_type, { user: 0, admin: 1 }\n  validates :content, presence: true\n\n  after_create :update_chat_timestamp\n  after_create :auto_unarchive, if: :user?\n  after_create_commit :broadcast_message\n  after_create_commit :schedule_notification, if: :admin?\n\n  private\n\n  def broadcast_message\n    ActionCable.server.broadcast(\"support_chat:#{support_chat_id}\", {\n      type: \"new_message\",\n      message: { id:, content:, sender_type:, read_at:, created_at: }\n    })\n  end\n\n  def schedule_notification\n    SupportReplyNotificationJob.set(wait: 5.minutes).perform_later(self)\n  end\nend\n```\n\n**Channel:**\n```ruby\n# app\u002Fchannels\u002Fsupport_chat_channel.rb\nclass SupportChatChannel \u003C ApplicationCable::Channel\n  def subscribed\n    @chat = SupportChat.find(params[:chat_id])\n    reject unless @chat.user_id == current_user.id || current_user.admin?\n    stream_from \"support_chat:#{@chat.id}\"\n  end\n\n  def send_message(data)\n    @chat.support_messages.create!(\n      content: data[\"content\"],\n      sender_type: current_user.admin? ? :admin : :user\n    )\n  end\nend\n```\n\n**Migration:**\n```ruby\ncreate_table :support_chats, id: :uuid do |t|\n  t.references :user, type: :uuid, null: false, foreign_key: true, index: { unique: true }\n  t.datetime :last_message_at\n  t.datetime :admin_viewed_at\n  t.datetime :archived_at\n  t.timestamps\nend\n\ncreate_table :support_messages, id: :uuid do |t|\n  t.references :support_chat, type: :uuid, null: false, foreign_key: true\n  t.text :content, null: false\n  t.integer :sender_type, default: 0\n  t.datetime :read_at\n  t.timestamps\nend\nadd_index :support_messages, [:support_chat_id, :created_at]\n```\n\n### React (with any backend)\n\n**Hook:**\n```typescript\n\u002F\u002F hooks\u002FuseSupportChat.ts\nimport { useEffect, useState, useRef, useCallback } from 'react'\n\nexport function useSupportChat(websocketUrl: string) {\n  const [chat, setChat] = useState\u003CChat | null>(null)\n  const [connected, setConnected] = useState(false)\n  const wsRef = useRef\u003CWebSocket | null>(null)\n  const seenIds = useRef(new Set\u003Cstring>())\n\n  useEffect(() => {\n    fetch('\u002Fapi\u002Fsupport_chat').then(r => r.json()).then(data => {\n      setChat(data)\n      data.messages.forEach((m: Message) => seenIds.current.add(m.id))\n    })\n  }, [])\n\n  useEffect(() => {\n    if (!chat?.id) return\n    const ws = new WebSocket(`${websocketUrl}?chat_id=${chat.id}`)\n    wsRef.current = ws\n\n    ws.onopen = () => setConnected(true)\n    ws.onclose = () => setConnected(false)\n    ws.onmessage = (event) => {\n      const data = JSON.parse(event.data)\n      if (data.type === 'new_message' && !seenIds.current.has(data.message.id)) {\n        seenIds.current.add(data.message.id)\n        setChat(prev => prev ? { ...prev, messages: [...prev.messages, data.message] } : prev)\n      }\n    }\n    return () => ws.close()\n  }, [chat?.id])\n\n  const sendMessage = useCallback((content: string) => {\n    wsRef.current?.send(JSON.stringify({ action: 'send_message', content }))\n  }, [])\n\n  return { chat, connected, sendMessage }\n}\n```\n\n**Widget Component:**\n```tsx\n\u002F\u002F components\u002FChatWidget.tsx\nexport function ChatWidget() {\n  const [isOpen, setIsOpen] = useState(false)\n  const { chat, connected, sendMessage } = useSupportChat('\u002Fws\u002Fchat')\n  const [input, setInput] = useState('')\n  const messagesEndRef = useRef\u003CHTMLDivElement>(null)\n\n  const unreadCount = chat?.messages.filter(\n    m => m.sender_type === 'admin' && !m.read_at\n  ).length ?? 0\n\n  useEffect(() => {\n    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })\n  }, [chat?.messages])\n\n  const handleSend = () => {\n    if (!input.trim()) return\n    sendMessage(input.trim())\n    setInput('')\n  }\n\n  return (\n    \u003Cdiv className=\"fixed bottom-4 right-4 z-50\">\n      {isOpen ? (\n        \u003Cdiv className=\"w-80 h-96 bg-white rounded-lg shadow-xl flex flex-col\">\n          \u003Cheader className=\"p-3 border-b flex justify-between items-center\">\n            \u003Cspan>Support Chat\u003C\u002Fspan>\n            \u003Cspan className={`w-2 h-2 rounded-full ${connected ? 'bg-green-500' : 'bg-gray-400'}`} \u002F>\n          \u003C\u002Fheader>\n          \u003Cdiv className=\"flex-1 overflow-y-auto p-3 space-y-2\">\n            {chat?.messages.map(m => (\n              \u003Cdiv key={m.id} className={`p-2 rounded ${m.sender_type === 'user' ? 'bg-blue-100 ml-auto' : 'bg-gray-100'}`}>\n                {m.content}\n              \u003C\u002Fdiv>\n            ))}\n            \u003Cdiv ref={messagesEndRef} \u002F>\n          \u003C\u002Fdiv>\n          \u003Cdiv className=\"p-3 border-t flex gap-2\">\n            \u003Cinput value={input} onChange={e => setInput(e.target.value)}\n              onKeyDown={e => e.key === 'Enter' && !e.shiftKey && handleSend()}\n              className=\"flex-1 border rounded px-2\" placeholder=\"Type a message...\" \u002F>\n            \u003Cbutton onClick={handleSend} className=\"px-3 py-1 bg-blue-500 text-white rounded\">Send\u003C\u002Fbutton>\n          \u003C\u002Fdiv>\n        \u003C\u002Fdiv>\n      ) : (\n        \u003Cbutton onClick={() => setIsOpen(true)} className=\"w-14 h-14 bg-blue-500 rounded-full text-white relative\">\n          💬\n          {unreadCount > 0 && (\n            \u003Cspan className=\"absolute -top-1 -right-1 bg-red-500 text-xs w-5 h-5 rounded-full flex items-center justify-center\">\n              {unreadCount > 9 ? '9+' : unreadCount}\n            \u003C\u002Fspan>\n          )}\n        \u003C\u002Fbutton>\n      )}\n    \u003C\u002Fdiv>\n  )\n}\n```\n\n### Next.js (App Router)\n\n**API Route:**\n```typescript\n\u002F\u002F app\u002Fapi\u002Fsupport-chat\u002Froute.ts\nimport { getServerSession } from 'next-auth'\nimport { prisma } from '@\u002Flib\u002Fprisma'\n\nexport async function GET() {\n  const session = await getServerSession()\n  if (!session?.user) return Response.json({ error: 'Unauthorized' }, { status: 401 })\n\n  let chat = await prisma.supportChat.findUnique({\n    where: { userId: session.user.id },\n    include: { messages: { orderBy: { createdAt: 'asc' } } }\n  })\n\n  if (!chat) {\n    chat = await prisma.supportChat.create({\n      data: { userId: session.user.id },\n      include: { messages: true }\n    })\n  }\n\n  return Response.json(chat)\n}\n```\n\n**WebSocket with Pusher\u002FAbly (serverless-friendly):**\n```typescript\n\u002F\u002F For serverless, use Pusher, Ably, or similar\nimport Pusher from 'pusher'\nconst pusher = new Pusher({ appId, key, secret, cluster })\n\n\u002F\u002F When message is created:\nawait pusher.trigger(`support-chat-${chatId}`, 'new-message', messageData)\n\n\u002F\u002F Client-side with pusher-js:\nconst channel = pusher.subscribe(`support-chat-${chatId}`)\nchannel.bind('new-message', (data) => { \u002F* update state *\u002F })\n```\n\n### PHP\u002FLaravel\n\n**Models:**\n```php\n\u002F\u002F app\u002FModels\u002FSupportChat.php\nclass SupportChat extends Model\n{\n    protected $casts = ['last_message_at' => 'datetime', 'archived_at' => 'datetime'];\n\n    public function user() { return $this->belongsTo(User::class); }\n    public function messages() { return $this->hasMany(SupportMessage::class); }\n\n    public function scopeActive($query) { return $query->whereNull('archived_at'); }\n    public function scopeArchived($query) { return $query->whereNotNull('archived_at'); }\n\n    public function isUnreadForAdmin(): bool {\n        return $this->messages()\n            ->where('sender_type', 'user')\n            ->where('created_at', '>', $this->admin_viewed_at ?? '1970-01-01')\n            ->exists();\n    }\n}\n\n\u002F\u002F app\u002FModels\u002FSupportMessage.php\nclass SupportMessage extends Model\n{\n    protected static function booted() {\n        static::created(function ($message) {\n            $message->supportChat->update(['last_message_at' => now()]);\n            broadcast(new NewSupportMessage($message))->toOthers();\n\n            if ($message->sender_type === 'admin') {\n                SendSupportReplyNotification::dispatch($message)->delay(now()->addMinutes(5));\n            }\n        });\n    }\n}\n```\n\n**Broadcasting Event:**\n```php\n\u002F\u002F app\u002FEvents\u002FNewSupportMessage.php\nclass NewSupportMessage implements ShouldBroadcast\n{\n    public function __construct(public SupportMessage $message) {}\n\n    public function broadcastOn() {\n        return new PrivateChannel('support-chat.' . $this->message->support_chat_id);\n    }\n\n    public function broadcastAs() { return 'new-message'; }\n}\n```\n\n### Vue.js\n\n**Composable:**\n```typescript\n\u002F\u002F composables\u002FuseSupportChat.ts\nimport { ref, onMounted, onUnmounted } from 'vue'\n\nexport function useSupportChat() {\n  const chat = ref\u003CChat | null>(null)\n  const connected = ref(false)\n  let ws: WebSocket | null = null\n  const seenIds = new Set\u003Cstring>()\n\n  onMounted(async () => {\n    const res = await fetch('\u002Fapi\u002Fsupport-chat')\n    chat.value = await res.json()\n    chat.value?.messages.forEach(m => seenIds.add(m.id))\n\n    ws = new WebSocket(`\u002Fws\u002Fchat?id=${chat.value?.id}`)\n    ws.onopen = () => connected.value = true\n    ws.onclose = () => connected.value = false\n    ws.onmessage = (e) => {\n      const data = JSON.parse(e.data)\n      if (data.type === 'new_message' && !seenIds.has(data.message.id)) {\n        seenIds.add(data.message.id)\n        chat.value?.messages.push(data.message)\n      }\n    }\n  })\n\n  onUnmounted(() => ws?.close())\n\n  const sendMessage = (content: string) => {\n    ws?.send(JSON.stringify({ action: 'send_message', content }))\n  }\n\n  return { chat, connected, sendMessage }\n}\n```\n\n---\n\n## Database Recommendations\n\n### PostgreSQL (Recommended)\n- Use UUID primary keys for security (non-guessable IDs)\n- Use `timestamptz` for all datetime columns\n- Add GIN index on content for full-text search (optional)\n\n### MySQL\n- Use `CHAR(36)` or `BINARY(16)` for UUIDs\n- Use `DATETIME(6)` for microsecond precision\n- Consider `utf8mb4` charset for emoji support\n\n### SQLite (Development\u002FSmall Scale)\n- Works fine for prototyping\n- Store UUIDs as TEXT\n- No native datetime type, store as ISO8601 strings\n\n### MongoDB (Document Store)\n- Embed messages in chat document if message count is bounded\n- Or use separate collection with chat_id reference\n- Use TTL index on archived chats for auto-cleanup (optional)\n\n---\n\n## Email Processing Recommendations\n\n### Transactional Email Services\n- **Postmark** - Best deliverability, simple API\n- **SendGrid** - Good free tier, robust\n- **AWS SES** - Cheapest at scale\n- **Resend** - Modern DX, React email templates\n\n### Implementation Pattern\n```pseudo\n\u002F\u002F Always use background jobs for email\nJob: SendSupportReplyNotification\n  delay: 5 minutes after admin message\n\n  perform(message_id):\n    message = find_message(message_id)\n\n    \u002F\u002F Guard clauses - don't send if:\n    if message.sender_type != 'admin': return\n    if message.read_at != null: return        \u002F\u002F Already read\n    if message.chat.archived?: return         \u002F\u002F Chat archived\n\n    send_email(\n      to: message.chat.user.email,\n      template: 'support_reply',\n      data: { message_preview: message.content.truncate(200) }\n    )\n```\n\n### Email Template Tips\n- Include message preview (truncated)\n- Add direct link to open chat (if web app)\n- Keep subject simple: \"New reply from [App] Support\"\n- Include unsubscribe link for compliance\n\n---\n\n## Real-Time Technology Options\n\n| Technology | Best For | Serverless? |\n|------------|----------|-------------|\n| ActionCable (Rails) | Rails apps | No |\n| Socket.IO | Node.js apps | No |\n| Pusher | Any stack | Yes |\n| Ably | Any stack | Yes |\n| Supabase Realtime | Supabase users | Yes |\n| Firebase RTDB | Firebase users | Yes |\n| Server-Sent Events | Simple one-way | Yes |\n\n### Fallback Strategy\nIf WebSocket unavailable, implement polling:\n```pseudo\n\u002F\u002F Poll every 5 seconds when disconnected\nif (!websocket.connected) {\n  setInterval(() => {\n    fetch('\u002Fapi\u002Fsupport-chat\u002Fmessages?since=' + lastMessageTime)\n      .then(newMessages => appendMessages(newMessages))\n  }, 5000)\n}\n```\n\n## Limitations\n- Use this skill only when the task clearly matches the scope described above.\n- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.\n- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.\n","","imported","https:\u002F\u002Fgithub.com\u002Fsickn33\u002Fantigravity-awesome-skills","user_system_seed","SkillOPIC",true,141,670,"2026-05-16 13:10:27",{"id":8,"name":21,"slug":22,"icon":23,"description":24,"sort":25,"createdAt":26},"其他","other","mdi-page-next-outline","其他类型Skill",5,"2026-05-16 12:53:40",{"id":7,"name":28,"slug":29,"icon":30,"description":31,"moduleId":8,"sort":32,"skillCount":33,"createdAt":26},"职场发展","career","mdi-briefcase-outline","面试准备、简历优化、职业规划",4,575,[35],{"id":36,"skillId":4,"version":37,"fileName":38,"fileSize":39,"filePath":40,"fileHash":41,"manifest":42,"createdAt":19},"83afbb85-be8f-42ab-8593-0033f27c1a15","1.0.0","chat-widget.zip",9031,"uploads\u002Fskills\u002F21941ef2-d31d-45e9-a787-0dbedbaf445c\u002Fchat-widget.zip","e7bcfd2e0f3594ff625d445c221c4c3350fa44075a9627f0e6c9742ba6d160b0","[{\"path\":\"SKILL.md\",\"isDirectory\":false,\"size\":27586}]",{"code":44,"message":45,"data":46},200,"success",{"items":47,"stats":48,"page":51},[],{"averageRating":49,"totalRatings":49,"ratingCounts":50},0,[49,49,49,49,49],{"limit":52,"offset":49,"hasMore":53,"nextOffset":52,"ratedOnly":16},15,false]