269 lines
9.0 KiB
TypeScript
269 lines
9.0 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import { App as AntApp, Empty } from 'antd';
|
||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||
import type { ModelOverrides } from '../../api';
|
||
import { SessionAPI } from '../../api';
|
||
import AgentSidebar from './components/AgentSidebar';
|
||
import ChatHeader from './components/ChatHeader';
|
||
import ChatBody from './components/ChatBody';
|
||
import ChatInput from './components/ChatInput';
|
||
import ChatDrawers from './components/ChatDrawers';
|
||
import ChatOutline from './components/ChatOutline';
|
||
import { useChatScroll } from './hooks/useChatScroll';
|
||
import { useChatData } from './hooks/useChatData';
|
||
import { useChatSender } from './hooks/useChatSender';
|
||
import { markdownToPlainText } from './utils/copy';
|
||
|
||
const lastRoomKey = (agentId: string) => `chat:lastRoom:${agentId}`;
|
||
const isValidRoomId = (v: string | null): v is string => !!v && !String(v).startsWith('legacy_');
|
||
|
||
export default function ChatPage() {
|
||
const { id } = useParams();
|
||
const navigate = useNavigate();
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
const { message } = AntApp.useApp();
|
||
|
||
const [roomId, setRoomId] = useState<string | null>(null);
|
||
const [highlightId, setHighlightId] = useState<string | null>(null);
|
||
const [overrides, setOverrides] = useState<ModelOverrides>({});
|
||
|
||
const [historyDrawerOpen, setHistoryDrawerOpen] = useState(false);
|
||
const [mcpDrawerOpen, setMcpDrawerOpen] = useState(false);
|
||
const [paramsDrawerOpen, setParamsDrawerOpen] = useState(false);
|
||
const [tplDrawerOpen, setTplDrawerOpen] = useState(false);
|
||
|
||
const abortRef = useRef<AbortController | null>(null);
|
||
const { bodyRef, scrollBottom, initialScrollDoneRef } = useChatScroll();
|
||
|
||
useEffect(() => {
|
||
if (!id) return;
|
||
abortRef.current?.abort();
|
||
setRoomId(null);
|
||
setHighlightId(null);
|
||
|
||
const s = searchParams.get('session');
|
||
const m = searchParams.get('msg');
|
||
|
||
if (isValidRoomId(s)) {
|
||
setRoomId(s);
|
||
try {
|
||
localStorage.setItem(lastRoomKey(id), s);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
if (m) {
|
||
setHighlightId(m);
|
||
const next = new URLSearchParams(searchParams);
|
||
next.delete('msg');
|
||
setSearchParams(next, { replace: true });
|
||
}
|
||
return;
|
||
}
|
||
if (s && !isValidRoomId(s)) {
|
||
const next = new URLSearchParams(searchParams);
|
||
next.delete('session');
|
||
next.delete('msg');
|
||
setSearchParams(next, { replace: true });
|
||
}
|
||
|
||
const saved = (() => {
|
||
try {
|
||
return localStorage.getItem(lastRoomKey(id));
|
||
} catch {
|
||
return null;
|
||
}
|
||
})();
|
||
if (isValidRoomId(saved)) {
|
||
setRoomId(saved);
|
||
return;
|
||
}
|
||
if (saved && !isValidRoomId(saved)) {
|
||
try {
|
||
localStorage.removeItem(lastRoomKey(id));
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
(async () => {
|
||
try {
|
||
const list = await SessionAPI.list(id, '0');
|
||
if (list.length > 0) {
|
||
setRoomId(list[0].id);
|
||
return;
|
||
}
|
||
const created = await SessionAPI.create(id);
|
||
setRoomId(created.id);
|
||
} catch (e: any) {
|
||
message.error('加载房间失败:' + (e?.message ?? e));
|
||
}
|
||
})();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [id]);
|
||
|
||
useEffect(() => {
|
||
if (!id || !roomId) return;
|
||
try {
|
||
localStorage.setItem(lastRoomKey(id), roomId);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
const cur = searchParams.get('session');
|
||
if (cur === roomId) return;
|
||
const next = new URLSearchParams(searchParams);
|
||
next.set('session', roomId);
|
||
next.delete('msg');
|
||
setSearchParams(next, { replace: true });
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [id, roomId]);
|
||
|
||
const { agent, agentList, messages, setMessages, branches, setBranches, loadMessages } = useChatData({
|
||
agentId: id,
|
||
roomId,
|
||
highlightId,
|
||
setHighlightId,
|
||
scrollBottom,
|
||
initialScrollDoneRef,
|
||
setOverrides: (updater) => setOverrides(updater),
|
||
abort: () => abortRef.current?.abort()
|
||
});
|
||
|
||
const sender = useChatSender({
|
||
agentId: id,
|
||
agent,
|
||
agentList,
|
||
roomId,
|
||
overrides,
|
||
setOverrides: (updater) => setOverrides(updater),
|
||
messages,
|
||
setMessages,
|
||
setBranches,
|
||
loadMessages,
|
||
scrollBottom,
|
||
notify: { success: (t) => message.success(t), error: (t) => message.error(t) },
|
||
abortRef
|
||
});
|
||
|
||
return (
|
||
<div className="chat-shell">
|
||
<AgentSidebar
|
||
agentList={agentList}
|
||
activeAgentId={id}
|
||
onCreate={() => navigate('/agents/new')}
|
||
onSelect={(aid) => navigate(`/chat/${aid}`)}
|
||
/>
|
||
|
||
<section className="chat-main">
|
||
{!agent ? (
|
||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<Empty description="请在左侧选择一个智能体开始对话" />
|
||
</div>
|
||
) : (
|
||
<>
|
||
<ChatHeader
|
||
agent={agent}
|
||
useStream={sender.useStream}
|
||
setUseStream={sender.setUseStream}
|
||
onOpenHistory={() => setHistoryDrawerOpen(true)}
|
||
onOpenParams={() => setParamsDrawerOpen(true)}
|
||
onOpenMcp={() => setMcpDrawerOpen(true)}
|
||
onManageAgent={() => navigate(`/agents/${id}`)}
|
||
onClear={sender.handleClear}
|
||
/>
|
||
|
||
<div className="chat-content-row">
|
||
<ChatBody
|
||
bodyRef={bodyRef}
|
||
agent={agent}
|
||
agentList={agentList}
|
||
currentAgentId={id!}
|
||
messages={messages}
|
||
branches={branches}
|
||
highlightId={highlightId}
|
||
sending={sender.sending}
|
||
streaming={sender.streaming}
|
||
onRegenerate={sender.handleRegenerate}
|
||
onSwitchBranch={sender.handleSwitchBranch}
|
||
onCopy={(text, mode) => {
|
||
const content = mode === 'markdown' ? text : markdownToPlainText(text);
|
||
return navigator.clipboard
|
||
?.writeText(content)
|
||
.then(() => message.success(mode === 'markdown' ? '已复制(Markdown)' : '已复制(纯文本)'));
|
||
}}
|
||
/>
|
||
|
||
<ChatOutline
|
||
messages={messages}
|
||
activeId={highlightId}
|
||
onJump={(msgId) => {
|
||
setHighlightId(msgId);
|
||
const el = document.getElementById('msg-' + msgId);
|
||
if (el) el.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<ChatInput
|
||
input={sender.input}
|
||
setInput={sender.setInput}
|
||
sending={sender.sending}
|
||
attachments={sender.attachments}
|
||
setAttachments={(updater) => sender.setAttachments(updater)}
|
||
imageUrls={sender.imageUrls}
|
||
setImageUrls={(updater) => sender.setImageUrls(updater)}
|
||
onSend={sender.handleSend}
|
||
onStop={sender.handleStop}
|
||
onAttach={sender.handleAttach}
|
||
onOpenTpl={() => setTplDrawerOpen(true)}
|
||
modelOptions={sender.modelOptions}
|
||
activeModelValue={sender.activeModelValue}
|
||
onChangeModel={(modelId) => {
|
||
const selected = sender.modelOptions.find((m) => m.value === modelId);
|
||
setOverrides((o) => ({ ...o, model_id: String(modelId), model: selected?.label || String(modelId) }));
|
||
}}
|
||
onOpenHistory={() => setHistoryDrawerOpen(true)}
|
||
onNewSession={async () => {
|
||
if (!id) return;
|
||
abortRef.current?.abort();
|
||
try {
|
||
const created = await SessionAPI.create(id);
|
||
setHighlightId(null);
|
||
setMessages(() => []);
|
||
setBranches({});
|
||
setRoomId(created.id);
|
||
} catch (e: any) {
|
||
message.error('创建房间失败:' + (e?.message ?? e));
|
||
}
|
||
}}
|
||
agentList={agentList}
|
||
onInsertMention={() => {}}
|
||
/>
|
||
</>
|
||
)}
|
||
</section>
|
||
|
||
<ChatDrawers
|
||
agentId={id}
|
||
agent={agent}
|
||
input={sender.input}
|
||
setInput={(updater) => sender.setInput(updater(sender.input))}
|
||
overrides={overrides}
|
||
setOverrides={(updater) => setOverrides(updater)}
|
||
roomId={roomId}
|
||
setRoomId={setRoomId}
|
||
setHighlightId={setHighlightId}
|
||
sessionRefresh={sender.sessionRefresh}
|
||
mcpDrawerOpen={mcpDrawerOpen}
|
||
setMcpDrawerOpen={setMcpDrawerOpen}
|
||
tplDrawerOpen={tplDrawerOpen}
|
||
setTplDrawerOpen={setTplDrawerOpen}
|
||
paramsDrawerOpen={paramsDrawerOpen}
|
||
setParamsDrawerOpen={setParamsDrawerOpen}
|
||
historyDrawerOpen={historyDrawerOpen}
|
||
setHistoryDrawerOpen={setHistoryDrawerOpen}
|
||
notify={{ success: (t) => message.success(t) }}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|