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:
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`);
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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" } });
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user