diff --git a/frontend-v2/frontend-v2/src/api/client.ts b/frontend-v2/frontend-v2/src/api/client.ts index 503aba2..a399854 100644 --- a/frontend-v2/frontend-v2/src/api/client.ts +++ b/frontend-v2/frontend-v2/src/api/client.ts @@ -31,6 +31,12 @@ async function data(path: string, options: RequestInit = {}): Promise { return env.data; } +// im_bot_user.py 专用 envelope:`{success, data, ...}`(无 code 字段)。 +async function successData(path: string, options: RequestInit = {}): Promise { + 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('/chat/conversations'), + listConversations: () => data('/chat/conversations'), - createConversation: (data: { title?: string; model?: string }) => - request('/chat/conversations', { + createConversation: (payload: { title?: string; model?: string }) => + data('/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(`/chat/conversations/${convId}/messages`), + data(`/chat/conversations/${convId}/messages`), sendMessage: (convId: string, data: { content: string; model?: string }) => request(`/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('/knowledge/bases'), +export interface KnowledgeBaseList { + bases: KnowledgeBase[]; + storage_used: number; + storage_limit: number; + membership_level: number; +} - createBase: (data: { name: string; description?: string }) => - request('/knowledge/bases', { +// 创建返回的子集(字段以 create 真实返回为准,缺失字段前端填充默认值) +export interface KnowledgeBaseCreated { + id: string; + name: string; + description: string; + created_at: string; +} + +export const knowledgeApi = { + listBases: () => data('/knowledge/bases'), + + createBase: (payload: { name: string; description?: string }) => + data('/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(`/apps/${appId}`), getMyApps: () => - request('/my-apps'), + data('/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('/im-bot/status'), + getStatus: () => successData('/im-bot/status'), getQRCode: () => request<{ qr_url: string; expire_at: string }>('/im-bot/qrcode'), diff --git a/frontend-v2/frontend-v2/src/hooks/useApi.ts b/frontend-v2/frontend-v2/src/hooks/useApi.ts index dd87f63..ccdfa75 100644 --- a/frontend-v2/frontend-v2/src/hooks/useApi.ts +++ b/frontend-v2/frontend-v2/src/hooks/useApi.ts @@ -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(d), + select: (d: MyApp[]) => Array.isArray(d) ? d : [], }); } diff --git a/frontend-v2/frontend-v2/src/pages/front/AppServices/index.tsx b/frontend-v2/frontend-v2/src/pages/front/AppServices/index.tsx index 57efde5..0cd9633 100644 --- a/frontend-v2/frontend-v2/src/pages/front/AppServices/index.tsx +++ b/frontend-v2/frontend-v2/src/pages/front/AppServices/index.tsx @@ -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 (

应用中心

-

管理第三方 AI 应用

+

