2026-06-15 15:04:43 -07:00
|
|
|
|
import { useEffect, useState } from 'react';
|
|
|
|
|
|
import { useChat } from '../context/ChatProvider';
|
2026-06-15 22:09:38 -07:00
|
|
|
|
import { INSUFFICIENT_BALANCE_NOTICE } from '../hooks/useTKMindChat';
|
2026-06-15 15:04:43 -07:00
|
|
|
|
import type { CapabilityMap, PortalUser } from '../types';
|
|
|
|
|
|
import { useNetworkStatus } from '../hooks/useNetworkStatus';
|
|
|
|
|
|
import { getSessionDisplayName } from '../utils/sessions';
|
2026-06-15 22:09:38 -07:00
|
|
|
|
import { BalanceRing } from './BalanceRing';
|
2026-06-15 15:04:43 -07:00
|
|
|
|
import { HistorySidebar } from './HistorySidebar';
|
|
|
|
|
|
import { TKMindAvatar } from './TKMindAvatar';
|
|
|
|
|
|
import { ChatPanel } from './ChatPanel';
|
2026-06-30 09:30:51 +08:00
|
|
|
|
import { ChatLoadingSpinner } from './ChatLoadingSpinner';
|
2026-06-15 15:47:19 -07:00
|
|
|
|
import { WechatBindPrompt } from './WechatBindPrompt';
|
|
|
|
|
|
import { WechatAccountButton } from './WechatAccountButton';
|
2026-06-15 22:09:38 -07:00
|
|
|
|
import { ChatHeaderMoreMenu } from './ChatHeaderMoreMenu';
|
2026-06-26 15:19:03 +08:00
|
|
|
|
import { NotificationCenter } from './NotificationCenter';
|
2026-06-15 15:04:43 -07:00
|
|
|
|
import type { MindSpaceSaveCategory } from '../types';
|
|
|
|
|
|
|
|
|
|
|
|
export function ChatView({
|
|
|
|
|
|
user,
|
|
|
|
|
|
capabilities,
|
|
|
|
|
|
grantedSkills,
|
|
|
|
|
|
onLogout,
|
|
|
|
|
|
onOpenSpace,
|
|
|
|
|
|
onOpenPage,
|
|
|
|
|
|
onOpenAdmin,
|
2026-06-29 20:49:15 +08:00
|
|
|
|
onOpenFeedback,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
}: {
|
|
|
|
|
|
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;
|
2026-06-29 20:49:15 +08:00
|
|
|
|
onOpenFeedback?: () => void;
|
2026-06-15 15:04:43 -07:00
|
|
|
|
}) {
|
|
|
|
|
|
const {
|
|
|
|
|
|
session,
|
|
|
|
|
|
sessions,
|
|
|
|
|
|
sessionsLoading,
|
2026-06-29 20:49:15 +08:00
|
|
|
|
sessionsLoadingMore,
|
|
|
|
|
|
sessionsHasMore,
|
|
|
|
|
|
sessionSearchQuery,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
messages,
|
2026-06-29 20:49:15 +08:00
|
|
|
|
messageHistoryLoadingMore,
|
|
|
|
|
|
messageHistoryHasMore,
|
|
|
|
|
|
messageHistoryTotal,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
chatState,
|
|
|
|
|
|
error,
|
|
|
|
|
|
notice,
|
|
|
|
|
|
pendingTool,
|
|
|
|
|
|
submit,
|
|
|
|
|
|
stop,
|
|
|
|
|
|
approveTool,
|
|
|
|
|
|
newSession,
|
|
|
|
|
|
switchSession,
|
2026-06-15 22:09:38 -07:00
|
|
|
|
deleteSession,
|
2026-06-29 20:49:15 +08:00
|
|
|
|
loadMoreSessions,
|
|
|
|
|
|
loadOlderMessages,
|
|
|
|
|
|
setSessionSearchQuery,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
retryConnect,
|
|
|
|
|
|
dismissNotice,
|
|
|
|
|
|
onSidebarOpen,
|
|
|
|
|
|
balanceCents,
|
|
|
|
|
|
totalCreditCents,
|
2026-06-15 22:09:38 -07:00
|
|
|
|
tokensUsed,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
subscription,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
openRecharge,
|
2026-06-26 15:19:03 +08:00
|
|
|
|
openSubscribe,
|
2026-06-19 23:06:43 +08:00
|
|
|
|
uploadChatImage,
|
2026-06-15 15:04:43 -07:00
|
|
|
|
} = 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;
|
2026-06-15 15:04:43 -07:00
|
|
|
|
|
|
|
|
|
|
const handleSelectSession = (sessionId: string) => {
|
|
|
|
|
|
void switchSession(sessionId);
|
|
|
|
|
|
setSidebarOpen(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleNewSession = () => {
|
|
|
|
|
|
void newSession();
|
|
|
|
|
|
setSidebarOpen(false);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-30 09:30:51 +08:00
|
|
|
|
const isConnectingTitle = !session && chatState !== 'idle';
|
2026-06-15 22:09:38 -07:00
|
|
|
|
const sessionTitle = session
|
|
|
|
|
|
? getSessionDisplayName(session)
|
2026-06-27 21:57:21 +08:00
|
|
|
|
: chatState === 'idle'
|
2026-06-15 22:09:38 -07:00
|
|
|
|
? '新对话'
|
|
|
|
|
|
: '连接中…';
|
|
|
|
|
|
|
|
|
|
|
|
const moreMenuItems = [
|
|
|
|
|
|
{
|
|
|
|
|
|
id: 'new-session',
|
|
|
|
|
|
label: '新会话',
|
|
|
|
|
|
onClick: () => void handleNewSession(),
|
|
|
|
|
|
},
|
2026-06-29 20:49:15 +08:00
|
|
|
|
...(onOpenFeedback
|
|
|
|
|
|
? [{ id: 'feedback', label: '反馈与建议', onClick: () => onOpenFeedback() }]
|
|
|
|
|
|
: []),
|
2026-06-15 22:09:38 -07:00
|
|
|
|
...(onOpenAdmin
|
|
|
|
|
|
? [{ id: 'admin', label: '管理', onClick: () => onOpenAdmin() }]
|
|
|
|
|
|
: []),
|
|
|
|
|
|
...(onLogout
|
|
|
|
|
|
? [{ id: 'logout', label: '登出', onClick: () => onLogout(), danger: true }]
|
|
|
|
|
|
: []),
|
|
|
|
|
|
];
|
2026-06-15 15:04:43 -07:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="app-shell">
|
|
|
|
|
|
<HistorySidebar
|
|
|
|
|
|
open={sidebarOpen}
|
|
|
|
|
|
sessions={sessions}
|
|
|
|
|
|
activeSessionId={session?.id}
|
|
|
|
|
|
loading={sessionsLoading}
|
2026-06-29 20:49:15 +08:00
|
|
|
|
loadingMore={sessionsLoadingMore}
|
|
|
|
|
|
hasMore={sessionsHasMore}
|
|
|
|
|
|
searchQuery={sessionSearchQuery}
|
2026-06-15 15:04:43 -07:00
|
|
|
|
onClose={() => setSidebarOpen(false)}
|
|
|
|
|
|
onSelect={handleSelectSession}
|
|
|
|
|
|
onNew={handleNewSession}
|
2026-06-15 22:09:38 -07:00
|
|
|
|
onDelete={(sessionId) => void deleteSession(sessionId)}
|
2026-06-29 20:49:15 +08:00
|
|
|
|
onLoadMore={() => void loadMoreSessions()}
|
|
|
|
|
|
onSearchChange={setSessionSearchQuery}
|
2026-06-15 15:04:43 -07:00
|
|
|
|
/>
|
|
|
|
|
|
|
2026-06-27 21:57:21 +08:00
|
|
|
|
<div className={`app${showHomeWelcome ? ' app-home' : ''}`}>
|
|
|
|
|
|
<header className={`header${showHomeWelcome ? ' header-home' : ''}`}>
|
2026-06-15 15:04:43 -07:00
|
|
|
|
<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>
|
2026-06-30 09:30:51 +08:00
|
|
|
|
<div className="header-sub">
|
|
|
|
|
|
{isConnectingTitle ? <ChatLoadingSpinner /> : null}
|
|
|
|
|
|
<span>{sessionTitle}</span>
|
|
|
|
|
|
</div>
|
2026-06-15 15:04:43 -07:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-06-15 22:09:38 -07:00
|
|
|
|
<div className="header-actions header-actions-desktop">
|
2026-06-15 15:04:43 -07:00
|
|
|
|
{typeof balanceCents === 'number' && (
|
|
|
|
|
|
<BalanceRing
|
|
|
|
|
|
balanceCents={balanceCents}
|
|
|
|
|
|
totalCreditCents={totalCreditCents}
|
2026-06-15 22:09:38 -07:00
|
|
|
|
tokensUsed={tokensUsed}
|
2026-06-26 15:19:03 +08:00
|
|
|
|
subscription={subscription}
|
2026-06-15 15:04:43 -07:00
|
|
|
|
onRecharge={openRecharge}
|
2026-06-26 15:19:03 +08:00
|
|
|
|
onSubscribe={openSubscribe}
|
2026-06-15 15:04:43 -07:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-06-26 15:19:03 +08:00
|
|
|
|
<NotificationCenter onOpenRecharge={() => openRecharge(false)} />
|
2026-06-15 15:04:43 -07:00
|
|
|
|
{onOpenAdmin && (
|
|
|
|
|
|
<button type="button" className="ghost-btn" onClick={onOpenAdmin}>
|
|
|
|
|
|
管理
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{onOpenSpace && (
|
|
|
|
|
|
<button type="button" className="ghost-btn" onClick={() => onOpenSpace?.()}>
|
|
|
|
|
|
我的空间
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2026-06-29 20:49:15 +08:00
|
|
|
|
{onOpenFeedback && (
|
|
|
|
|
|
<button type="button" className="ghost-btn" onClick={() => onOpenFeedback()}>
|
|
|
|
|
|
反馈
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2026-06-15 15:04:43 -07:00
|
|
|
|
<button type="button" className="ghost-btn" onClick={() => void handleNewSession()}>
|
|
|
|
|
|
新会话
|
|
|
|
|
|
</button>
|
2026-06-15 15:47:19 -07:00
|
|
|
|
{user && <WechatAccountButton returnTo={window.location.pathname} />}
|
2026-06-15 15:04:43 -07:00
|
|
|
|
{onLogout && (
|
|
|
|
|
|
<button type="button" className="ghost-btn logout-btn" onClick={onLogout}>
|
|
|
|
|
|
登出
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2026-06-15 22:09:38 -07:00
|
|
|
|
|
|
|
|
|
|
<div className="header-actions header-actions-mobile">
|
|
|
|
|
|
{typeof balanceCents === 'number' && (
|
|
|
|
|
|
<BalanceRing
|
|
|
|
|
|
balanceCents={balanceCents}
|
|
|
|
|
|
totalCreditCents={totalCreditCents}
|
|
|
|
|
|
tokensUsed={tokensUsed}
|
2026-06-26 15:19:03 +08:00
|
|
|
|
subscription={subscription}
|
2026-06-15 22:09:38 -07:00
|
|
|
|
onRecharge={openRecharge}
|
2026-06-26 15:19:03 +08:00
|
|
|
|
onSubscribe={openSubscribe}
|
2026-06-15 22:09:38 -07:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-06-26 15:19:03 +08:00
|
|
|
|
<NotificationCenter onOpenRecharge={() => openRecharge(false)} />
|
2026-06-15 22:09:38 -07:00
|
|
|
|
{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>
|
2026-06-15 15:04:43 -07:00
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
{!online && (
|
|
|
|
|
|
<div className="banner banner-offline">网络已断开,请检查连接后重试</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-06-15 15:47:19 -07:00
|
|
|
|
{user && <WechatBindPrompt returnTo={window.location.pathname} />}
|
|
|
|
|
|
|
2026-06-15 15:04:43 -07:00
|
|
|
|
{typeof balanceCents === 'number' && balanceCents <= 0 && (
|
|
|
|
|
|
<div className="banner banner-error">
|
|
|
|
|
|
<span>余额不足,请充值后继续使用</span>
|
2026-06-15 22:09:38 -07:00
|
|
|
|
<button type="button" className="banner-action" onClick={() => openRecharge(false)}>
|
2026-06-15 15:04:43 -07:00
|
|
|
|
去充值
|
|
|
|
|
|
</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 && (
|
2026-06-15 22:09:38 -07:00
|
|
|
|
<div className={`banner${notice === INSUFFICIENT_BALANCE_NOTICE ? ' banner-warning' : ' banner-info'}`}>
|
2026-06-26 15:19:03 +08:00
|
|
|
|
<span style={{ whiteSpace: 'pre-line' }}>{notice}</span>
|
2026-06-15 22:09:38 -07:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-06-15 15:04:43 -07:00
|
|
|
|
</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}
|
2026-06-29 20:49:15 +08:00
|
|
|
|
historyLoadingMore={messageHistoryLoadingMore}
|
|
|
|
|
|
historyHasMore={messageHistoryHasMore}
|
|
|
|
|
|
historyTotal={messageHistoryTotal}
|
2026-06-15 15:04:43 -07:00
|
|
|
|
chatState={chatState}
|
|
|
|
|
|
pendingTool={pendingTool}
|
|
|
|
|
|
session={session}
|
|
|
|
|
|
capabilities={capabilities}
|
|
|
|
|
|
grantedSkills={grantedSkills}
|
2026-06-28 12:18:26 +08:00
|
|
|
|
onSubmit={(text, imageUrls, previewImageUrls) =>
|
|
|
|
|
|
void submit(text, undefined, imageUrls, previewImageUrls)
|
|
|
|
|
|
}
|
2026-06-19 23:06:43 +08:00
|
|
|
|
onUploadImage={uploadChatImage}
|
2026-06-29 20:49:15 +08:00
|
|
|
|
onLoadOlderMessages={loadOlderMessages}
|
2026-06-15 15:04:43 -07:00
|
|
|
|
onStop={stop}
|
|
|
|
|
|
onApproveTool={approveTool}
|
|
|
|
|
|
onPageSaved={(result) => {
|
|
|
|
|
|
if (result.kind === 'page' && result.pageId) {
|
|
|
|
|
|
onOpenPage?.(result.pageId);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
onOpenSpace?.({ categoryCode: result.categoryCode });
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|