Files
memind/src/components/ChatView.tsx
T

316 lines
10 KiB
TypeScript
Raw Normal View History

import { useEffect, useState } from 'react';
import { useChat } from '../context/ChatProvider';
import { INSUFFICIENT_BALANCE_NOTICE } from '../hooks/useTKMindChat';
import type { CapabilityMap, PortalUser } from '../types';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { getSessionDisplayName } from '../utils/sessions';
import { BalanceRing } from './BalanceRing';
import { HistorySidebar } from './HistorySidebar';
import { TKMindAvatar } from './TKMindAvatar';
import { ChatPanel } from './ChatPanel';
import { ChatLoadingSpinner } from './ChatLoadingSpinner';
import { WechatBindPrompt } from './WechatBindPrompt';
import { WechatAccountButton } from './WechatAccountButton';
import { ChatHeaderMoreMenu } from './ChatHeaderMoreMenu';
import { NotificationCenter } from './NotificationCenter';
import type { MindSpaceSaveCategory } from '../types';
export function ChatView({
user,
capabilities,
grantedSkills,
onLogout,
onOpenSpace,
onOpenPage,
onOpenAdmin,
onOpenFeedback,
}: {
user?: PortalUser | null;
capabilities?: CapabilityMap;
grantedSkills?: string[];
onUserUpdate?: (user: PortalUser) => void;
onLogout?: () => void;
onOpenSpace?: (target?: { categoryCode?: MindSpaceSaveCategory; pageId?: string }) => void;
onOpenPage?: (pageId: string) => void;
onOpenAdmin?: () => void;
onOpenFeedback?: () => void;
}) {
const {
session,
sessions,
sessionsLoading,
sessionsLoadingMore,
sessionsHasMore,
sessionSearchQuery,
messages,
messageHistoryLoadingMore,
messageHistoryHasMore,
messageHistoryTotal,
chatState,
error,
notice,
pendingTool,
submit,
stop,
approveTool,
newSession,
switchSession,
deleteSession,
loadMoreSessions,
loadOlderMessages,
setSessionSearchQuery,
retryConnect,
dismissNotice,
onSidebarOpen,
balanceCents,
totalCreditCents,
tokensUsed,
subscription,
openRecharge,
openSubscribe,
uploadChatImage,
} = useChat();
const online = useNetworkStatus();
const [sidebarOpen, setSidebarOpen] = useState(false);
useEffect(() => {
if (sidebarOpen) {
onSidebarOpen();
}
}, [sidebarOpen, onSidebarOpen]);
2026-06-27 21:57:21 +08:00
const showHomeWelcome = messages.length === 0;
const handleSelectSession = (sessionId: string) => {
void switchSession(sessionId);
setSidebarOpen(false);
};
const handleNewSession = () => {
void newSession();
setSidebarOpen(false);
};
const isConnectingTitle = !session && chatState !== 'idle';
const sessionTitle = session
? getSessionDisplayName(session)
2026-06-27 21:57:21 +08:00
: chatState === 'idle'
? '新对话'
: '连接中…';
const moreMenuItems = [
{
id: 'new-session',
label: '新会话',
onClick: () => void handleNewSession(),
},
...(onOpenFeedback
? [{ id: 'feedback', label: '反馈与建议', onClick: () => onOpenFeedback() }]
: []),
...(onOpenAdmin
? [{ id: 'admin', label: '管理', onClick: () => onOpenAdmin() }]
: []),
...(onLogout
? [{ id: 'logout', label: '登出', onClick: () => onLogout(), danger: true }]
: []),
];
return (
<div className="app-shell">
<HistorySidebar
open={sidebarOpen}
sessions={sessions}
activeSessionId={session?.id}
loading={sessionsLoading}
loadingMore={sessionsLoadingMore}
hasMore={sessionsHasMore}
searchQuery={sessionSearchQuery}
onClose={() => setSidebarOpen(false)}
onSelect={handleSelectSession}
onNew={handleNewSession}
onDelete={(sessionId) => void deleteSession(sessionId)}
onLoadMore={() => void loadMoreSessions()}
onSearchChange={setSessionSearchQuery}
/>
2026-06-27 21:57:21 +08:00
<div className={`app${showHomeWelcome ? ' app-home' : ''}`}>
<header className={`header${showHomeWelcome ? ' header-home' : ''}`}>
<div className="header-left">
<button
type="button"
className="header-menu-btn"
aria-label="展开历史对话"
onClick={() => setSidebarOpen(true)}
>
☰
</button>
<TKMindAvatar size="sm" className="header-brand-avatar" />
<div>
<div className="header-title">{user?.displayName ?? 'TKMind'}</div>
<div className="header-sub">
{isConnectingTitle ? <ChatLoadingSpinner /> : null}
<span>{sessionTitle}</span>
</div>
</div>
</div>
<div className="header-actions header-actions-desktop">
{typeof balanceCents === 'number' && (
<BalanceRing
balanceCents={balanceCents}
totalCreditCents={totalCreditCents}
tokensUsed={tokensUsed}
subscription={subscription}
onRecharge={openRecharge}
onSubscribe={openSubscribe}
/>
)}
<NotificationCenter onOpenRecharge={() => openRecharge(false)} />
{onOpenAdmin && (
<button type="button" className="ghost-btn" onClick={onOpenAdmin}>
管理
</button>
)}
{onOpenSpace && (
<button type="button" className="ghost-btn" onClick={() => onOpenSpace?.()}>
我的空间
</button>
)}
{onOpenFeedback && (
<button type="button" className="ghost-btn" onClick={() => onOpenFeedback()}>
反馈
</button>
)}
<button type="button" className="ghost-btn" onClick={() => void handleNewSession()}>
新会话
</button>
{user && <WechatAccountButton returnTo={window.location.pathname} />}
{onLogout && (
<button type="button" className="ghost-btn logout-btn" onClick={onLogout}>
登出
</button>
)}
</div>
<div className="header-actions header-actions-mobile">
{typeof balanceCents === 'number' && (
<BalanceRing
balanceCents={balanceCents}
totalCreditCents={totalCreditCents}
tokensUsed={tokensUsed}
subscription={subscription}
onRecharge={openRecharge}
onSubscribe={openSubscribe}
/>
)}
<NotificationCenter onOpenRecharge={() => openRecharge(false)} />
{onOpenSpace && (
<button
type="button"
className="header-icon-btn"
aria-label="我的空间"
title="我的空间"
onClick={() => onOpenSpace?.()}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 8.5V19a1 1 0 001 1h14a1 1 0 001-1V8.5M4 8.5l8-4.5 8 4.5M4 8.5h16M9 21v-6h6v6"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
<ChatHeaderMoreMenu
items={moreMenuItems}
returnTo={window.location.pathname}
showWechat={Boolean(user)}
/>
</div>
</header>
{!online && (
<div className="banner banner-offline">网络已断开,请检查连接后重试</div>
)}
{user && <WechatBindPrompt returnTo={window.location.pathname} />}
{typeof balanceCents === 'number' && balanceCents <= 0 && (
<div className="banner banner-error">
<span>余额不足,请充值后继续使用</span>
<button type="button" className="banner-action" onClick={() => openRecharge(false)}>
去充值
</button>
</div>
)}
{typeof balanceCents === 'number' && balanceCents > 0 && balanceCents <= 100 && (
<div className="banner banner-warning">
<span>余额较低(¥{(balanceCents / 100).toFixed(2)}),建议提前充值</span>
<button type="button" className="banner-action" onClick={() => openRecharge(false)}>
去充值
</button>
</div>
)}
{notice && (
<div className={`banner${notice === INSUFFICIENT_BALANCE_NOTICE ? ' banner-warning' : ' banner-info'}`}>
<span style={{ whiteSpace: 'pre-line' }}>{notice}</span>
{notice === INSUFFICIENT_BALANCE_NOTICE ? (
<>
<button type="button" className="banner-action" onClick={() => openRecharge(false)}>
去充值
</button>
<button type="button" className="banner-dismiss" onClick={dismissNotice}>
知道了
</button>
</>
) : (
<button type="button" className="banner-dismiss" onClick={dismissNotice}>
知道了
</button>
)}
</div>
)}
{error && (
<div className="banner banner-error">
<span>{error}</span>
<button type="button" className="banner-action" onClick={() => void retryConnect()}>
重试
</button>
</div>
)}
<ChatPanel
variant="full"
user={user}
messages={messages}
historyLoadingMore={messageHistoryLoadingMore}
historyHasMore={messageHistoryHasMore}
historyTotal={messageHistoryTotal}
chatState={chatState}
pendingTool={pendingTool}
session={session}
capabilities={capabilities}
grantedSkills={grantedSkills}
onSubmit={(text, imageUrls, previewImageUrls) =>
void submit(text, undefined, imageUrls, previewImageUrls)
}
onUploadImage={uploadChatImage}
onLoadOlderMessages={loadOlderMessages}
onStop={stop}
onApproveTool={approveTool}
onPageSaved={(result) => {
if (result.kind === 'page' && result.pageId) {
onOpenPage?.(result.pageId);
return;
}
onOpenSpace?.({ categoryCode: result.categoryCode });
}}
/>
</div>
</div>
);
}