管理已安装的 AI 应用

) : ( - apps.map((app: MarketApp) => ( + apps.map((app: MyApp) => (
- {app.type === 'standalone' ? ( + {app.icon ? ( + {app.name} + ) : app.type === 'standalone' ? ( ) : ( )}
- - {app.installed ? '已安装' : '未安装'} - - - {app.status === 'published' ? '已发布' : app.status} - + 已安装 + {app.type}

{app.name}

-

{app.description}

+

+ {app.price > 0 ? `¥${app.price.toFixed(2)}` : '免费'} + {app.purchased_at ? ` · 购买于 ${new Date(app.purchased_at).toLocaleDateString('zh-CN')}` : ''} +

@@ -81,13 +80,9 @@ export function AppServices() {
diff --git a/frontend-v2/frontend-v2/src/pages/front/ChatPage/index.tsx b/frontend-v2/frontend-v2/src/pages/front/ChatPage/index.tsx new file mode 100644 index 0000000..d209a13 --- /dev/null +++ b/frontend-v2/frontend-v2/src/pages/front/ChatPage/index.tsx @@ -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(null); + const [input, setInput] = useState(''); + const messagesEndRef = useRef(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 ( +
+ {/* Conversation List */} +
+
+
+ + +
+
+ + + + +
+ {convsLoading ? ( +
+ +
+ ) : ( + conversations.map((conv: Conversation) => ( +
+ + +
+ )) + )} +
+
+
+ + {/* Chat Area */} +
+ {/* Messages */} + + {!selectedConv ? ( +
+
+ +
+

开始与 AI 对话吧

+

+ 支持 Markdown 渲染和代码高亮,选择合适的模型开始聊天 +

+ +
+ ) : msgsLoading ? ( +
+ +
+ ) : ( +
+ {messages.map((msg: ChatMessage) => ( + +
+
{msg.content}
+ {msg.role === 'assistant' && ( +
+ + + + +
+ )} +
+
+ ))} + + {/* Streaming indicator */} + {streamingContent && ( + +
+
{streamingContent}
+
+
+
+
+
+
+ + )} +
+
+ )} + + + {/* Input Area */} +
+
+ setInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && !e.shiftKey && handleSend()} + placeholder={selectedConv ? '输入消息... (Shift+Enter 换行)' : '请先选择或创建对话'} + className="flex-1" + disabled={!selectedConv || sendMessage.isPending} + /> + +
+
+
+ Auto(自动匹配) +
+ +
+
+
+
+ ); +} + +// Import for streaming +import { chatApi } from '@/api/client'; diff --git a/frontend-v2/frontend-v2/src/pages/front/ImBotHub/index.tsx b/frontend-v2/frontend-v2/src/pages/front/ImBotHub/index.tsx new file mode 100644 index 0000000..4e94b92 --- /dev/null +++ b/frontend-v2/frontend-v2/src/pages/front/ImBotHub/index.tsx @@ -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 ( +
+
+
+

渠道管理

+

配置和管理您的 IM 渠道

+
+ +
+ + {/* WeChat Bot - Real Data */} + + + +
+
+
+ 💬 +
+
+

个人微信

+

+ {wechatStatus?.ilink_bot_id || '未配置'} +

+
+
+ + {isLoading ? ( + + ) : wechatStatus?.status === 'connected' ? ( + 已连接 + ) : wechatStatus?.status === 'expired' ? ( + 已过期 + ) : ( + 未连接 + )} + +
+ + {wechatStatus?.status === 'connected' && ( +
+ + {wechatStatus.message_count || 0} 条消息 + + + 连接于 {wechatStatus.connected_at ? new Date(wechatStatus.connected_at).toLocaleString('zh-CN') : '—'} + +
+ )} + + {wechatStatus?.status === 'expired' && ( +
+ 会话已过期,请重新扫码连接 +
+ )} + + + +
+ {wechatStatus?.status === 'connected' ? ( + <> + + + + + ) : ( + + )} +
+
+
+
+ + {/* Other Channels - Mock */} + + {otherChannels.map((channel) => ( + + +
+
+
+ {channel.icon} +
+
+

{channel.name}

+

未配置

+
+
+ + 未连接 + +
+ + + +
+ +
+
+
+ ))} +
+
+ ); +} diff --git a/frontend-v2/frontend-v2/src/pages/front/KnowledgePage/index.tsx b/frontend-v2/frontend-v2/src/pages/front/KnowledgePage/index.tsx new file mode 100644 index 0000000..24cfaaa --- /dev/null +++ b/frontend-v2/frontend-v2/src/pages/front/KnowledgePage/index.tsx @@ -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 ( +
+ {/* Search Bar */} + + +
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+

+ 支持跨库检索 · 向量相似度排序 · 来源溯源 +

+
+
+ + {/* Tabs */} + +
+ + 全部知识库 + 最近使用 + + + + + + + + 新建知识库 + +
+
+ + setNewName(e.target.value)} + /> +
+
+ + setNewDesc(e.target.value)} + /> +
+ +
+
+
+
+ + + + {isLoading ? ( +
+ +
+ ) : ( + bases.map((kb: KnowledgeBase) => ( + + +
+
+ +
+ + {kb.status === 'active' ? '运行中' : kb.status === 'building' ? '构建中' : '异常'} + +
+

{kb.name}

+
+ + {kb.document_count} 文档 + +
+ {kb.description && ( +

{kb.description}

+ )} +
+ + 更新于 {new Date(kb.updated_at).toLocaleDateString('zh-CN')} + +
+ + + +
+
+
+
+ )) + )} + + {/* Add New Card */} + setShowCreateDialog(true)} + > + +
+ +
+

新建知识库

+
+
+
+
+
+
+ ); +}