import { useEffect, useMemo, useRef, useState } from "react"; import { useAppStore } from "../store/useAppStore"; import { useCurrentPersona } from "../store/personaSelectors"; import { useSendChat } from "../lib/useChat"; import { sendUp } from "../lib/ws"; import { HistoryList } from "../components/messages/HistoryList"; import { ChatInput } from "../components/ChatInput"; import { EmptyState } from "../components/primitives"; import { Icon } from "../components/Icon"; import { SessionStatusHint, ApprovalNoticeHint } from "../components/status"; import { requestModeSwitch, useHerActive } from "../components/modeSwitch"; import { personaChatBackground, type Orientation } from "../lib/persona"; /** * 唯一共享页面(design/07 §1):所有行为模式共用**一条消息流**,每条消息带自己的 * `mode`(由 agent 在运行时热切换,design/01 §3),气泡按该消息的 mode 渲染。 * * 输入模式(下一条消息的形态)的切换入口在输入框上方的 `ModeSelector` * (design/07 §1,裁决 16.4.1):**她活动时她自己切**;**她静默时用户可以切**; * 用户**提前切**只记为「已预约」,在她下次静默时生效——规则收口在 * `components/modeSwitch.ts`,本页只负责把用户发起的切换(语音按钮)也送进同一入口。 */ export function ChatShell(): React.ReactNode { const messages = useAppStore((s) => s.messages); // The chat wallpaper follows the *active session's* role (design/22 §4.6). const currentPersona = useCurrentPersona(); const { send, sending } = useSendChat(); const herActive = useHerActive(sending); const bottomRef = useRef(null); // Chat wallpaper follows the current orientation (landscape when wide, else // portrait) and re-evaluates on window resize. const [orientation, setOrientation] = useState(() => window.innerWidth > window.innerHeight ? "landscape" : "portrait" ); useEffect(() => { const onResize = () => setOrientation(window.innerWidth > window.innerHeight ? "landscape" : "portrait"); window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); }, []); // The persona's own wallpaper for the current orientation; undefined keeps the // plain Miuix surface (never another role's image). const chatBackground = personaChatBackground(currentPersona, orientation); // The latest assistant message is the one being streamed right now; only it // carries the live 唤醒路径 node sequence (WakePath), so the capacity state // reflects the current turn rather than a collapsed historical message. const lastAssistantId = useMemo(() => { for (let i = messages.length - 1; i >= 0; i--) { if (messages[i].role === "assistant") return messages[i].id; } return undefined; }, [messages]); // Scroll to the newest message only when a *new* message reaches the tail // (append/session-switch), not when the user hot-pages older history into the // list — otherwise "load earlier" would yank the viewport to the bottom. const lastMessageId = messages[messages.length - 1]?.id; useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" }); }, [lastMessageId]); return (
{chatBackground ? (
) : null}
{messages.length === 0 ? ( } title="开始与昔涟对话" hint="模式只改变消息框的呈现方式,会话上下文与记忆保持不变" /> ) : ( )}
void send(t, image)} sending={sending} onVoice={() => { // Voice is not wired to a live pipeline yet (real call deferred). Activate // the voice_input uplink and switch the shell to voice mode so the agent // (or the demo) reflects a voice session. 模式切换走统一入口:她活动时 // 只记为预约(裁决 16.4.1),不绕过输入区的切换规则。 requestModeSwitch("voice", herActive); sendUp({ type: "voice_input", data: { mode: "voice" } }); }} />
); }