Files
Cyrene_v2/frontend/web/src/modes/ChatShell.tsx
T
AskaEth f454273c5a feat(web): 静默/活动态切换 + 预约生效(裁决 16.4.1)
- 唯一逻辑入口 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 守卫)
2026-10-04 13:41:00 +08:00

112 lines
5.0 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 { 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>
);
}