Files
memind/src/components/ChatView.tsx
T

578 lines
19 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, useMemo, useRef, useState } from 'react';
import { useChat } from '../context/ChatProvider';
import { INSUFFICIENT_BALANCE_NOTICE } from '../hooks/useTKMindChat';
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';
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';
function RecentSessionRail({
sessions,
activeSessionId,
armedSessionId,
loading,
loadingMore,
hasMore,
chipWidth,
variant = 'inline',
onSelect,
onLoadMore,
}: {
sessions: SessionSummary[];
activeSessionId?: string | null;
armedSessionId?: string | null;
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
chipWidth?: number | null;
variant?: 'inline' | 'floating';
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';
return (
<div
className={`header-session-rail${isFloating ? ' header-session-rail-floating' : ''}`}
aria-label="最近对话入口"
style={
!isFloating && chipWidth && chipWidth > 0
? ({ ['--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);
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]);
const showHomeWelcome = messages.length === 0;
const handleSelectSession = (sessionId: string) => {
void switchSession(sessionId);
setSidebarOpen(false);
};
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';
const pendingSessionTitle =
chatState === 'connecting'
? '正在创建新对话…'
: chatState === 'waiting'
? '请求已发出…'
: '连接中…';
const sessionTitle = session
? getSessionDisplayName(session)
: chatState === 'idle'
? '新对话'
: 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}
/>
<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 && (
<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 });
}}
/>
<ConversationPackagePanel
sessionId={packagePanelSession?.id ?? null}
sessionName={packagePanelSession ? getSessionDisplayName(packagePanelSession) : null}
open={Boolean(packagePanelSession)}
onClose={() => {
setPackagePanelSession(null);
setArmedSessionId(null);
}}
/>
</div>
</div>
);
}