feat(frontend): #1001/#1003/#1004/#1005 前台4页契约对齐真实API(chat/knowledge/im-bot/my-apps) + 契约层解包
This commit is contained in:
1 parent
1ba81ba0d1
commit
8f30a588fb
6 files changed
+731
-43
No files matched your search
@@ -31,6 +31,12 @@ async function data<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
return env.data;
|
||||
}
|
||||
|
||||
// im_bot_user.py 专用 envelope:`{success, data, ...}`(无 code 字段)。
|
||||
async function successData<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
const env = await request<{ success: boolean; data: T }>(path, options);
|
||||
return env.data;
|
||||
}
|
||||
|
||||
// ─── Auth ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const authApi = {
|
||||
@@ -56,13 +62,19 @@ export const authApi = {
|
||||
|
||||
// ─── Chat ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// 后端契约 (chat.py):list/messages/create 均返回 {code,data:[...]}/{code,data:{...}}
|
||||
// 列表字段:id,title,model_id,status,pinned,source,created_at,updated_at(无 message_count)
|
||||
// 消息字段:id,conversation_id,role,content,model_id,created_at,token_used
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
model: string;
|
||||
model_id: string;
|
||||
status?: string;
|
||||
pinned?: boolean;
|
||||
source?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
message_count: number;
|
||||
message_count?: number; // 后端未返回,前端可选兼容
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
@@ -70,24 +82,25 @@ export interface ChatMessage {
|
||||
conversation_id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
model: string;
|
||||
model_id: string;
|
||||
created_at: string;
|
||||
token_used?: number;
|
||||
}
|
||||
|
||||
export const chatApi = {
|
||||
listConversations: () => request<Conversation[]>('/chat/conversations'),
|
||||
listConversations: () => data<Conversation[]>('/chat/conversations'),
|
||||
|
||||
createConversation: (data: { title?: string; model?: string }) =>
|
||||
request<Conversation>('/chat/conversations', {
|
||||
createConversation: (payload: { title?: string; model?: string }) =>
|
||||
data<Conversation>('/chat/conversations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
deleteConversation: (id: string) =>
|
||||
request(`/chat/conversations/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getMessages: (convId: string) =>
|
||||
request<ChatMessage[]>(`/chat/conversations/${convId}/messages`),
|
||||
data<ChatMessage[]>(`/chat/conversations/${convId}/messages`),
|
||||
|
||||
sendMessage: (convId: string, data: { content: string; model?: string }) =>
|
||||
request<ChatMessage>(`/chat/conversations/${convId}/messages`, {
|
||||
@@ -130,23 +143,41 @@ export const chatApi = {
|
||||
|
||||
// ─── Knowledge ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// 后端契约 (knowledge.py):
|
||||
// list 返回 {code,data:{bases:[...],storage_used,storage_limit,membership_level}}
|
||||
// create 返回 {code,data:{id,name,description,created_at}}(缺 document_count/status/updated_at)
|
||||
export interface KnowledgeBase {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
document_count: number;
|
||||
status: 'active' | 'building' | 'error';
|
||||
status: 'active' | 'building' | 'error' | string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const knowledgeApi = {
|
||||
listBases: () => request<KnowledgeBase[]>('/knowledge/bases'),
|
||||
export interface KnowledgeBaseList {
|
||||
bases: KnowledgeBase[];
|
||||
storage_used: number;
|
||||
storage_limit: number;
|
||||
membership_level: number;
|
||||
}
|
||||
|
||||
createBase: (data: { name: string; description?: string }) =>
|
||||
request<KnowledgeBase>('/knowledge/bases', {
|
||||
// 创建返回的子集(字段以 create 真实返回为准,缺失字段前端填充默认值)
|
||||
export interface KnowledgeBaseCreated {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const knowledgeApi = {
|
||||
listBases: () => data<KnowledgeBaseList>('/knowledge/bases'),
|
||||
|
||||
createBase: (payload: { name: string; description?: string }) =>
|
||||
data<KnowledgeBaseCreated>('/knowledge/bases', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
|
||||
deleteBase: (id: string) =>
|
||||
@@ -240,6 +271,18 @@ export interface MarketAppList {
|
||||
|
||||
export type MarketSort = 'latest' | 'popular' | 'rating';
|
||||
|
||||
// 后端契约 (market.py /my-apps):{code,data:[{id,app_id,name,icon,type,price,purchased_at}]}
|
||||
// 与 MarketApp 字段差距大(无 installed/status/description/version),单独建模。
|
||||
export interface MyApp {
|
||||
id: string;
|
||||
app_id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
type: MarketAppType;
|
||||
price: number;
|
||||
purchased_at: string | null;
|
||||
}
|
||||
|
||||
export const marketApi = {
|
||||
// 分页 + 筛选 + 排序 + 搜索,解包 {data:{total,page,page_size,items}}
|
||||
listApps: (params: {
|
||||
@@ -267,7 +310,7 @@ export const marketApi = {
|
||||
data<MarketAppDetail>(`/apps/${appId}`),
|
||||
|
||||
getMyApps: () =>
|
||||
request<MarketApp[]>('/my-apps'),
|
||||
data<MyApp[]>('/my-apps'),
|
||||
|
||||
getReviews: (appId: string, params: { page?: number; page_size?: number } = {}) => {
|
||||
const qs = new URLSearchParams();
|
||||
@@ -299,16 +342,22 @@ export const marketApi = {
|
||||
|
||||
// ─── IM Bot ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
// 后端契约 (im_bot_user.py /status):{success,data:{connected,status,configured,baseurl,ilink_bot_id,username,connected_at}}
|
||||
// 注意:data 内无 message_count / last_active(前端页面已用可选默认值兼容)。
|
||||
export interface ImBotStatus {
|
||||
status: 'connected' | 'disconnected' | 'expired';
|
||||
ilink_bot_id: string;
|
||||
connected_at: string;
|
||||
last_active: string;
|
||||
message_count: number;
|
||||
status: 'connected' | 'disconnected' | 'expired' | string;
|
||||
ilink_bot_id: string | null;
|
||||
connected_at: string | null;
|
||||
last_active?: string | null;
|
||||
message_count?: number;
|
||||
connected?: boolean;
|
||||
configured?: boolean;
|
||||
username?: string | null;
|
||||
baseurl?: string | null;
|
||||
}
|
||||
|
||||
export const imBotApi = {
|
||||
getStatus: () => request<ImBotStatus>('/im-bot/status'),
|
||||
getStatus: () => successData<ImBotStatus>('/im-bot/status'),
|
||||
|
||||
getQRCode: () => request<{ qr_url: string; expire_at: string }>('/im-bot/qrcode'),
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
profileApi,
|
||||
tokenApi,
|
||||
type TokenOverview,
|
||||
type MarketApp,
|
||||
type MyApp,
|
||||
type MarketAd,
|
||||
type MarketSort,
|
||||
type MarketAppType,
|
||||
@@ -91,6 +91,16 @@ export function useKnowledgeBases() {
|
||||
return useQuery({
|
||||
queryKey: ['knowledge', 'bases'],
|
||||
queryFn: knowledgeApi.listBases,
|
||||
select: (d) => {
|
||||
const bases = Array.isArray(d?.bases) ? d.bases : [];
|
||||
// 后端 create 返回缺 document_count/status/updated_at,这里补默认值避免渲染崩溃
|
||||
return bases.map((kb) => ({
|
||||
...kb,
|
||||
document_count: kb.document_count ?? 0,
|
||||
status: kb.status ?? 'active',
|
||||
updated_at: kb.updated_at ?? kb.created_at ?? '',
|
||||
}));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -159,7 +169,7 @@ export function useMyApps() {
|
||||
return useQuery({
|
||||
queryKey: ['market', 'my-apps'],
|
||||
queryFn: marketApi.getMyApps,
|
||||
select: (d: unknown) => asArray<MarketApp>(d),
|
||||
select: (d: MyApp[]) => Array.isArray(d) ? d : [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,24 +8,22 @@ import {
|
||||
Plus,
|
||||
ExternalLink,
|
||||
Settings,
|
||||
Play,
|
||||
Globe,
|
||||
Monitor,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { useMyApps } from '@/hooks/useApi';
|
||||
import type { MarketApp } from '@/api/client';
|
||||
import type { MyApp } from '@/api/client';
|
||||
|
||||
export function AppServices() {
|
||||
const { data: rawApps, isLoading } = useMyApps();
|
||||
const apps: MarketApp[] = Array.isArray(rawApps) ? rawApps : (((rawApps as any)?.data ?? (rawApps as any)?.items) ?? []);
|
||||
const { data: apps = [], isLoading } = useMyApps();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">应用中心</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">管理第三方 AI 应用</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">管理已安装的 AI 应用</p>
|
||||
</div>
|
||||
<Button className="gap-2">
|
||||
<Plus size={16} /> 创建应用
|
||||
@@ -48,29 +46,30 @@ export function AppServices() {
|
||||
<p className="text-sm">去应用市场浏览并安装应用</p>
|
||||
</div>
|
||||
) : (
|
||||
apps.map((app: MarketApp) => (
|
||||
apps.map((app: MyApp) => (
|
||||
<Card key={app.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
{app.type === 'standalone' ? (
|
||||
{app.icon ? (
|
||||
<img src={app.icon} alt={app.name} className="w-6 h-6 rounded" />
|
||||
) : app.type === 'standalone' ? (
|
||||
<Globe size={20} className="text-primary" />
|
||||
) : (
|
||||
<Monitor size={20} className="text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={app.installed ? 'success' : 'secondary'}>
|
||||
{app.installed ? '已安装' : '未安装'}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{app.status === 'published' ? '已发布' : app.status}
|
||||
</Badge>
|
||||
<Badge variant="success">已安装</Badge>
|
||||
<Badge variant="outline">{app.type}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold text-foreground mb-2">{app.name}</h3>
|
||||
<p className="text-sm text-muted-foreground line-clamp-2 mb-4">{app.description}</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{app.price > 0 ? `¥${app.price.toFixed(2)}` : '免费'}
|
||||
{app.purchased_at ? ` · 购买于 ${new Date(app.purchased_at).toLocaleDateString('zh-CN')}` : ''}
|
||||
</p>
|
||||
|
||||
<Separator className="mb-4" />
|
||||
|
||||
@@ -81,13 +80,9 @@ export function AppServices() {
|
||||
<Button
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
variant={app.installed ? 'default' : 'outline'}
|
||||
variant="default"
|
||||
>
|
||||
{app.installed ? (
|
||||
<><ExternalLink size={14} /> 打开</>
|
||||
) : (
|
||||
<><Play size={14} /> 安装</>
|
||||
)}
|
||||
<ExternalLink size={14} /> 打开
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Send,
|
||||
Sparkles,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
MessageSquare,
|
||||
Copy,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useConversations,
|
||||
useMessages,
|
||||
useCreateConversation,
|
||||
useSendMessage,
|
||||
useDeleteConversation,
|
||||
} from '@/hooks/useApi';
|
||||
import type { Conversation, ChatMessage } from '@/api/client';
|
||||
|
||||
export function ChatPage() {
|
||||
const [selectedConv, setSelectedConv] = useState<string | null>(null);
|
||||
const [input, setInput] = useState('');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
|
||||
// API hooks
|
||||
const { data: conversations = [], isLoading: convsLoading } = useConversations();
|
||||
const { data: messages = [], isLoading: msgsLoading } = useMessages(selectedConv);
|
||||
const createConv = useCreateConversation();
|
||||
const sendMessage = useSendMessage(selectedConv || '');
|
||||
const deleteConv = useDeleteConversation();
|
||||
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages, streamingContent]);
|
||||
|
||||
const handleNewChat = async () => {
|
||||
try {
|
||||
const conv = await createConv.mutateAsync({ title: '新对话' });
|
||||
setSelectedConv(conv.id);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!input.trim() || !selectedConv) return;
|
||||
const content = input;
|
||||
setInput('');
|
||||
setStreamingContent('');
|
||||
|
||||
try {
|
||||
// Try streaming first
|
||||
const stream = chatApi.streamMessage(selectedConv, { content });
|
||||
let accumulated = '';
|
||||
for await (const chunk of stream) {
|
||||
accumulated += chunk;
|
||||
setStreamingContent(accumulated);
|
||||
}
|
||||
setStreamingContent('');
|
||||
// Refresh messages
|
||||
} catch {
|
||||
// Fallback to regular send
|
||||
await sendMessage.mutateAsync({ content });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteConv.mutateAsync(id);
|
||||
if (selectedConv === id) setSelectedConv(null);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] gap-4">
|
||||
{/* Conversation List */}
|
||||
<div className="w-72 flex-shrink-0 flex flex-col border border-border rounded-xl bg-white dark:bg-gray-950 overflow-hidden">
|
||||
<div className="p-3 border-b border-border">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索对话..."
|
||||
className="h-9 w-full rounded-lg bg-muted pl-9 pr-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="mx-3 mt-3 gap-2"
|
||||
variant="outline"
|
||||
onClick={handleNewChat}
|
||||
disabled={createConv.isPending}
|
||||
>
|
||||
<Plus size={16} /> 新建对话
|
||||
</Button>
|
||||
|
||||
<ScrollArea className="flex-1 px-2 py-2">
|
||||
<div className="space-y-1">
|
||||
{convsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 size={20} className="animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((conv: Conversation) => (
|
||||
<div key={conv.id} className="group relative">
|
||||
<button
|
||||
onClick={() => setSelectedConv(conv.id)}
|
||||
className={`w-full text-left px-3 py-2.5 rounded-lg transition-colors ${
|
||||
selectedConv === conv.id
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'hover:bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare size={14} className="shrink-0" />
|
||||
<span className="text-sm truncate flex-1">{conv.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 ml-5">
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{conv.model_id || 'Auto'}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{new Date(conv.updated_at).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleDelete(conv.id); }}
|
||||
className="absolute right-2 top-2 p-1 rounded opacity-0 group-hover:opacity-100 hover:bg-destructive/10 transition-opacity"
|
||||
>
|
||||
<Trash2 size={12} className="text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Chat Area */}
|
||||
<div className="flex-1 flex flex-col border border-border rounded-xl bg-white dark:bg-gray-950 overflow-hidden">
|
||||
{/* Messages */}
|
||||
<ScrollArea className="flex-1 p-6">
|
||||
{!selectedConv ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
|
||||
<Sparkles className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">开始与 AI 对话吧</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
支持 Markdown 渲染和代码高亮,选择合适的模型开始聊天
|
||||
</p>
|
||||
<Button className="mt-4 gap-2" onClick={handleNewChat}>
|
||||
<Plus size={16} /> 新建对话
|
||||
</Button>
|
||||
</div>
|
||||
) : msgsLoading ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<Loader2 size={24} className="animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{messages.map((msg: ChatMessage) => (
|
||||
<motion.div
|
||||
key={msg.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div className={`max-w-[80%] rounded-xl px-4 py-3 ${
|
||||
msg.role === 'user'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-foreground'
|
||||
}`}>
|
||||
<div className="text-sm whitespace-pre-wrap">{msg.content}</div>
|
||||
{msg.role === 'assistant' && (
|
||||
<div className="flex items-center gap-2 mt-2 pt-2 border-t border-border/50">
|
||||
<button className="p-1 hover:bg-background/50 rounded transition-colors">
|
||||
<Copy size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<button className="p-1 hover:bg-background/50 rounded transition-colors">
|
||||
<ThumbsUp size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<button className="p-1 hover:bg-background/50 rounded transition-colors">
|
||||
<ThumbsDown size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<button className="p-1 hover:bg-background/50 rounded transition-colors">
|
||||
<RotateCcw size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* Streaming indicator */}
|
||||
{streamingContent && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex justify-start"
|
||||
>
|
||||
<div className="max-w-[80%] rounded-xl px-4 py-3 bg-muted text-foreground">
|
||||
<div className="text-sm whitespace-pre-wrap">{streamingContent}</div>
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse [animation-delay:0.2s]" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse [animation-delay:0.4s]" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="p-4 border-t border-border">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()}
|
||||
placeholder={selectedConv ? '输入消息... (Shift+Enter 换行)' : '请先选择或创建对话'}
|
||||
className="flex-1"
|
||||
disabled={!selectedConv || sendMessage.isPending}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || !selectedConv || sendMessage.isPending}
|
||||
className="gap-2"
|
||||
>
|
||||
{sendMessage.isPending ? (
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
) : (
|
||||
<Send size={16} />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Sparkles size={12} /> Auto(自动匹配)
|
||||
</div>
|
||||
<button className="p-1 hover:bg-muted rounded transition-colors">
|
||||
<Settings size={16} className="text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Import for streaming
|
||||
import { chatApi } from '@/api/client';
|
||||
@@ -0,0 +1,163 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Plus,
|
||||
MessageSquare,
|
||||
Settings,
|
||||
QrCode,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { useImBotStatus, useDisconnectBot } from '@/hooks/useApi';
|
||||
|
||||
const otherChannels = [
|
||||
{ id: '2', name: '企业微信', icon: '🏢', status: 'disconnected' as const, botId: '', lastMessage: '', messageCount: 0 },
|
||||
{ id: '3', name: '飞书', icon: '📨', status: 'disconnected' as const, botId: '', lastMessage: '', messageCount: 0 },
|
||||
{ id: '4', name: 'QQ', icon: '🐧', status: 'disconnected' as const, botId: '', lastMessage: '', messageCount: 0 },
|
||||
];
|
||||
|
||||
export function ImBotHub() {
|
||||
const { data: wechatStatus, isLoading } = useImBotStatus();
|
||||
const disconnectBot = useDisconnectBot();
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
if (!confirm('确定断开微信连接?')) return;
|
||||
try {
|
||||
await disconnectBot.mutateAsync();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">渠道管理</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">配置和管理您的 IM 渠道</p>
|
||||
</div>
|
||||
<Button className="gap-2">
|
||||
<Plus size={16} /> 添加渠道
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* WeChat Bot - Real Data */}
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}>
|
||||
<Card className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-green-50 flex items-center justify-center text-2xl">
|
||||
💬
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">个人微信</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{wechatStatus?.ilink_bot_id || '未配置'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={wechatStatus?.status === 'connected' ? 'success' : 'secondary'}>
|
||||
{isLoading ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : wechatStatus?.status === 'connected' ? (
|
||||
<span className="flex items-center gap-1"><Wifi size={12} /> 已连接</span>
|
||||
) : wechatStatus?.status === 'expired' ? (
|
||||
<span className="flex items-center gap-1 text-amber-600"><AlertTriangle size={12} /> 已过期</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1"><WifiOff size={12} /> 未连接</span>
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{wechatStatus?.status === 'connected' && (
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground mb-4">
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageSquare size={14} /> {wechatStatus.message_count || 0} 条消息
|
||||
</span>
|
||||
<span>
|
||||
连接于 {wechatStatus.connected_at ? new Date(wechatStatus.connected_at).toLocaleString('zh-CN') : '—'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wechatStatus?.status === 'expired' && (
|
||||
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200 text-amber-700 text-sm mb-4">
|
||||
会话已过期,请重新扫码连接
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator className="mb-4" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{wechatStatus?.status === 'connected' ? (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<Settings size={14} /> 管理
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="gap-1">
|
||||
<ExternalLink size={14} /> 查看对话
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1 text-destructive hover:text-destructive"
|
||||
onClick={handleDisconnect}
|
||||
disabled={disconnectBot.isPending}
|
||||
>
|
||||
{disconnectBot.isPending ? <Loader2 size={14} className="animate-spin" /> : null}
|
||||
断开连接
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" className="gap-1">
|
||||
<QrCode size={14} /> 扫码绑定
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Other Channels - Mock */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
{otherChannels.map((channel) => (
|
||||
<Card key={channel.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center text-2xl">
|
||||
{channel.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-foreground">{channel.name}</h3>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">未配置</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
<span className="flex items-center gap-1"><WifiOff size={12} /> 未连接</span>
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Separator className="mb-4" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" className="gap-1">
|
||||
<QrCode size={14} /> 扫码绑定
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Upload,
|
||||
FileText,
|
||||
Database,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
useKnowledgeBases,
|
||||
useCreateKnowledgeBase,
|
||||
useDeleteKnowledgeBase,
|
||||
} from '@/hooks/useApi';
|
||||
import type { KnowledgeBase } from '@/api/client';
|
||||
|
||||
export function KnowledgePage() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
|
||||
const { data: bases = [], isLoading } = useKnowledgeBases();
|
||||
const createBase = useCreateKnowledgeBase();
|
||||
const deleteBase = useDeleteKnowledgeBase();
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim()) return;
|
||||
try {
|
||||
await createBase.mutateAsync({ name: newName, description: newDesc });
|
||||
setShowCreateDialog(false);
|
||||
setNewName('');
|
||||
setNewDesc('');
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定删除此知识库?')) return;
|
||||
try {
|
||||
await deleteBase.mutateAsync(id);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Search Bar */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="在所有知识库中检索问题..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
支持跨库检索 · 向量相似度排序 · 来源溯源
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="all">
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">全部知识库</TabsTrigger>
|
||||
<TabsTrigger value="recent">最近使用</TabsTrigger>
|
||||
</TabsList>
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="gap-2">
|
||||
<Plus size={16} /> 新建知识库
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建知识库</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>名称</Label>
|
||||
<Input
|
||||
placeholder="知识库名称"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>描述</Label>
|
||||
<Input
|
||||
placeholder="简要描述(可选)"
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleCreate}
|
||||
disabled={!newName.trim() || createBase.isPending}
|
||||
>
|
||||
{createBase.isPending ? (
|
||||
<><Loader2 size={16} className="animate-spin" /> 创建中...</>
|
||||
) : '创建'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<TabsContent value="all" className="mt-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="col-span-full flex items-center justify-center py-12">
|
||||
<Loader2 size={24} className="animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
bases.map((kb: KnowledgeBase) => (
|
||||
<Card key={kb.id} className="hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
|
||||
<Database size={20} className="text-primary" />
|
||||
</div>
|
||||
<Badge variant={kb.status === 'active' ? 'success' : 'secondary'}>
|
||||
{kb.status === 'active' ? '运行中' : kb.status === 'building' ? '构建中' : '异常'}
|
||||
</Badge>
|
||||
</div>
|
||||
<h3 className="font-semibold text-foreground mb-1">{kb.name}</h3>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<FileText size={14} /> {kb.document_count} 文档
|
||||
</span>
|
||||
</div>
|
||||
{kb.description && (
|
||||
<p className="text-xs text-muted-foreground mt-2 line-clamp-2">{kb.description}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
更新于 {new Date(kb.updated_at).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button className="p-1.5 hover:bg-muted rounded-lg transition-colors">
|
||||
<Upload size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<button className="p-1.5 hover:bg-muted rounded-lg transition-colors">
|
||||
<RefreshCw size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(kb.id)}
|
||||
className="p-1.5 hover:bg-destructive/10 rounded-lg transition-colors"
|
||||
disabled={deleteBase.isPending}
|
||||
>
|
||||
<Trash2 size={14} className="text-destructive" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Add New Card */}
|
||||
<Card
|
||||
className="border-dashed hover:shadow-md transition-shadow cursor-pointer"
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
>
|
||||
<CardContent className="p-5 flex flex-col items-center justify-center h-full min-h-[200px]">
|
||||
<div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center mb-3">
|
||||
<Plus size={20} className="text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-muted-foreground">新建知识库</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user