PicoBot/web/src/components/Chat/ChatContainer.tsx
oudecheng a629486ad3 perf(chat): 长话题历史分页——messages 按 seq keyset 增量加载
后端新增 load_messages_for_topic_page(seq < cursor + limit 分页,走 (session_id, seq) 索引替代 OFFSET 深翻页),ChatMessage 增加 seq 游标;历史批次消息带 topic_id 下发,前端以 seq+topic_id 双重判定批次归属,规避切话题瞬间在途旧批次污染。

前端触顶增量加载:批次缓存在 pendingHistoryRef,收到 topic_history_end 一次性去重 prepend,scrollTop 按新增高度补偿锚定原头部消息;不足一屏自动补页,loading 超时 10s 自愈;流式输出中加载历史不清空流式累加器,避免已流出文本丢失。
2026-08-18 07:51:31 +08:00

154 lines
5.2 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 { useState, useCallback, memo } from 'react';
import { MessageList } from './MessageList';
import { MessageInput } from './MessageInput';
import { ExpertSelector } from './ExpertSelector';
import { ModelSelector } from './ModelSelector';
import { Zap } from 'lucide-react';
import type { ChatMessage, Attachment } from '../../types/protocol';
interface ChatContainerProps {
messages: ChatMessage[];
isLoading: boolean;
isReadOnly?: boolean;
channelName?: string;
onSendMessage: (content: string, attachments: Attachment[]) => void;
onNavigateToSubAgent?: (taskId: string, description: string, subagentType?: string) => void;
onStop?: () => void;
showThinking?: boolean;
/** 视图标识,用于保存/恢复滚动位置 */
viewKey?: string;
/** 高亮的消息 ID */
highlightedMessageId?: string | null;
/** 当前 session ID用于专家选择 */
sessionId?: string | null;
/** 打开设置页(用于专家管理入口) */
onOpenSettings?: () => void;
/** 设置弹窗关闭信号(每次关闭递增,用于触发 ExpertSelector 刷新) */
settingsClosedTick?: number;
/** 当前话题 ID用于切换话题时清空输入框草稿 */
topicId?: string | null;
/** 历史分页:是否还有更早的消息可加载 */
hasMoreOlder?: boolean;
/** 历史分页:是否正在加载更早一页 */
loadingOlder?: boolean;
/** 触顶时请求加载更早的历史消息 */
onLoadOlder?: () => void;
}
// memoprops 除 messages 外全部稳定useCallback/原始值),
// App 因非消息类 state侧栏折叠、主题等重渲染时跳过整个聊天子树。
export const ChatContainer = memo(function ChatContainer({
messages,
isLoading,
isReadOnly = false,
channelName,
onSendMessage,
onNavigateToSubAgent,
onStop,
showThinking = true,
viewKey,
highlightedMessageId,
sessionId,
onOpenSettings,
settingsClosedTick,
topicId,
hasMoreOlder,
loadingOlder,
onLoadOlder,
}: ChatContainerProps) {
const [selectedExpert, setSelectedExpert] = useState<{
name: string;
description: string;
} | null>(null);
// 当前生效模型(供 Task 卡片差异显示:子代理模型 ≠ 主代理模型时提示)
const [effectiveModel, setEffectiveModel] = useState<{
provider: string;
model: string;
} | null>(null);
// 稳定引用,避免内联箭头破坏下游 memo 化
const handleModelSelectionChange = useCallback(
(effective: { provider: string; model: string }) =>
setEffectiveModel({ provider: effective.provider, model: effective.model }),
[],
);
const selectors = (
<div className="flex flex-wrap items-center gap-1 px-3 pt-2">
<ExpertSelector
sessionId={sessionId ?? null}
onManageExperts={onOpenSettings}
onSelectionChange={setSelectedExpert}
settingsClosedTick={settingsClosedTick}
/>
<ModelSelector
sessionId={sessionId ?? null}
topicId={topicId ?? null}
settingsClosedTick={settingsClosedTick}
onSelectionChange={handleModelSelectionChange}
/>
</div>
);
const input = (
<MessageInput
onSend={onSendMessage}
onStop={onStop}
disabled={isLoading}
isLoading={isLoading}
isReadOnly={isReadOnly}
channelName={channelName}
selectedExpert={selectedExpert}
topicId={topicId}
/>
);
/* Hero 空状态:空话题 + 非加载 + 可写。
注意composer 保持在组件树中的固定位置(不随分支 remount
避免空→非空切换时丢失未发送草稿。 */
const isHero = messages.length === 0 && !isLoading && !isReadOnly;
return (
<div className="relative flex h-full w-full flex-col">
{isHero && (
<div
className="pointer-events-none absolute inset-0"
style={{
background:
'radial-gradient(closest-side at 50% 42%, color-mix(in srgb, var(--accent-cyan) 9%, transparent), transparent)',
}}
/>
)}
{isHero ? (
<div className="relative flex flex-1 flex-col items-center justify-center px-6 pb-6">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-[var(--border-color)] bg-[var(--bg-tertiary)]">
<Zap className="h-6 w-6 text-[var(--accent-cyan)]" />
</div>
<h1 className="mt-4 text-[26px] font-semibold text-[var(--text-primary)]">PicoBot</h1>
<p className="mt-1 text-sm text-[var(--text-muted)]"></p>
</div>
) : (
<div className="relative flex-1 overflow-hidden">
<MessageList
messages={messages}
onNavigateToSubAgent={onNavigateToSubAgent}
showThinking={showThinking}
viewKey={viewKey}
highlightedMessageId={highlightedMessageId}
effectiveModel={effectiveModel}
hasMoreOlder={hasMoreOlder}
loadingOlder={loadingOlder}
onLoadOlder={onLoadOlder}
/>
</div>
)}
<div className="relative shrink-0 px-6 pb-4">
<div className={`mx-auto w-full ${isHero ? 'max-w-[680px]' : 'max-w-[736px]'}`}>
{selectors}
{input}
</div>
</div>
</div>
);
});