Files
memind/src/components/ChatView.tsx
T
john 722b18326f feat(mindspace): 0630004 空间 UI、聊天连接、微信分享与 Agent 能力
含 MindSpace 三列布局与统计修复、聊天加载态与连接降级、平台页脚标记与 og:site_name 微信卡片、勾选资料删除 Agent 接口及内部话术过滤。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 18:08:06 +08:00

316 lines
10 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.
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]);
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)
: 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}
/>
<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>
);
}