Files
users/app/ai-agent/channels/page.tsx

618 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client"
import { useState, useEffect } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import {
MessageSquare,
Plus,
Settings,
CheckCircle2,
XCircle,
RefreshCw,
Copy,
ExternalLink,
Webhook,
Bot,
Loader2,
Send,
Key,
Globe,
Smartphone,
} from "lucide-react"
// 渠道接口
interface Channel {
id: string
name: string
type: 'feishu' | 'wecom' | 'wechat' | 'api' | 'webhook'
status: 'connected' | 'disconnected' | 'pending'
config: {
appId?: string
appSecret?: string
webhookUrl?: string
token?: string
encryptKey?: string
}
stats: {
totalMessages: number
todayMessages: number
lastMessage?: string
}
createdAt: string
enabled: boolean
}
// 渠道类型配置 - 包含详细配置步骤
const CHANNEL_TYPES = {
feishu: {
name: '飞书',
icon: '🪶',
color: 'bg-blue-100 text-blue-700',
description: '对接飞书机器人,支持私聊和群聊',
configFields: ['appId', 'appSecret', 'encryptKey', 'token'],
steps: [
'1. 登录飞书开放平台 open.feishu.cn',
'2. 创建企业自建应用 → 机器人',
'3. 获取 App ID 和 App Secret',
'4. 配置事件订阅 URL: {apiBaseUrl}/feishu/events',
'5. 添加权限: im:message, im:message.group_at_msg',
'6. 将机器人添加到目标群聊',
'7. @机器人 即可开始对话',
],
},
wecom: {
name: '企业微信',
icon: '💼',
color: 'bg-green-100 text-green-700',
description: '对接企业微信应用或机器人',
configFields: ['corpId', 'agentId', 'secret', 'token', 'encodingAESKey'],
steps: [
'1. 登录企业微信管理后台 work.weixin.qq.com',
'2. 应用管理 → 自建应用 → 创建应用',
'3. 获取 CorpID、AgentId、Secret',
'4. 设置接收消息 URL: {apiBaseUrl}/wecom/callback',
'5. 配置 Token 和 EncodingAESKey',
'6. 开启客户联系、消息推送等权限',
'7. 发消息给应用即可触发对话',
],
},
wechat: {
name: '微信',
icon: '💬',
color: 'bg-emerald-100 text-emerald-700',
description: '通过存客宝对接微信私域',
configFields: ['ckbApiKey', 'ckbSecret'],
steps: [
'1. 登录存客宝后台 admin.cunkebao.com',
'2. 系统设置 → API配置',
'3. 获取 API Key 和 Secret',
'4. 配置消息回调 URL: {apiBaseUrl}/ckb/webhook',
'5. 在卡若AI配置存客宝凭证',
'6. 微信好友发消息即可触发AI回复',
],
},
api: {
name: 'API接口',
icon: '🔌',
color: 'bg-purple-100 text-purple-700',
description: '标准REST API供第三方系统调用',
configFields: ['apiKey'],
steps: [
'1. 在本页面生成 API Key',
'2. 调用方式: POST {apiBaseUrl}/chat',
'3. Header: Authorization: Bearer {API_KEY}',
'4. Body: { "message": "你的问题" }',
'5. 响应: { "success": true, "response": {...} }',
],
},
webhook: {
name: 'Webhook',
icon: '🔗',
color: 'bg-orange-100 text-orange-700',
description: '通用Webhook回调接口',
configFields: ['webhookUrl', 'secret'],
steps: [
'1. 配置接收 Webhook 的目标 URL',
'2. 设置签名密钥 (可选)',
'3. 神射手会在以下事件时推送:',
' - 新用户画像生成',
' - 流量包创建完成',
' - 报告生成完成',
'4. 推送格式: { "event": "...", "data": {...} }',
],
},
}
// 预定义渠道
const DEFAULT_CHANNELS: Channel[] = [
{
id: 'ch_feishu_1',
name: '卡若AI飞书机器人',
type: 'feishu',
status: 'connected',
config: {
appId: 'cli_a6xxx',
appSecret: '********',
token: 'karuo_token',
},
stats: {
totalMessages: 12580,
todayMessages: 156,
lastMessage: '5分钟前',
},
createdAt: '2024-01-15',
enabled: true,
},
{
id: 'ch_wecom_1',
name: '企业微信-神射手助手',
type: 'wecom',
status: 'connected',
config: {
appId: 'ww_xxx',
},
stats: {
totalMessages: 8920,
todayMessages: 89,
lastMessage: '12分钟前',
},
createdAt: '2024-02-01',
enabled: true,
},
{
id: 'ch_wechat_1',
name: '存客宝微信通道',
type: 'wechat',
status: 'connected',
config: {},
stats: {
totalMessages: 45600,
todayMessages: 320,
lastMessage: '1分钟前',
},
createdAt: '2024-01-01',
enabled: true,
},
{
id: 'ch_api_1',
name: '神射手API',
type: 'api',
status: 'connected',
config: {
apiKey: 'sk-archer-xxx',
},
stats: {
totalMessages: 156000,
todayMessages: 2340,
lastMessage: '刚刚',
},
createdAt: '2024-01-01',
enabled: true,
},
]
export default function ChannelsPage() {
const [channels, setChannels] = useState<Channel[]>(DEFAULT_CHANNELS)
const [loading, setLoading] = useState(false)
const [showAddDialog, setShowAddDialog] = useState(false)
const [showConfigDialog, setShowConfigDialog] = useState(false)
const [selectedChannel, setSelectedChannel] = useState<Channel | null>(null)
const [newChannelType, setNewChannelType] = useState<keyof typeof CHANNEL_TYPES>('feishu')
const [testLoading, setTestLoading] = useState<string | null>(null)
// API端点基础URL - 动态获取当前域名
const [apiBaseUrl, setApiBaseUrl] = useState('')
useEffect(() => {
// 获取当前部署的域名
const host = typeof window !== 'undefined' ? window.location.origin : ''
setApiBaseUrl(`${host}/api/shensheshou`)
}, [])
const formatNumber = (num: number): string => {
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`
return num.toLocaleString()
}
// 测试连接
const testConnection = async (channelId: string) => {
setTestLoading(channelId)
await new Promise(r => setTimeout(r, 2000))
setTestLoading(null)
}
// 切换启用状态
const toggleChannel = (channelId: string) => {
setChannels(channels.map(ch =>
ch.id === channelId ? { ...ch, enabled: !ch.enabled } : ch
))
}
// 复制到剪贴板
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
}
const getStatusBadge = (status: string) => {
switch (status) {
case 'connected':
return <Badge className="bg-green-100 text-green-700"><CheckCircle2 className="h-3 w-3 mr-1" /></Badge>
case 'disconnected':
return <Badge className="bg-red-100 text-red-700"><XCircle className="h-3 w-3 mr-1" /></Badge>
default:
return <Badge className="bg-yellow-100 text-yellow-700"><RefreshCw className="h-3 w-3 mr-1" /></Badge>
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50/30 to-purple-50/20">
<div className="p-6 space-y-6">
{/* 顶部标题 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="text-sm text-gray-500 mt-1">AI助手可通过多渠道访问</p>
</div>
<Button onClick={() => setShowAddDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-4 gap-4">
<Card className="border-0 shadow-sm bg-white/80">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500"></p>
<p className="text-2xl font-bold">{channels.length}</p>
</div>
<Webhook className="h-8 w-8 text-blue-500 opacity-50" />
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm bg-white/80">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500"></p>
<p className="text-2xl font-bold text-green-600">{channels.filter(c => c.status === 'connected').length}</p>
</div>
<CheckCircle2 className="h-8 w-8 text-green-500 opacity-50" />
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm bg-white/80">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500"></p>
<p className="text-2xl font-bold">{formatNumber(channels.reduce((sum, c) => sum + c.stats.todayMessages, 0))}</p>
</div>
<MessageSquare className="h-8 w-8 text-purple-500 opacity-50" />
</div>
</CardContent>
</Card>
<Card className="border-0 shadow-sm bg-white/80">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-500"></p>
<p className="text-2xl font-bold">{formatNumber(channels.reduce((sum, c) => sum + c.stats.totalMessages, 0))}</p>
</div>
<Send className="h-8 w-8 text-orange-500 opacity-50" />
</div>
</CardContent>
</Card>
</div>
{/* API接入说明 */}
<Card className="border-0 shadow-sm bg-gradient-to-r from-blue-50 to-purple-50">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold text-gray-900 mb-1">🔗 API接入端点</h3>
<p className="text-sm text-gray-600 mb-2">Skill通信</p>
<div className="space-y-2">
<div className="flex items-center gap-2">
<code className="bg-white px-3 py-1.5 rounded text-sm font-mono text-purple-600">
POST {apiBaseUrl}/chat
</code>
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(`${apiBaseUrl}/chat`)}>
<Copy className="h-3 w-3" />
</Button>
<span className="text-xs text-gray-500"></span>
</div>
<div className="flex items-center gap-2">
<code className="bg-white px-3 py-1.5 rounded text-sm font-mono text-purple-600">
POST {apiBaseUrl}/query
</code>
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(`${apiBaseUrl}/query`)}>
<Copy className="h-3 w-3" />
</Button>
<span className="text-xs text-gray-500"></span>
</div>
</div>
</div>
<Button variant="outline" size="sm">
<ExternalLink className="h-4 w-4 mr-1" />
API文档
</Button>
</div>
</CardContent>
</Card>
{/* 渠道列表 */}
<div className="space-y-3">
{channels.map(channel => {
const typeConfig = CHANNEL_TYPES[channel.type]
return (
<Card key={channel.id} className="border-0 shadow-sm bg-white/80 hover:shadow-md transition-all">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl ${typeConfig.color.split(' ')[0]} flex items-center justify-center text-2xl`}>
{typeConfig.icon}
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900">{channel.name}</h3>
<Badge className={typeConfig.color}>{typeConfig.name}</Badge>
{getStatusBadge(channel.status)}
</div>
<p className="text-sm text-gray-500 mt-0.5">{typeConfig.description}</p>
<div className="flex items-center gap-4 mt-1 text-xs text-gray-500">
<span>: {formatNumber(channel.stats.todayMessages)} </span>
<span>: {formatNumber(channel.stats.totalMessages)} </span>
{channel.stats.lastMessage && (
<span>: {channel.stats.lastMessage}</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-3">
<Switch
checked={channel.enabled}
onCheckedChange={() => toggleChannel(channel.id)}
/>
<Button
variant="outline"
size="sm"
onClick={() => testConnection(channel.id)}
disabled={testLoading === channel.id}
>
{testLoading === channel.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedChannel(channel)
setShowConfigDialog(true)
}}
>
<Settings className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
{/* 添加渠道弹窗 */}
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-3 py-4">
{Object.entries(CHANNEL_TYPES).map(([key, config]) => (
<div
key={key}
onClick={() => setNewChannelType(key as keyof typeof CHANNEL_TYPES)}
className={`p-4 rounded-xl border-2 cursor-pointer transition-all ${
newChannelType === key ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="text-2xl mb-2">{config.icon}</div>
<h4 className="font-semibold text-gray-900">{config.name}</h4>
<p className="text-xs text-gray-500 mt-1 line-clamp-2">{config.description}</p>
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowAddDialog(false)}></Button>
<Button onClick={() => {
// 跳转到配置页面
setShowAddDialog(false)
setShowConfigDialog(true)
setSelectedChannel({
id: `ch_${newChannelType}_new`,
name: '',
type: newChannelType,
status: 'pending',
config: {},
stats: { totalMessages: 0, todayMessages: 0 },
createdAt: new Date().toISOString().split('T')[0],
enabled: false,
})
}}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 配置弹窗 */}
<Dialog open={showConfigDialog} onOpenChange={setShowConfigDialog}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
{selectedChannel?.id.includes('new') ? '配置新渠道' : '编辑渠道配置'}
</DialogTitle>
<DialogDescription>
{selectedChannel && CHANNEL_TYPES[selectedChannel.type]?.description}
</DialogDescription>
</DialogHeader>
{selectedChannel && (
<div className="space-y-4 py-4">
{/* 配置步骤指南 */}
<div className="p-3 rounded-lg bg-blue-50 border border-blue-100">
<h4 className="font-medium text-blue-900 mb-2 flex items-center gap-2">
📋 {CHANNEL_TYPES[selectedChannel.type].name}
</h4>
<ol className="text-xs text-blue-800 space-y-1">
{(CHANNEL_TYPES[selectedChannel.type] as any).steps?.map((step: string, i: number) => (
<li key={i} className="flex items-start gap-1">
<span>{step.replace('{apiBaseUrl}', apiBaseUrl || 'https://your-domain.com/api/shensheshou')}</span>
</li>
))}
</ol>
</div>
<div className="space-y-2">
<Label></Label>
<Input
placeholder={`例如:${CHANNEL_TYPES[selectedChannel.type].name}机器人`}
defaultValue={selectedChannel.name}
/>
</div>
{selectedChannel.type === 'feishu' && (
<>
<div className="space-y-2">
<Label>App ID</Label>
<Input placeholder="cli_xxx" defaultValue={selectedChannel.config.appId} />
</div>
<div className="space-y-2">
<Label>App Secret</Label>
<Input type="password" placeholder="输入App Secret" />
</div>
<div className="space-y-2">
<Label>Verification Token</Label>
<Input placeholder="用于验证请求来源" defaultValue={selectedChannel.config.token} />
</div>
<div className="p-3 rounded-lg bg-blue-50 text-sm">
<p className="font-medium text-blue-800 mb-1">📋 </p>
<ol className="text-blue-700 text-xs space-y-1 list-decimal list-inside">
<li></li>
<li></li>
<li></li>
<li></li>
</ol>
</div>
</>
)}
{selectedChannel.type === 'wecom' && (
<>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>ID (CorpId)</Label>
<Input placeholder="ww_xxx" />
</div>
<div className="space-y-2">
<Label>ID (AgentId)</Label>
<Input placeholder="1000001" />
</div>
</div>
<div className="space-y-2">
<Label>Secret</Label>
<Input type="password" placeholder="输入应用Secret" />
</div>
</>
)}
{selectedChannel.type === 'wechat' && (
<>
<div className="space-y-2">
<Label>API Key</Label>
<Input placeholder="从存客宝获取" />
</div>
<div className="p-3 rounded-lg bg-green-50 text-sm">
<p className="font-medium text-green-800 mb-1">💡 </p>
<p className="text-green-700 text-xs">
SDK接入API密钥
</p>
</div>
</>
)}
{selectedChannel.type === 'api' && (
<>
<div className="space-y-2">
<Label>API Key</Label>
<div className="flex gap-2">
<Input value="sk-archer-xxx-xxx-xxx" readOnly className="font-mono" />
<Button variant="outline" size="icon">
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<pre className="p-3 rounded-lg bg-gray-900 text-green-400 text-xs overflow-x-auto">
{`curl -X POST ${apiBaseUrl}/chat \\
-H "Authorization: Bearer sk-archer-xxx" \\
-H "Content-Type: application/json" \\
-d '{"message": "查询用户 13800138000"}'`}
</pre>
</div>
</>
)}
{selectedChannel.type === 'webhook' && (
<>
<div className="space-y-2">
<Label>Webhook回调URL</Label>
<Input placeholder="https://your-server.com/webhook" />
</div>
<div className="space-y-2">
<Label></Label>
<Input placeholder="用于验证回调请求" />
</div>
</>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setShowConfigDialog(false)}></Button>
<Button onClick={() => {
setShowConfigDialog(false)
// 保存配置逻辑
}}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
)
}