diff --git a/frontend/web/src/components/ChatInput.tsx b/frontend/web/src/components/ChatInput.tsx index 95250e8..fa85f39 100644 --- a/frontend/web/src/components/ChatInput.tsx +++ b/frontend/web/src/components/ChatInput.tsx @@ -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(undefined); const taRef = useRef(null); const fileRef = useRef(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 (
+ {pendingImage ? (
= { + chat: "对话", + code: "代码", + home: "家居", + work: "工作", + docs: "知识库", + voice: "语音", + vision: "视觉", + agents: "子代理", + settings: "设置", +}; + +const MODE_ICON: Record = { + 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 ( + + 输入模式 + + + ); +} + +/** 展开的模式列表(向上弹出,因为输入栏在屏幕底部)。 */ +function ModeMenu({ + mode, + reserved, + onPick, +}: { + mode: Mode; + reserved: Mode | null; + onPick: (m: Mode) => void; +}): React.ReactNode { + return ( +
+ {MODES.map((m) => { + const current = m === mode; + return ( + + ); + })} +
+ ); +} + +/** 切换状态的可见提示:谁定的当前模式 / 预约徽章(带撤销)/ 活动态说明 / 落地提示。 */ +function SwitchHints({ + origin, + reserved, + active, + settled, +}: { + origin: ModeOrigin | null; + reserved: Mode | null; + active: boolean; + settled: Mode | null; +}): React.ReactNode { + return ( + <> + {origin === "agent" ? ( + 她自己切的 + ) : null} + + {reserved !== null ? ( + + + 已预约 · {MODE_LABEL[reserved]}(她静默后生效) + + + ) : active ? ( + 她正在活动,切换会记为预约 + ) : null} + + {settled !== null ? ( + + + 已切换到 {MODE_LABEL[settled]} + + ) : 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(null); + const rootRef = useRef(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 ( +
+ setOpen((v) => !v)} /> + + {open ? : null} +
+ ); +} diff --git a/frontend/web/src/components/modeSwitch.test.ts b/frontend/web/src/components/modeSwitch.test.ts new file mode 100644 index 0000000..153fa85 --- /dev/null +++ b/frontend/web/src/components/modeSwitch.test.ts @@ -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(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`); diff --git a/frontend/web/src/components/modeSwitch.ts b/frontend/web/src/components/modeSwitch.ts new file mode 100644 index 0000000..9e7acc7 --- /dev/null +++ b/frontend/web/src/components/modeSwitch.ts @@ -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()(() => ({ + 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 }); +} diff --git a/frontend/web/src/modes/ChatShell.tsx b/frontend/web/src/modes/ChatShell.tsx index f0048cf..b6c1df7 100644 --- a/frontend/web/src/modes/ChatShell.tsx +++ b/frontend/web/src/modes/ChatShell.tsx @@ -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(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" } }); }} />