- 唯一逻辑入口 components/modeSwitch.ts(纯函数判定 + 预约态 + 落地/撤销)+ ModeSelector 组件 - 活动态:她自己切(下行 mode_switch)直接生效,用户此时切换只登记**预约**、不偷偷改、不上行, 且不清除预约(两层不打架) - 静默态:用户可即时切 + 上行 mode_switch - **用户提前切 → 下次静默态生效**:`active` 由 true→false 时落地;徽章写「已预约 · X(她静默后生效)」, 不会让人误以为立刻生效 - 「活动/静默」判据=她是否在忙(sessionBusy || 屏上 streaming || sending);若口径要改成 capacity 三态, 只需改 modeSwitch.ts 的一个谓词(已登记) - 复核:tsc 0 错 + vite build 成功 + node 逻辑测试 28 项;2 条变异反证(去掉活动态分叉 / 去掉 active 守卫)
112 lines
5.0 KiB
TypeScript
112 lines
5.0 KiB
TypeScript
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<HTMLDivElement>(null);
|
||
|
||
// Chat wallpaper follows the current orientation (landscape when wide, else
|
||
// portrait) and re-evaluates on window resize.
|
||
const [orientation, setOrientation] = useState<Orientation>(() =>
|
||
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 (
|
||
<div className="flex h-full flex-col">
|
||
<div className="relative min-h-0 flex-1">
|
||
{chatBackground ? (
|
||
<div className="absolute inset-0 overflow-hidden" aria-hidden>
|
||
<img
|
||
src={chatBackground}
|
||
alt=""
|
||
draggable={false}
|
||
className="h-full w-full object-cover"
|
||
/>
|
||
<div className="absolute inset-0 bg-[var(--mu-surface-2)]/40" />
|
||
</div>
|
||
) : null}
|
||
<div className="relative h-full overflow-y-auto">
|
||
<div className="mx-auto flex max-w-2xl flex-col gap-4 p-4">
|
||
<SessionStatusHint />
|
||
<ApprovalNoticeHint />
|
||
{messages.length === 0 ? (
|
||
<EmptyState
|
||
icon={<Icon name="chat" size={24} />}
|
||
title="开始与昔涟对话"
|
||
hint="模式只改变消息框的呈现方式,会话上下文与记忆保持不变"
|
||
/>
|
||
) : (
|
||
<HistoryList lastAssistantId={lastAssistantId} />
|
||
)}
|
||
<div ref={bottomRef} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<ChatInput
|
||
onSend={(t, image) => 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" } });
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|