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:
1 parent
4aab0f501a
commit
77f75b0d99
6 files changed
+105
-129
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { TopBar } from "./components/TopBar";
|
import { TopBar } from "./components/TopBar";
|
||||||
import { Sidebar, BottomNav } from "./components/Nav";
|
import { Sidebar } from "./components/Nav";
|
||||||
import { ModeRouter } from "./modes/ModeRouter";
|
import { ModeRouter } from "./modes/ModeRouter";
|
||||||
import { AdminApp } from "./admin/AdminApp";
|
import { AdminApp } from "./admin/AdminApp";
|
||||||
import { parseAdminHash } from "./admin/router";
|
import { parseAdminHash } from "./admin/router";
|
||||||
@@ -34,11 +34,10 @@ export default function App(): React.ReactNode {
|
|||||||
<TopBar />
|
<TopBar />
|
||||||
<div className="flex min-h-0 flex-1">
|
<div className="flex min-h-0 flex-1">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main className="relative flex-1 overflow-y-auto pb-[var(--mu-bottomnav-h)] md:pb-0">
|
<main className="relative min-h-0 flex-1 overflow-y-auto">
|
||||||
<ModeRouter />
|
<ModeRouter />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<BottomNav />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,14 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Button } from "./primitives";
|
import { Button } from "./primitives";
|
||||||
import { Icon, type IconName } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { useModeStore } from "../store/useModeStore";
|
|
||||||
import type { Mode } from "../lib/types";
|
|
||||||
|
|
||||||
export interface ChatInputProps {
|
export interface ChatInputProps {
|
||||||
onSend: (text: string, mode: Mode) => void;
|
onSend: (text: string) => void;
|
||||||
sending: boolean;
|
sending: boolean;
|
||||||
onVoice: () => void;
|
onVoice: () => void;
|
||||||
placeholder?: string;
|
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({
|
export function ChatInput({
|
||||||
onSend,
|
onSend,
|
||||||
sending,
|
sending,
|
||||||
@@ -28,8 +17,6 @@ export function ChatInput({
|
|||||||
}: ChatInputProps): React.ReactNode {
|
}: ChatInputProps): React.ReactNode {
|
||||||
const [value, setValue] = useState("");
|
const [value, setValue] = useState("");
|
||||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
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
|
// auto-grow the textarea up to a max height
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -42,31 +29,12 @@ export function ChatInput({
|
|||||||
const submit = () => {
|
const submit = () => {
|
||||||
const t = value.trim();
|
const t = value.trim();
|
||||||
if (!t || sending) return;
|
if (!t || sending) return;
|
||||||
onSend(t, mode);
|
onSend(t);
|
||||||
setValue("");
|
setValue("");
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass sticky bottom-0 border-t border-[var(--mu-glass-border)] p-3 md:p-4">
|
<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)]">
|
<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
|
<textarea
|
||||||
ref={taRef}
|
ref={taRef}
|
||||||
|
|||||||
@@ -1,87 +1,100 @@
|
|||||||
import { useModeStore } from "../store/useModeStore";
|
import { useState } from "react";
|
||||||
import { sendUp } from "../lib/ws";
|
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
|
||||||
import { MODES, type Mode } from "../lib/types";
|
import { getAuthToken } from "../lib/api";
|
||||||
|
import { useAppStore } from "../store/useAppStore";
|
||||||
import { Icon, type IconName } from "./Icon";
|
import { Icon, type IconName } from "./Icon";
|
||||||
|
import { Tag } from "./primitives";
|
||||||
|
|
||||||
const NAV_MODES: ReadonlyArray<Mode> = [
|
/**
|
||||||
"chat", "code", "home", "work", "docs", "voice", "vision", "agents",
|
* 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.
|
||||||
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 } });
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Sidebar(): React.ReactNode {
|
export function Sidebar(): React.ReactNode {
|
||||||
const mode = useModeStore((s) => s.mode);
|
const wsStatus = useAppStore((s) => s.wsStatus);
|
||||||
const switchMode = useSwitchMode();
|
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 (
|
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">
|
<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>
|
</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>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BottomNav(): React.ReactNode {
|
function NavItem({
|
||||||
const mode = useModeStore((s) => s.mode);
|
icon,
|
||||||
const switchMode = useSwitchMode();
|
label,
|
||||||
|
onClick,
|
||||||
|
active = false,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
icon: IconName;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
active?: boolean;
|
||||||
|
hint?: string;
|
||||||
|
}): React.ReactNode {
|
||||||
return (
|
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">
|
<button
|
||||||
{NAV_MODES.slice(0, 5).map((m) => {
|
onClick={onClick}
|
||||||
const meta = modeMeta[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] ${
|
||||||
const active = m === mode;
|
active
|
||||||
return (
|
? "bg-[var(--mu-accent)]/15 font-medium text-[var(--mu-accent-hi)]"
|
||||||
<button
|
: "text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-4)]"
|
||||||
key={m}
|
}`}
|
||||||
onClick={() => switchMode(m)}
|
>
|
||||||
className={`flex flex-1 flex-col items-center justify-center gap-0.5 text-[11px] transition-colors duration-200 ${
|
<Icon name={icon} size={18} />
|
||||||
active ? "text-[var(--mu-accent-hi)]" : "text-[var(--mu-text-lo)]"
|
<span>{label}</span>
|
||||||
}`}
|
{hint ? <span className="ml-auto text-[10px] text-[var(--mu-text-lo)]">{hint}</span> : null}
|
||||||
>
|
</button>
|
||||||
<Icon name={meta.icon} size={18} />
|
|
||||||
<span>{meta.label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -155,10 +155,10 @@ export function chatSync(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** POST /api/v1/chat with stream:true — triggers streaming over the WS channel. */
|
/** POST /api/v1/chat with stream:true — triggers streaming over the WS channel. */
|
||||||
export function chatStream(sessionId: string, text: string, mode?: Mode): Promise<{ stream: true }> {
|
export function chatStream(sessionId: string, text: string): Promise<{ stream: true }> {
|
||||||
return request<{ stream: true }>("/chat", {
|
return request<{ stream: true }>("/chat", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ sessionId, text, stream: true, mode }),
|
body: JSON.stringify({ sessionId, text, stream: true }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,19 +2,18 @@ import { useCallback, useState } from "react";
|
|||||||
import { chatStream } from "./api";
|
import { chatStream } from "./api";
|
||||||
import { connectWs, isWsOpen } from "./ws";
|
import { connectWs, isWsOpen } from "./ws";
|
||||||
import { useAppStore } from "../store/useAppStore";
|
import { useAppStore } from "../store/useAppStore";
|
||||||
import { useModeStore } from "../store/useModeStore";
|
|
||||||
import { now } from "./util";
|
import { now } from "./util";
|
||||||
import type { Mode } from "./types";
|
|
||||||
|
|
||||||
/** Drives one user turn: push the user bubble, blanket a streaming assistant
|
/** Drives one user turn: push the user bubble, blanket a streaming assistant
|
||||||
* bubble, trigger the backend loop over REST (`stream:true`), and let the WS
|
* bubble, trigger the backend loop over REST (`stream:true`), and let the WS
|
||||||
* stream render into that bubble. Falls back to the WS `chat` channel when the
|
* stream render into that bubble. Falls back to the WS `chat` channel when the
|
||||||
* REST trigger fails so the demo still works without a wired loop.
|
* REST trigger fails so the demo still works without a wired loop.
|
||||||
*
|
*
|
||||||
* Each message carries the current input mode, so its bubble renders per that
|
* The behavior mode is NOT chosen by the user at send time — the agent / backend
|
||||||
* mode (design/07 §1: shared message stream, per-message mode rendering). */
|
* hot-switches it (design/01 §3), and each message carries its own mode, so the
|
||||||
|
* bubble renders per that message's mode. */
|
||||||
export function useSendChat(): {
|
export function useSendChat(): {
|
||||||
send: (text: string, mode?: Mode) => Promise<void>;
|
send: (text: string) => Promise<void>;
|
||||||
sending: boolean;
|
sending: boolean;
|
||||||
} {
|
} {
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
@@ -27,18 +26,17 @@ export function useSendChat(): {
|
|||||||
const appendTaskLog = useAppStore((s) => s.appendTaskLog);
|
const appendTaskLog = useAppStore((s) => s.appendTaskLog);
|
||||||
|
|
||||||
const send = useCallback(
|
const send = useCallback(
|
||||||
async (text: string, mode?: Mode) => {
|
async (text: string) => {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed || sending) return;
|
if (!trimmed || sending) return;
|
||||||
setSending(true);
|
setSending(true);
|
||||||
if (!isWsOpen()) connectWs();
|
if (!isWsOpen()) connectWs();
|
||||||
|
|
||||||
const m = mode ?? "chat";
|
addUserMessage(trimmed);
|
||||||
addUserMessage(trimmed, m);
|
beginAssistant();
|
||||||
beginAssistant(m);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await chatStream(sessionId, trimmed, m);
|
await chatStream(sessionId, trimmed);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
// Backend loop not wired or gateway unreachable: show the failure inline
|
// Backend loop not wired or gateway unreachable: show the failure inline
|
||||||
// rather than leaving a permanently-streaming bubble.
|
// rather than leaving a permanently-streaming bubble.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useAppStore } from "../store/useAppStore";
|
import { useAppStore } from "../store/useAppStore";
|
||||||
import { useModeStore } from "../store/useModeStore";
|
|
||||||
import { useSendChat } from "../lib/useChat";
|
import { useSendChat } from "../lib/useChat";
|
||||||
import { MessageBubble } from "../components/chat";
|
import { MessageBubble } from "../components/chat";
|
||||||
import { ChatInput } from "../components/ChatInput";
|
import { ChatInput } from "../components/ChatInput";
|
||||||
@@ -9,15 +8,14 @@ import { Icon } from "../components/Icon";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The single shared page (design/07 §1). All behavior modes live in ONE message
|
* The single shared page (design/07 §1). All behavior modes live in ONE message
|
||||||
* stream; each message carries its own `mode`, so its bubble renders per that
|
* stream; each message carries its own `mode` (assigned by the agent at runtime,
|
||||||
* mode — a session can mix chat / code / home / docs / work / vision shapes.
|
* design/01 §3 hot-switch), so its bubble renders per that mode. There is no
|
||||||
* The "input mode" chip above the composer sets the mode of the NEXT message.
|
* manual mode selector — the agent / backend drives the mode.
|
||||||
*/
|
*/
|
||||||
export function ChatShell(): React.ReactNode {
|
export function ChatShell(): React.ReactNode {
|
||||||
const messages = useAppStore((s) => s.messages);
|
const messages = useAppStore((s) => s.messages);
|
||||||
const { send, sending } = useSendChat();
|
const { send, sending } = useSendChat();
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
const setMode = useModeStore((s) => s.setMode);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||||
@@ -39,7 +37,7 @@ export function ChatShell(): React.ReactNode {
|
|||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChatInput onSend={(t, mode) => void send(t, mode)} sending={sending} onVoice={() => setMode("voice")} />
|
<ChatInput onSend={(t) => void send(t)} sending={sending} onVoice={() => { /* voice handled by agent */ }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user