578 lines
19 KiB
TypeScript
578 lines
19 KiB
TypeScript
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>
|
||
);
|
||
}
|