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

@@ -1,6 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "./primitives";
import { Icon } from "./Icon";
import { ModeSelector } from "./ModeSelector";
import { useHerActive } from "./modeSwitch";
export interface ChatInputProps {
onSend: (text: string, image?: string) => void;
@@ -19,6 +21,9 @@ export function ChatInput({
const [pendingImage, setPendingImage] = useState<string | undefined>(undefined);
const taRef = useRef<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
// 她是否在活动(裁决 16.4.1 的「静默 / 活动」判据)——决定输入区上方的模式
// 切换是立即生效还是记为预约。
const herActive = useHerActive(sending);
// auto-grow the textarea up to a max height
useEffect(() => {
@@ -51,6 +56,7 @@ export function ChatInput({
return (
<div className="glass sticky bottom-0 border-t border-[var(--mu-glass-border)] p-3 md:p-4">
<div className="mx-auto flex max-w-2xl flex-col gap-2">
<ModeSelector active={herActive} />
{pendingImage ? (
<div className="relative inline-flex w-fit overflow-hidden rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)]">
<img
@@ -0,0 +1,216 @@
import { useEffect, useRef, useState } from "react";
import { Icon, type IconName } from "./Icon";
import { Tag } from "./primitives";
import { useModeStore } from "../store/useModeStore";
import { MODES, type Mode } from "../lib/types";
import {
cancelReservedMode,
requestModeSwitch,
settleReservedMode,
useModeSwitchStore,
type ModeOrigin,
} from "./modeSwitch";
/**
* 输入框上方的「当前输入 mode」指示 + 下拉切换(design/07 §1 规格,裁决 16.4.1)。
* 九模式取自权威枚举 `MODES`(design/07 §6),切换规则全部收口在 `modeSwitch.ts`:
* 她在活动 → 记为「已预约、她静默后生效」;她静默 → 立即生效。**不制造假状态**:
* 未生效的切换只以预约徽章呈现,芯片始终显示真正生效的那个模式。
*/
const MODE_LABEL: Record<Mode, string> = {
chat: "对话",
code: "代码",
home: "家居",
work: "工作",
docs: "知识库",
voice: "语音",
vision: "视觉",
agents: "子代理",
settings: "设置",
};
const MODE_ICON: Record<Mode, IconName> = {
chat: "chat",
code: "terminal",
home: "home",
work: "folder",
docs: "docs",
voice: "mic",
vision: "vision",
agents: "agent",
settings: "settings",
};
/** 当前输入模式的芯片:点击展开九模式列表(列表向上弹出,见 ModeMenu)。 */
function ModeChip({
mode,
active,
open,
onToggle,
}: {
mode: Mode;
active: boolean;
open: boolean;
onToggle: () => void;
}): React.ReactNode {
return (
<span className="flex items-center gap-2">
<span className="text-xs text-[var(--mu-text-lo)]">输入模式</span>
<button
onClick={onToggle}
aria-haspopup="listbox"
aria-expanded={open}
title={active ? "她正在活动,切换会记为预约" : "切换下一条消息的形态"}
className="inline-flex items-center gap-1.5 rounded-[var(--mu-radius)] bg-[var(--mu-surface-4)] px-2.5 py-1 text-xs font-medium text-[var(--mu-text-hi)] transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-5)] active:scale-95"
>
<Icon name={MODE_ICON[mode]} size={14} />
{MODE_LABEL[mode]}
<Icon
name="arrow"
size={12}
className={`transition-transform duration-200 ${open ? "rotate-90" : ""}`}
/>
</button>
</span>
);
}
/** 展开的模式列表(向上弹出,因为输入栏在屏幕底部)。 */
function ModeMenu({
mode,
reserved,
onPick,
}: {
mode: Mode;
reserved: Mode | null;
onPick: (m: Mode) => void;
}): React.ReactNode {
return (
<div
role="listbox"
aria-label="选择输入模式"
className="mu-rise-in absolute bottom-full left-0 z-40 mb-2 w-52 overflow-hidden rounded-[var(--mu-radius-lg)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] p-1 shadow-[var(--mu-shadow-2)]"
>
{MODES.map((m) => {
const current = m === mode;
return (
<button
key={m}
role="option"
aria-selected={current}
onClick={() => onPick(m)}
className={`flex w-full items-center gap-2 rounded-[var(--mu-radius)] px-2 py-1.5 text-left text-sm transition-colors duration-150 ${
current
? "bg-[var(--mu-accent)]/15 text-[var(--mu-accent-hi)]"
: "text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-4)]"
}`}
>
<Icon name={MODE_ICON[m]} size={15} />
<span className="flex-1">{MODE_LABEL[m]}</span>
{reserved === m ? <Tag tone="warn">已预约</Tag> : null}
{current ? <Icon name="check" size={14} /> : null}
</button>
);
})}
</div>
);
}
/** 切换状态的可见提示:谁定的当前模式 / 预约徽章(带撤销)/ 活动态说明 / 落地提示。 */
function SwitchHints({
origin,
reserved,
active,
settled,
}: {
origin: ModeOrigin | null;
reserved: Mode | null;
active: boolean;
settled: Mode | null;
}): React.ReactNode {
return (
<>
{origin === "agent" ? (
<span className="text-[11px] text-[var(--mu-text-lo)]">她自己切的</span>
) : null}
{reserved !== null ? (
<Tag tone="warn" className="mu-rise-in">
<Icon name="clock" size={11} />
已预约 · {MODE_LABEL[reserved]}(她静默后生效)
<button
onClick={cancelReservedMode}
aria-label="撤销预约"
title="撤销预约"
className="ml-0.5 inline-flex h-4 w-4 items-center justify-center rounded-full transition-transform duration-200 hover:scale-110"
>
<Icon name="close" size={11} />
</button>
</Tag>
) : active ? (
<span className="text-[11px] text-[var(--mu-text-lo)]">她正在活动,切换会记为预约</span>
) : null}
{settled !== null ? (
<Tag tone="ok" className="mu-rise-in">
<Icon name="check" size={11} />
已切换到 {MODE_LABEL[settled]}
</Tag>
) : null}
</>
);
}
export function ModeSelector({ active }: { active: boolean }): React.ReactNode {
const mode = useModeStore((s) => s.mode);
const reserved = useModeSwitchStore((s) => s.reserved);
const origin = useModeSwitchStore((s) => s.origin);
const [open, setOpen] = useState(false);
/** 预约刚落地的短暂提示,避免「什么时候变过来的」无声无息。 */
const [settled, setSettled] = useState<Mode | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
// 她由活动转静默 → 预约落地(裁决 16.4.1 第③种情形)。判据在纯函数里,静默且
// 有预约才落地;没有预约时这里是空操作。
useEffect(() => {
const applied = settleReservedMode(active);
if (applied !== null) setSettled(applied);
}, [active]);
useEffect(() => {
if (settled === null) return;
const t = window.setTimeout(() => setSettled(null), 2600);
return () => window.clearTimeout(t);
}, [settled]);
// 点击外部 / Esc 收起下拉(不改变任何模式状态)。
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const pick = (target: Mode) => {
setOpen(false);
requestModeSwitch(target, active);
};
return (
<div ref={rootRef} className="relative flex flex-wrap items-center gap-2">
<ModeChip mode={mode} active={active} open={open} onToggle={() => setOpen((v) => !v)} />
<SwitchHints origin={origin} reserved={reserved} active={active} settled={settled} />
{open ? <ModeMenu mode={mode} reserved={reserved} onPick={pick} /> : null}
</div>
);
}
@@ -0,0 +1,120 @@
// 静默/活动态模式切换的逻辑测试(裁决 16.4.1,design/07 §1)。
//
// 项目没有前端测试框架/jsdom(组件与 `useHerActive` 钩子需要 DOM,故不在此覆盖),
// 所以这是一个零依赖的纯 node 检查:它驱动真实的规则模块
// (components/modeSwitch.ts)与真实的 store,断言 `ModeSelector` 所读的那几个
// 字段,并在第一个失败处抛出。
//
// 跑法(用 vite 自带的 esbuild,不新增依赖、不改 package.json;banner 补上
// store 的 persist 中间件在 node 下探测的 window.localStorage):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/components/modeSwitch.test.ts \
// --bundle --platform=node --format=esm \
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
// --outfile=/tmp/cyrene-modeswitch.test.mjs \
// && node /tmp/cyrene-modeswitch.test.mjs
//
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它
// 只从项目内导入、不 import node:*。
import { useModeStore } from "../store/useModeStore";
import type { Mode } from "../lib/types";
import {
cancelReservedMode,
planPromotion,
planUserModeSwitch,
requestModeSwitch,
settleReservedMode,
useModeSwitchStore,
} from "./modeSwitch";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function same<T>(got: T, want: T, what: string): void {
ok(
JSON.stringify(got) === JSON.stringify(want),
`${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`
);
}
/** 每个场景前把两个 store 复位(她静默、当前模式 chat、无预约、无来源)。 */
function reset(): void {
useModeStore.getState().setMode("chat");
useModeSwitchStore.setState({ reserved: null, origin: null, userPicked: null });
}
// ---- 纯函数:三种情形的判定 -------------------------------------------------
// ① 静默态 + 用户切 → 立即生效(不产生预约)。
same(planUserModeSwitch("code", "chat", false), { apply: "code", reserve: null }, "静默态用户切换立即生效");
// ② 活动态 + 用户切 → 只登记预约,当前模式不动。
same(planUserModeSwitch("code", "chat", true), { apply: null, reserve: "code" }, "活动态用户切换记为预约");
// 用户选中的就是当前模式 = 撤销既有预约(回到她现在的形态)。
same(planUserModeSwitch("chat", "chat", true), { apply: null, reserve: null }, "选当前模式应撤销预约");
// ③ 预约落地:只有「静默 + 有预约」才落地。
same(planPromotion("code", false), "code", "静默时预约落地");
same(planPromotion("code", true), null, "活动态不落地预约");
same(planPromotion(null, false), null, "无预约时落地是空操作");
// ---- store 级:真实入口的副作用 --------------------------------------------
// 活动态点击:mode 不变、reserved 记上(组件据此渲染「已预约」徽章)。
reset();
same(requestModeSwitch("code", true), false, "活动态切换不立即生效");
same(useModeStore.getState().mode, "chat", "活动态切换不得偷偷改当前模式");
same(useModeSwitchStore.getState().reserved, "code", "活动态切换写入预约(可见状态)");
// 她静默下来:预约落地 + 清空,模式真的变过去。
same(settleReservedMode(false), "code", "静默时落地返回新生效模式");
same(useModeStore.getState().mode, "code", "落地后当前模式 = 预约值");
same(useModeSwitchStore.getState().reserved, null, "落地后预约清空(徽章消失)");
same(useModeSwitchStore.getState().origin, "user", "落地算用户的切换");
// 她还在活动:预约不落地(不能提前生效)。
reset();
requestModeSwitch("voice", true);
same(settleReservedMode(true), null, "活动态不得落地预约");
same(useModeSwitchStore.getState().reserved, "voice", "活动态下预约保留");
same(useModeStore.getState().mode, "chat", "活动态下模式不变");
// 静默态点击:立即生效,且不留下预约。
reset();
same(requestModeSwitch("docs", false), true, "静默态切换立即生效");
same(useModeStore.getState().mode, "docs", "静默态切换真的改了模式");
same(useModeSwitchStore.getState().reserved, null, "静默态切换不产生预约");
// 撤销预约(预约徽章上的叉)。
reset();
requestModeSwitch("agents", true);
cancelReservedMode();
same(useModeSwitchStore.getState().reserved, null, "撤销后无预约");
same(useModeStore.getState().mode, "chat", "撤销预约不改当前模式");
// ---- 她自己切(活动态):来源标注 + 不打架 --------------------------------
reset();
requestModeSwitch("code", true); // 用户提前预约
useModeStore.getState().setMode("vision"); // 下行 mode_switch 帧:她自己切的
same(useModeStore.getState().mode, "vision", "她自切直接改当前模式(她活动、她说了算)");
same(useModeSwitchStore.getState().origin, "agent", "她自切被标为 agent 来源");
same(useModeSwitchStore.getState().reserved, "code", "她自切不得清掉用户的预约");
same(settleReservedMode(false), "code", "她静默后预约仍按约落地");
same(useModeStore.getState().mode, "code", "落地值 = 用户预约值,不是她切的值");
// 用户自己的切换被标为 user(回显同值不得误标成她切的)。
reset();
requestModeSwitch("work", false);
same(useModeSwitchStore.getState().origin, "user", "用户切换标记为 user");
useModeStore.getState().setMode("work"); // 上行 mode_switch 的回显
same(useModeSwitchStore.getState().origin, "user", "同值回显不得误标为 agent");
console.log(`mode switch: ${checks} checks passed`);
+129
View File
@@ -0,0 +1,129 @@
import { create } from "zustand";
import { useAppStore } from "../store/useAppStore";
import { useModeStore } from "../store/useModeStore";
import { sendUp } from "../lib/ws";
import type { Mode } from "../lib/types";
/**
* 静默 / 活动态下的输入模式切换(裁决 16.4.1,design/07 §1)。
*
* 主人原话口径:**活动态 → 角色自己可切**;**静默态 → 用户可以切**;用户也可以
* **提前切**、但**在下次静默态生效**(必须有可见的「已预约」状态,不能让用户
* 以为立刻生效)。三种情形在本模块收口为唯一入口:
*
* 1. `requestModeSwitch(target, active)` —— 用户发起(输入框下拉 / 语音按钮):
* 活动态只登记预约,静默态立即生效并上行 `mode_switch`;
* 2. `settleReservedMode(active)` —— 她由活动转静默时把预约落地(组件在 `active`
* 变化时调用);
* 3. 她自切(下行 `mode_switch` 帧,design/01 §3)直接改 `useModeStore.mode`,
* 本模块只标注来源(「她自己切的」),**不清除**用户的预约 —— 两层互不打架。
*
* 「活动 / 静默」的判据 = 她是否在活动:design/15 §5 的会话编排 `sessionBusy`
* (`session_busy` / `session_pending` / `stream_end`)+本地流式标志,由
* `useHerActive` 统一给出。模块**不自己造后端状态**:后端 capacity / `session_busy`
* 的暴露面不足另列回报。
*/
/** 当前输入模式的来源:`user` = 用户切的;`agent` = 她自己切的。 */
export type ModeOrigin = "user" | "agent";
/** 一次用户切换的落地方案(纯函数产物,便于反证测试与变异验证)。 */
export interface ModeSwitchPlan {
/** 立即生效的模式;null = 本次不立刻改(活动态下改为预约)。 */
apply: Mode | null;
/** 预约到下次静默态生效的模式;null = 清掉既有预约。 */
reserve: Mode | null;
}
/**
* 用户选中某个模式时的处置(纯函数)。
* - 选中的就是当前模式:无操作并**撤销**既有预约(用户想回到她现在的形态);
* - 她活动:只登记预约(`reserve`),**不立刻改**当前模式;
* - 她静默:立即生效(`apply`),预约清空。
*/
export function planUserModeSwitch(target: Mode, current: Mode, active: boolean): ModeSwitchPlan {
if (target === current) return { apply: null, reserve: null };
if (active) return { apply: null, reserve: target };
return { apply: target, reserve: null };
}
/** 她由活动转静默时,预约是否落地(纯函数):静默且有预约才落地,否则 null。 */
export function planPromotion(reserved: Mode | null, active: boolean): Mode | null {
if (active || reserved === null) return null;
return reserved;
}
/** 模式切换的可见状态:预约(待生效)+ 当前模式的来源。 */
export interface ModeSwitchState {
/** 用户已预约、将在她静默后生效的模式;null = 无预约。 */
reserved: Mode | null;
/** 当前模式是谁定的(用于显示「她自己切的」);null = 尚无切换记录。 */
origin: ModeOrigin | null;
/** 最近一次由用户确认生效的模式(区分她自切与用户自选的锚点)。 */
userPicked: Mode | null;
}
export const useModeSwitchStore = create<ModeSwitchState>()(() => ({
reserved: null,
origin: null,
userPicked: null,
}));
/**
* 她自切会直接改 `useModeStore.mode`(下行 `mode_switch` 帧的处理在
* `lib/ws/handlers/session.ts`,本组不改那份文件)——这里订阅同一次变更,
* 把「谁定的」标出来:不是用户刚选的那个值,就是她切的。
* (用户自己的上行 `mode_switch` 回显与生效值相同,不会误标。)
*/
useModeStore.subscribe((state, prev) => {
if (state.mode === prev.mode) return;
useModeSwitchStore.setState((s) => ({
origin: state.mode === s.userPicked ? "user" : "agent",
}));
});
/** 她是否在活动(一个回合在飞):会话编排忙态 / 最后一条消息仍在流式 / 本地发送中。 */
export function useHerActive(sending: boolean): boolean {
const sessionBusy = useAppStore((s) => s.sessionBusy);
const streaming = useAppStore((s) => s.messages[s.messages.length - 1]?.streaming === true);
return sessionBusy || streaming || sending;
}
/** 把一个模式落为当前生效值,并标注来源(先写来源再改值,订阅据此判定)。 */
function applyMode(mode: Mode, origin: ModeOrigin): void {
useModeSwitchStore.setState({ origin, userPicked: origin === "user" ? mode : null });
useModeStore.getState().setMode(mode);
}
/** 上行告知后端 / agent 当前生效的输入模式(design/07 §3 上行 `mode_switch`)。 */
function announceMode(mode: Mode): void {
sendUp({ type: "mode_switch", data: { mode } });
}
/**
* 用户发起切换的唯一入口。**返回是否立即生效**(false = 已记为预约)。
* 活动态下不偷偷改模式,也不上行 —— 预约只存在于前端,不进后端契约。
*/
export function requestModeSwitch(target: Mode, active: boolean): boolean {
const plan = planUserModeSwitch(target, useModeStore.getState().mode, active);
useModeSwitchStore.setState({ reserved: plan.reserve });
if (plan.apply === null) return false;
applyMode(plan.apply, "user");
announceMode(plan.apply);
return true;
}
/** 她静默下来时把预约落地;返回新生效的模式(null = 没有预约可落地)。 */
export function settleReservedMode(active: boolean): Mode | null {
const promoted = planPromotion(useModeSwitchStore.getState().reserved, active);
if (promoted === null) return null;
useModeSwitchStore.setState({ reserved: null });
applyMode(promoted, "user");
announceMode(promoted);
return promoted;
}
/** 用户撤销预约(预约徽章上的叉)。 */
export function cancelReservedMode(): void {
useModeSwitchStore.setState({ reserved: null });
}
+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" } });
}}
/>