- useSubAgentView 镜像主视图:delta 累加到 ref,rAF 批量一次 setState,避免逐 token 重渲染 - 所有非 delta 消息处理前同步落盘 pending delta,保证顺序与内容完整性 - MessageList 消息 ID 映射 useMemo,稳定回调引用减少重渲染 - MessageBubble base64 下载改分块解码(32K),大附件峰值内存从数百 MB 降为单块级
140 lines
4.6 KiB
TypeScript
140 lines
4.6 KiB
TypeScript
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>
|
||
);
|
||
}
|