Files
memind/src/components/ChatView.tsx
T

578 lines
19 KiB
TypeScript
Raw Normal View History

2026-07-03 00:34:03 +08:00
import { useEffect, useMemo, useRef, useState } from 'react';
import { useChat } from '../context/ChatProvider';
import { INSUFFICIENT_BALANCE_NOTICE } from '../hooks/useTKMindChat';
2026-07-03 00:34:03 +08:00
import type { CapabilityMap, PortalUser, SessionSummary } from '../types';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { getSessionDisplayName } from '../utils/sessions';
import { isH5OrWechatClient } from '../utils/wechat';
import { BalanceRing } from './BalanceRing';
import { HistorySidebar } from './HistorySidebar';
import { TKMindAvatar } from './TKMindAvatar';
import { ChatPanel } from './ChatPanel';
import { ChatLoadingSpinner } from './ChatLoadingSpinner';
2026-07-03 00:34:03 +08:00
import { ConversationPackagePanel } from './ConversationPackagePanel';
import { WechatBindPrompt } from './WechatBindPrompt';
import { WechatAccountButton } from './WechatAccountButton';
import { ChatHeaderMoreMenu } from './ChatHeaderMoreMenu';
import { NotificationCenter } from './NotificationCenter';
import type { MindSpaceSaveCategory } from '../types';
2026-07-03 00:34:03 +08:00
function RecentSessionRail({
sessions,
activeSessionId,
armedSessionId,
loading,
loadingMore,
hasMore,
chipWidth,
variant = 'inline',
2026-07-03 00:34:03 +08:00
onSelect,
onLoadMore,
}: {
sessions: SessionSummary[];
activeSessionId?: string | null;
armedSessionId?: string | null;
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
chipWidth?: number | null;
variant?: 'inline' | 'floating';
2026-07-03 00:34:03 +08:00
onSelect: (session: SessionSummary) => void;
onLoadMore: () => void;
}) {
const compactLabel = (value: string, maxChars = 8) => {
const trimmed = String(value ?? '').trim();
if (!trimmed) return '未命名';
return trimmed.length > maxChars ? `${trimmed.slice(0, maxChars)}...` : trimmed;
};
const railRef = useRef<HTMLDivElement | null>(null);
const recentSessions = useMemo(() => sessions, [sessions]);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
useEffect(() => {
const node = railRef.current;
if (!node) return;
const updateScrollState = () => {
const maxScrollLeft = Math.max(0, node.scrollWidth - node.clientWidth);
setCanScrollLeft(node.scrollLeft > 8);
setCanScrollRight(node.scrollLeft < maxScrollLeft - 8);
};
updateScrollState();
node.addEventListener('scroll', updateScrollState, { passive: true });
window.addEventListener('resize', updateScrollState);
return () => {
node.removeEventListener('scroll', updateScrollState);
window.removeEventListener('resize', updateScrollState);
};
}, [recentSessions.length]);
useEffect(() => {
const node = railRef.current;
if (!node) return;
const maybeLoadMore = () => {
if (!hasMore || loadingMore) return;
const remaining = node.scrollWidth - (node.scrollLeft + node.clientWidth);
if (remaining <= 96) onLoadMore();
};
maybeLoadMore();
node.addEventListener('scroll', maybeLoadMore, { passive: true });
return () => node.removeEventListener('scroll', maybeLoadMore);
}, [hasMore, loadingMore, onLoadMore, recentSessions.length]);
const scrollByPage = (direction: -1 | 1) => {
const node = railRef.current;
if (!node) return;
const amount = Math.max(180, Math.round(node.clientWidth * 0.82)) * direction;
node.scrollBy({ left: amount, behavior: 'smooth' });
};
if (!loading && recentSessions.length === 0) return null;
const isFloating = variant === 'floating';
2026-07-03 00:34:03 +08:00
return (
<div
className={`header-session-rail${isFloating ? ' header-session-rail-floating' : ''}`}
2026-07-03 00:34:03 +08:00
aria-label="最近对话入口"
style={
!isFloating && chipWidth && chipWidth > 0
2026-07-03 00:34:03 +08:00
? ({ ['--header-session-chip-width' as string]: `${chipWidth}px` })
: undefined
}
>
<button
type="button"
className="header-session-rail-arrow header-session-rail-arrow-left"
aria-label="查看更早的最近对话"
disabled={!canScrollLeft}
onClick={() => scrollByPage(-1)}
>
‹
</button>
<div
className="header-session-rail-track"
ref={railRef}
>
{recentSessions.map((item) => {
const active = item.id === activeSessionId;
const armed = item.id === armedSessionId;
const displayName = getSessionDisplayName(item);
return (
<button
key={item.id}
type="button"
className={`header-session-chip${active ? ' is-active' : ''}${armed ? ' is-armed' : ''}`}
aria-pressed={active || armed}
title={displayName}
onClick={() => onSelect(item)}
>
<strong>{compactLabel(displayName, 8)}</strong>
<span>{armed ? '再次点击' : item.message_count > 0 ? `${item.message_count}条` : '继续'}</span>
</button>
);
})}
{(loading || loadingMore) && (
<div className="header-session-chip header-session-chip-loading" aria-hidden="true">
<strong>{loading ? '加载中…' : '继续加载…'}</strong>
<span>{loading ? '正在准备最近对话' : '滑到这里会自动接上更多'}</span>
</div>
)}
</div>
<button
type="button"
className="header-session-rail-arrow header-session-rail-arrow-right"
aria-label="查看更多最近对话"
disabled={!canScrollRight}
onClick={() => scrollByPage(1)}
>
›
</button>
</div>
);
}
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);
2026-07-03 00:34:03 +08:00
const [packagePanelSession, setPackagePanelSession] = useState<SessionSummary | null>(null);
const [armedSessionId, setArmedSessionId] = useState<string | null>(null);
const [desktopChipWidth, setDesktopChipWidth] = useState<number | null>(null);
const spaceButtonRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const node = spaceButtonRef.current;
if (!node) return;
const updateWidth = () => {
const nextWidth = Math.round(node.getBoundingClientRect().width);
if (nextWidth > 0) setDesktopChipWidth(nextWidth);
};
updateWidth();
const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(updateWidth) : null;
resizeObserver?.observe(node);
window.addEventListener('resize', updateWidth);
return () => {
resizeObserver?.disconnect();
window.removeEventListener('resize', updateWidth);
};
}, [onOpenSpace]);
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);
};
2026-07-03 00:34:03 +08:00
const handleOpenConversationPackage = (sessionSummary: SessionSummary) => {
setPackagePanelSession(sessionSummary);
};
const handleRecentSessionSelect = (sessionSummary: SessionSummary) => {
const isSameSession = session?.id === sessionSummary.id;
const isArmed = armedSessionId === sessionSummary.id;
if (isSameSession && isArmed) {
handleOpenConversationPackage(sessionSummary);
setArmedSessionId(null);
return;
}
setPackagePanelSession(null);
setArmedSessionId(sessionSummary.id);
if (!isSameSession) {
handleSelectSession(sessionSummary.id);
}
};
const handleNewSession = () => {
void newSession();
setSidebarOpen(false);
};
const isConnectingTitle = !session && chatState !== 'idle';
2026-07-03 00:34:03 +08:00
const pendingSessionTitle =
chatState === 'connecting'
? '正在创建新对话…'
: chatState === 'waiting'
? '请求已发出…'
: '连接中…';
const sessionTitle = session
? getSessionDisplayName(session)
2026-06-27 21:57:21 +08:00
: chatState === 'idle'
? '新对话'
2026-07-03 00:34:03 +08:00
: pendingSessionTitle;
const moreMenuItems = [
...(onOpenAdmin
? [{ id: 'admin', label: '管理', onClick: () => onOpenAdmin() }]
: []),
...(onLogout
? [{ id: 'logout', label: '登出', onClick: () => onLogout(), danger: true }]
: []),
];
const useFloatingSessionRail = useMemo(() => isH5OrWechatClient(), []);
const recentSessionRailProps = {
sessions,
activeSessionId: session?.id,
armedSessionId,
loading: sessionsLoading,
loadingMore: sessionsLoadingMore,
hasMore: sessionsHasMore,
onSelect: handleRecentSessionSelect,
onLoadMore: () => void loadMoreSessions(),
};
return (
<div className={`app-shell${useFloatingSessionRail ? ' app-shell-h5' : ''}`}>
<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' : ''}`}>
<div className={useFloatingSessionRail ? 'header-shell header-shell-sticky' : undefined}>
<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">
{!useFloatingSessionRail && (
<RecentSessionRail
{...recentSessionRailProps}
chipWidth={desktopChipWidth}
/>
)}
{typeof balanceCents === 'number' && (
<BalanceRing
balanceCents={balanceCents}
totalCreditCents={totalCreditCents}
tokensUsed={tokensUsed}
subscription={subscription}
onRecharge={openRecharge}
onSubscribe={openSubscribe}
/>
)}
<NotificationCenter onOpenRecharge={() => openRecharge(false)} />
<button
type="button"
className="header-icon-btn"
aria-label="新聊天"
title="新聊天"
onClick={() => void handleNewSession()}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M12 5v14M5 12h14"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
/>
</svg>
</button>
{onOpenAdmin && (
<button type="button" className="ghost-btn" onClick={onOpenAdmin}>
管理
</button>
)}
{onOpenSpace && (
2026-07-03 00:34:03 +08:00
<button
ref={spaceButtonRef}
type="button"
className="ghost-btn"
onClick={() => onOpenSpace?.()}
>
我的空间
</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">
{!useFloatingSessionRail && (
<RecentSessionRail
{...recentSessionRailProps}
chipWidth={null}
/>
)}
{typeof balanceCents === 'number' && (
<BalanceRing
balanceCents={balanceCents}
totalCreditCents={totalCreditCents}
tokensUsed={tokensUsed}
subscription={subscription}
onRecharge={openRecharge}
onSubscribe={openSubscribe}
/>
)}
<NotificationCenter onOpenRecharge={() => openRecharge(false)} />
<button
type="button"
className="header-icon-btn"
aria-label="新聊天"
title="新聊天"
onClick={() => void handleNewSession()}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M12 5v14M5 12h14"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
/>
</svg>
</button>
{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>
{useFloatingSessionRail && (
<RecentSessionRail
{...recentSessionRailProps}
variant="floating"
chipWidth={null}
/>
)}
</div>
{!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, options) =>
void submit(
text,
{ messageId: options?.messageId, forceDeepReasoning: options?.forceDeepReasoning },
imageUrls,
previewImageUrls,
)
}
onUploadImage={(file, onProgress, options) =>
uploadChatImage(file, onProgress, { messageId: options?.messageId })
}
onLoadOlderMessages={loadOlderMessages}
onStop={stop}
onApproveTool={approveTool}
onPageSaved={(result) => {
if (result.kind === 'page' && result.pageId) {
onOpenPage?.(result.pageId);
return;
}
onOpenSpace?.({ categoryCode: result.categoryCode });
}}
/>
2026-07-03 00:34:03 +08:00
<ConversationPackagePanel
sessionId={packagePanelSession?.id ?? null}
sessionName={packagePanelSession ? getSessionDisplayName(packagePanelSession) : null}
open={Boolean(packagePanelSession)}
onClose={() => {
setPackagePanelSession(null);
setArmedSessionId(null);
}}
/>
</div>
</div>
);
}