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 守卫)
This commit is contained in:
AskaEth committed 2026-10-04 13:41:00 +08:00
1 parent 093f38d986
commit f454273c5a
5 files changed
+483 -7

No files matched your search

+12 -7
View File
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useAppStore } from "../store/useAppStore";
import { useCurrentPersona } from "../store/personaSelectors";
import { useModeStore } from "../store/useModeStore";
import { useSendChat } from "../lib/useChat";
import { sendUp } from "../lib/ws";
import { HistoryList } from "../components/messages/HistoryList";
@@ -9,19 +8,24 @@ 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";
/**
* The single shared page (design/07 §1). All behavior modes live in ONE message
* stream; each message carries its own `mode` (assigned by the agent at runtime,
* design/01 §3 hot-switch), so its bubble renders per that mode. There is no
* manual mode selector — the agent / backend drives the mode.
* 唯一共享页面(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
@@ -96,8 +100,9 @@ export function ChatShell(): React.ReactNode {
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.
useModeStore.getState().setMode("voice");
// (or the demo) reflects a voice session. 模式切换走统一入口:她活动时
// 只记为预约(裁决 16.4.1),不绕过输入区的切换规则。
requestModeSwitch("voice", herActive);
sendUp({ type: "voice_input", data: { mode: "voice" } });
}}
/>