PicoBot/web/src/components/Chat/ChatContainer.tsx
oudecheng 5e2bdaf757 perf(web): 子代理 stream_delta rAF 批处理 + 消息槽 memo 化 + base64 分块解码
- useSubAgentView 镜像主视图:delta 累加到 ref,rAF 批量一次 setState,避免逐 token 重渲染
- 所有非 delta 消息处理前同步落盘 pending delta,保证顺序与内容完整性
- MessageList 消息 ID 映射 useMemo,稳定回调引用减少重渲染
- MessageBubble base64 下载改分块解码(32K),大附件峰值内存从数百 MB 降为单块级
2026-08-17 09:55:11 +08:00

140 lines
4.6 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 } 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;
}
export function ChatContainer({
messages,
isLoading,
isReadOnly = false,
channelName,
onSendMessage,
onNavigateToSubAgent,
onStop,
showThinking = true,
viewKey,
highlightedMessageId,
sessionId,
onOpenSettings,
settingsClosedTick,
topicId,
}: 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}
/>
</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>
);
}