refactor(web): 前端单页收敛修正——导航式侧边栏 + 去输入模式 chip,mode 由后端自切

按设计/01 §3 + 主人要求:行为模式切换由后端/agent 自切(热切换),前端不再提供
手动切换的行为模式按钮列表。
- 侧边栏改为导航式(连接状态 / 设置 / 管理面板 / 主题),不再列行为模式按钮;
  App.tsx 还原 <Sidebar/> 布局。
- ChatInput 去掉行为模式 chip 切换条(模式不由用户手选)。
- useChat.send/chatStream 不再传 mode;消息 mode 由后端在回复时决定,
  MessageBubble 仍按 message.mode 渲染。
- ChatShell onVoice 为占位(语音处理在 agent)。
This commit is contained in:
Aska committed 2026-09-06 14:04:46 +08:00
1 parent 4aab0f501a
commit 77f75b0d99
6 files changed
+105 -129

No files matched your search

+3 -35
View File
@@ -1,25 +1,14 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "./primitives";
import { Icon, type IconName } from "./Icon";
import { useModeStore } from "../store/useModeStore";
import type { Mode } from "../lib/types";
import { Icon } from "./Icon";
export interface ChatInputProps {
onSend: (text: string, mode: Mode) => void;
onSend: (text: string) => void;
sending: boolean;
onVoice: () => void;
placeholder?: string;
}
const INPUT_MODES: ReadonlyArray<{ mode: Mode; label: string; icon: IconName }> = [
{ mode: "chat", label: "对话", icon: "chat" },
{ mode: "code", label: "代码", icon: "terminal" },
{ mode: "home", label: "家居", icon: "home" },
{ mode: "work", label: "工作", icon: "folder" },
{ mode: "docs", label: "知识库", icon: "docs" },
{ mode: "vision", label: "视觉", icon: "vision" },
];
export function ChatInput({
onSend,
sending,
@@ -28,8 +17,6 @@ export function ChatInput({
}: ChatInputProps): React.ReactNode {
const [value, setValue] = useState("");
const taRef = useRef<HTMLTextAreaElement>(null);
const mode = useModeStore((s) => s.mode);
const setMode = useModeStore((s) => s.setMode);
// auto-grow the textarea up to a max height
useEffect(() => {
@@ -42,31 +29,12 @@ export function ChatInput({
const submit = () => {
const t = value.trim();
if (!t || sending) return;
onSend(t, mode);
onSend(t);
setValue("");
};
return (
<div className="glass sticky bottom-0 border-t border-[var(--mu-glass-border)] p-3 md:p-4">
{/* input-mode switcher: sets the mode for the NEXT message (design/07 §1). */}
<div className="mx-auto mb-2 flex max-w-2xl items-center gap-1 overflow-x-auto pb-0.5">
<span className="mr-1 shrink-0 text-[10px] text-[var(--mu-text-lo)]">行为模式</span>
{INPUT_MODES.map(({ mode: m, label, icon }) => (
<button
key={m}
onClick={() => setMode(m)}
className={`inline-flex shrink-0 items-center gap-1 rounded-[var(--mu-radius-pill)] px-2.5 py-1 text-xs transition-all duration-200 ease-[var(--mu-spring)] active:scale-95 ${
mode === m
? "bg-[var(--mu-accent)]/15 text-[var(--mu-accent-hi)]"
: "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-5)]"
}`}
>
<Icon name={icon} size={13} />
{label}
</button>
))}
</div>
<div className="mx-auto flex max-w-2xl items-end gap-2 rounded-[var(--mu-radius-lg)] bg-[var(--mu-surface-2)] p-2 shadow-[var(--mu-shadow-1)]">
<textarea
ref={taRef}
+86 -73
View File
@@ -1,87 +1,100 @@
import { useModeStore } from "../store/useModeStore";
import { sendUp } from "../lib/ws";
import { MODES, type Mode } from "../lib/types";
import { useState } from "react";
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
import { getAuthToken } from "../lib/api";
import { useAppStore } from "../store/useAppStore";
import { Icon, type IconName } from "./Icon";
import { Tag } from "./primitives";
const NAV_MODES: ReadonlyArray<Mode> = [
"chat", "code", "home", "work", "docs", "voice", "vision", "agents",
];
const modeMeta: Record<Mode, { label: string; icon: IconName }> = {
chat: { label: "对话", icon: "chat" },
code: { label: "代码", icon: "terminal" },
home: { label: "家居", icon: "home" },
work: { label: "工作", icon: "folder" },
docs: { label: "知识库", icon: "docs" },
voice: { label: "语音", icon: "mic" },
vision: { label: "视觉", icon: "vision" },
agents: { label: "子代理", icon: "agent" },
settings: { label: "设置", icon: "settings" },
};
/** Sets the input mode — the mode of the NEXT message (design/07 §1). In the
* single-page design this no longer switches pages; it only shapes the next
* bubble and notifies the backend for a mode-aware reply. */
function useSwitchMode(): (m: Mode) => void {
const setMode = useModeStore((s) => s.setMode);
return (m: Mode) => {
setMode(m);
sendUp({ type: "mode_switch", data: { mode: m } });
};
}
/**
* Left rail — NOT a behavior-mode list (design/01 §3: mode switching is driven
* at runtime by the agent / user, and rendered per message). This is a small
* navigation rail: brand + connection status + theme + admin/settings entry.
*/
export function Sidebar(): React.ReactNode {
const mode = useModeStore((s) => s.mode);
const switchMode = useSwitchMode();
const wsStatus = useAppStore((s) => s.wsStatus);
const mood = useAppStore((s) => s.mood);
const [theme, setTheme] = useState<ThemeMode>(readTheme);
const connected = wsStatus === "connected";
const hasToken = !!getAuthToken();
const toggleTheme = () => {
const next: ThemeMode = theme === "dark" ? "light" : "dark";
applyTheme(next);
setTheme(next);
};
return (
<nav className="glass hidden h-full w-[var(--mu-sidebar-w)] shrink-0 flex-col gap-1 border-r border-[var(--mu-glass-border)] p-3 md:flex">
<div className="px-2 py-2 text-xs font-semibold uppercase tracking-wider text-[var(--mu-text-lo)]">
行为模式
<div className="flex items-center gap-2 px-2 py-2">
<img src="/icon.svg" alt="Cyrene" className="h-7 w-7" />
<div className="leading-tight">
<div className="text-sm font-semibold">Cyrene</div>
<div className="text-[11px] text-[var(--mu-text-lo)]">昔涟</div>
</div>
</div>
{/* connection status */}
<NavItem
active={connected}
icon="channel"
label={connected ? "实时连接" : "未连接"}
onClick={() => {}}
hint={wsStatus}
/>
{mood ? (
<div className="px-2 py-1 text-[11px] text-[var(--mu-text-lo)]">
心情 · {mood}
</div>
) : null}
<div className="mt-auto flex flex-col gap-1">
<NavItem
icon="settings"
label="设置"
onClick={() => { window.location.hash = "#/admin/system"; }}
/>
<NavItem
icon="shield"
label="管理面板"
onClick={() => { window.location.hash = "#/admin"; }}
/>
<NavItem
icon={theme === "dark" ? "sun" : "moon"}
label={theme === "dark" ? "浅色" : "深色"}
onClick={toggleTheme}
/>
{!hasToken ? <Tag tone="warn">未登录</Tag> : null}
</div>
{NAV_MODES.map((m) => {
const meta = modeMeta[m];
const active = m === mode;
return (
<button
key={m}
onClick={() => switchMode(m)}
className={`flex items-center gap-2.5 rounded-[var(--mu-radius)] px-3 py-2 text-sm transition-all duration-200 ease-[var(--mu-spring)] active:scale-[0.98] ${
active
? "bg-[var(--mu-accent)]/15 font-medium text-[var(--mu-accent-hi)]"
: "text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-4)]"
}`}
>
<Icon name={meta.icon} size={18} />
<span>{meta.label}</span>
</button>
);
})}
</nav>
);
}
export function BottomNav(): React.ReactNode {
const mode = useModeStore((s) => s.mode);
const switchMode = useSwitchMode();
function NavItem({
icon,
label,
onClick,
active = false,
hint,
}: {
icon: IconName;
label: string;
onClick: () => void;
active?: boolean;
hint?: string;
}): React.ReactNode {
return (
<nav className="glass fixed inset-x-0 bottom-0 z-30 flex h-[var(--mu-bottomnav-h)] items-stretch border-t border-[var(--mu-glass-border)] md:hidden">
{NAV_MODES.slice(0, 5).map((m) => {
const meta = modeMeta[m];
const active = m === mode;
return (
<button
key={m}
onClick={() => switchMode(m)}
className={`flex flex-1 flex-col items-center justify-center gap-0.5 text-[11px] transition-colors duration-200 ${
active ? "text-[var(--mu-accent-hi)]" : "text-[var(--mu-text-lo)]"
}`}
>
<Icon name={meta.icon} size={18} />
<span>{meta.label}</span>
</button>
);
})}
</nav>
<button
onClick={onClick}
className={`flex items-center gap-2.5 rounded-[var(--mu-radius)] px-3 py-2 text-sm transition-all duration-200 ease-[var(--mu-spring)] active:scale-[0.98] ${
active
? "bg-[var(--mu-accent)]/15 font-medium text-[var(--mu-accent-hi)]"
: "text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-4)]"
}`}
>
<Icon name={icon} size={18} />
<span>{label}</span>
{hint ? <span className="ml-auto text-[10px] text-[var(--mu-text-lo)]">{hint}</span> : null}
</button>
);
}