后端新增 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 自愈;流式输出中加载历史不清空流式累加器,避免已流出文本丢失。
154 lines
5.2 KiB
TypeScript
154 lines
5.2 KiB
TypeScript
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;
|
||
}
|
||
|
||
// memo:props 除 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>
|
||
);
|
||
});
|