feat(web): 前端收敛为「共享消息流 + 按消息 mode 渲染」,并补全 emoji→MD3 图标
架构变更(主人要求,design/07 §1/§2 已改定稿):把「9 个行为模式 = 9 个独立页面」改为 「一个共享页面 + 每条消息绑 mode,消息框按对应 mode 渲染」。 - ChatMessage 增 mode?(types.ts);useAppStore addUserMessage/beginAssistant 带 mode; useSendChat.send(text, mode) / chatStream body 带 mode(通知后端按 mode 回复)。 - MessageBubble 按 message.mode 分发到 components/messages/ 各形态渲染器 (chat 气泡 / code 等宽代码块 / home 设备卡 / docs 文档卡 / work 任务卡 / vision 视觉卡)。 - ChatInput 顶部加「行为模式」chip 切换下一条消息的 mode(默认 chat);输入框上方显示当前模式。 - ModeRouter 收敛为只渲染 ChatShell;删除 9 个旧独立 mode 页面;Nav/TopBar 的 mode 改为 「输入模式」语义;settings 归管理面板(design/10)。 同时补全上一轮 emoji→MD3 图标(约 23 文件):新增 Icon.tsx(MD3 SVG、IconName、零依赖、 离线优先),全部界面 emoji 替换为 <Icon name=... />;正文说明箭头保留。 npm run build 绿(tsc 0 错,79→71 模块,JS 284KB/gzip 86KB)。
This commit is contained in:
1 parent
265b207699
commit
4aab0f501a
28 files changed
+539
-950
No files matched your search
@@ -1,13 +1,25 @@
|
||||
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";
|
||||
|
||||
export interface ChatInputProps {
|
||||
onSend: (text: string) => void;
|
||||
onSend: (text: string, mode: Mode) => 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,
|
||||
@@ -16,6 +28,8 @@ 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(() => {
|
||||
@@ -28,12 +42,31 @@ export function ChatInput({
|
||||
const submit = () => {
|
||||
const t = value.trim();
|
||||
if (!t || sending) return;
|
||||
onSend(t);
|
||||
onSend(t, mode);
|
||||
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}
|
||||
@@ -54,7 +87,7 @@ export function ChatInput({
|
||||
aria-label="语音"
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--mu-radius)] bg-[var(--mu-surface-4)] text-base transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-5)] active:scale-95"
|
||||
>
|
||||
🎙
|
||||
<Icon name="mic" size={18} />
|
||||
</button>
|
||||
<Button
|
||||
variant="primary"
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* MD3-style vector icon set (Material Symbols Rounded, 24 viewBox, currentColor).
|
||||
* Zero runtime dependency — pure inline SVG paths, tree-shakeable & offline-first.
|
||||
*
|
||||
* Usage:
|
||||
* import { Icon, type IconName } from "./Icon";
|
||||
* <Icon name="chat" />
|
||||
* <Icon name="settings" size={20} className="text-[var(--mu-accent)]" />
|
||||
*/
|
||||
|
||||
export type IconName =
|
||||
| "chart" // 📊 dashboard (bar_chart_rounded)
|
||||
| "dna" // 🧬 personas (biotech_rounded)
|
||||
| "chat" // 💬 sessions/chat (chat_bubble_rounded)
|
||||
| "memory" // 🧠 memories (psychology_rounded)
|
||||
| "docs" // 📚 rag/docs (menu_book_rounded)
|
||||
| "plugin" // 🔌 plugins (extension_rounded)
|
||||
| "channel" // 📡 channels (sensors_rounded)
|
||||
| "device" // 📲 iot (devices_rounded)
|
||||
| "robot" // 🤖 llm (smart_toy_rounded)
|
||||
| "clock" // ⏰ automations (access_time_rounded)
|
||||
| "agent" // 🧩 agents (hub_rounded)
|
||||
| "night" // 🌙 backthink (dark_mode_rounded)
|
||||
| "trend" // 📈 observability (monitoring_rounded)
|
||||
| "users" // 👥 users (group_rounded)
|
||||
| "settings" // ⚙ system/settings (settings_rounded)
|
||||
| "light" // 💡 light (lightbulb_rounded)
|
||||
| "ac" // ❄️ ac (ac_unit_rounded)
|
||||
| "mic" // 🎙 voice (mic_rounded)
|
||||
| "micOff" // 🔇 mute (mic_off_rounded)
|
||||
| "sun" // ☀ light mode (light_mode_rounded)
|
||||
| "moon" // ☾ dark mode (dark_mode_rounded)
|
||||
| "lock" // 🔒 login (lock_rounded)
|
||||
| "block" // 🚫 forbidden (block_rounded)
|
||||
| "check" // ✓ done (check_rounded)
|
||||
| "close" // ✗ error (close_rounded)
|
||||
| "gear" // ⚙ loading/tool (settings_rounded)
|
||||
| "file" // 📄 doc (description_rounded)
|
||||
| "package" // 📦 env/package (inventory_rounded)
|
||||
| "folder" // 🗂 work/folder (folder_rounded)
|
||||
| "shield" // 🔐/🛡 security (shield_rounded)
|
||||
| "storage" // 🗃 storage (storage_rounded)
|
||||
| "refresh" // 🔄 reload (refresh_rounded)
|
||||
| "vision" // 👁 vision (visibility_rounded)
|
||||
| "home" // 🏠 home (home_rounded)
|
||||
| "arrow" // → direction (arrow_forward_rounded)
|
||||
| "terminal" // 🖥 code (terminal_rounded);
|
||||
|
||||
const ICON_PATHS: Record<IconName, string> = {
|
||||
chart:
|
||||
"M4 20V10h3v10H4Zm6.5 0V4h3v16h-3ZM17 20v-7h3v7h-3Z",
|
||||
dna:
|
||||
"M12 3c2.1 0 3.9.7 5 1.7-1.1 1-2.9 1.7-5 1.7s-3.9-.7-5-1.7c1.1-1 2.9-1.7 5-1.7Zm0 5c2.4 0 4.5.9 6 2.3l-1.4 1.4A5.9 5.9 0 0 0 12 10c-1.8 0-3.5.8-4.6 2.1L6 10.7A7.7 7.7 0 0 1 12 8Zm0 5c2.4 0 4.5.9 6 2.3l-1.4 1.4A5.9 5.9 0 0 0 12 15c-1.8 0-3.5.8-4.6 2.1l-1.5-1.4A7.5 7.5 0 0 1 12 13Zm0 5c.9 0 1.7.2 2.4.5L15 20.5c-.9.3-2 .5-3 .5s-2.1-.2-3-.5l.6-2.5c.7-.3 1.5-.5 2.4-.5Z",
|
||||
chat:
|
||||
"M12 3C6.5 3 2 6.9 2 11.7c0 2.7 1.4 5.1 3.6 6.7-.1.8-.5 2.2-1.8 3.4-.2.2 0 .6.3.5 1.6-.1 3.6-.8 4.7-1.6.9.2 1.9.3 2.9.3 5.5 0 10-3.9 10-8.7S17.5 3 12 3Z",
|
||||
memory:
|
||||
"M13 4a2 2 0 0 1 2 2h1a3 3 0 0 1-3 3h-1v1.1c3.1.4 5.5 2.9 6 6H16a2 2 0 0 1-4 0h-.5a2 2 0 0 1-4 0H5.9c.3-2.9 2.4-5.3 5.1-5.9V9H7a3 3 0 0 1-3-3h1a2 2 0 0 1 4 0 3 3 0 0 1 4 0 4.9 4.9 0 0 1 0-2ZM13 18a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z",
|
||||
docs:
|
||||
"M12 4C8.7 2.9 5.5 2.9 3 3.5V19c2.5-.6 5.7-.6 9 .5 3.3-1.1 6.5-1.1 9-.5V3.5C18.5 2.9 15.3 2.9 12 4Zm-1 12.5c-1.6-.4-3.1-.5-4-.5v-1c.9 0 2.4.1 4 .5v1Zm0-3c-1.6-.4-3.1-.5-4-.5v-1c.9 0 2.4.1 4 .5v1Zm0-3c-1.6-.4-3.1-.5-4-.5V9c.9 0 2.4.1 4 .5v1Zm1-3c1.6-.4 3.1-.5 4-.5v1c-.9 0-2.4.1-4 .5v-1Zm0 3c1.6-.4 3.1-.5 4-.5v1c-.9 0-2.4.1-4 .5v-1Z",
|
||||
plugin:
|
||||
"M12 3c0 1.1.9 2 2 2s2-.9 2-2h4v4c0 1.1-.9 2-2 2s-2-.9-2-2H8v5h3a2 2 0 0 1-2 2s0 0 0 0H5c-1.1 0-2-.9-2-2v-4H1c0-1.1.9-2 2-2h2V3h4c1.1 0 2 .9 2 2s-.9 2-2 2H7C6.4 6 6 5.5 6 5h2c1.1 0 2-.9 2-2h3V3Z",
|
||||
channel:
|
||||
"M12 9.5a2.5 2.5 0 0 1 2.5 2.5c0 1.4-1.1 2.5-2.5 2.5S9.5 13.4 9.5 12 10.6 9.5 12 9.5Zm0-6c3.9 0 7.4 1.6 9.9 4.1l-1.4 1.4A12.6 12.6 0 0 0 12 5.5c-2.7 0-5.2.9-7.2 2.3L3.4 6.5A13.4 13.4 0 0 1 12 3.5Zm0 6a2.5 2.5 0 0 1 0 5v3a3.5 3.5 0 0 0 0-7v-1Z",
|
||||
device:
|
||||
"M4 7a5 5 0 0 1 5 5c0 1.7-.9 3.2-2.2 4.1L5.6 18H5a3 3 0 0 1-3-3v-4a3 3 0 0 1 3-3h-1Zm6 0h6a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3h-6a3 3 0 0 1-3-3v-6a3 3 0 0 1 3-3Z",
|
||||
robot:
|
||||
"M12 3a1.5 1.5 0 0 1 1.5 1.5c0 .5-.2.9-.5 1.2 2.4.6 4.5 2.3 5.5 4.5H20a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h1.5c1-2.2 3.1-3.9 5.5-4.5-.3-.3-.5-.7-.5-1.2A1.5 1.5 0 0 1 12 3ZM8.5 12a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Zm7 0a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z",
|
||||
clock:
|
||||
"M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2Zm0 2c4.4 0 8 3.6 8 8s-3.6 8-8 8-8-3.6-8-8 3.6-8 8-8Zm1 4v5.2l3.5 2-1 1.7L11 14V8h2Z",
|
||||
agent:
|
||||
"M12 3c1.7 0 3.2 1.3 3.2 3 0 .7-.2 1.3-.6 1.8l3.1 1.8c.4-.2.9-.3 1.4-.3a3 3 0 0 1 3 3c0 1.7-1.3 3-3 3-.5 0-1-.1-1.4-.4l-3 1.8c.3.4.5 1 .5 1.5a3.2 3.2 0 0 1-3.2 3.1c-1.7 0-3.2-1.3-3.2-3 0-.8.3-1.5.8-2L7.2 15c-.3.2-.7.3-1.1.3a3 3 0 0 1-3-3c0-1.7 1.3-3 3-3 .4 0 .8.1 1.1.3l3-1.8c-.5-.5-.8-1.2-.8-2C9.4 4.3 10.3 3 12 3Z",
|
||||
night:
|
||||
"M20.2 14.2A8.5 8.5 0 0 1 9.8 3.8a8.5 8.5 0 1 0 10.4 10.4Z",
|
||||
trend:
|
||||
"M3 21v-2h18v2H3Zm2-4v-5h2v5H5Zm4 0V8h2v9H9Zm4 0v-3h2v3h-2Zm4 0V6h2v11h-2Z",
|
||||
users:
|
||||
"M9 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm6 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm-6 2c2.3 0 7 1.1 7 3.5V19H2v-2.5C2 14.1 6.7 13 9 13Zm6 0c.6 0 1.4.1 2.2.3 2 .6 3.8 1.6 3.8 3.2V19h-4v-2.5c0-1.1-.7-2-1.8-2.7.4-.1.8-.2 1.3-.2l-.5.4Z",
|
||||
settings:
|
||||
"M12 9.5A2.5 2.5 0 1 1 9.5 12 2.5 2.5 0 0 1 12 9.5Zm7.4 7.4a7.5 7.5 0 0 0 0-2.1l2.1-1.6-2-3.5-2.5 1a7.6 7.6 0 0 0-1.8-1.1L14 6.5h-4l-.4 2.7c-.7.3-1.3.6-1.8 1.1l-2.5-1-2 3.5 2.1 1.6a7.5 7.5 0 0 0 0 2.1l-2.1 1.6 2 3.5 2.5-1c.5.4 1.1.8 1.8 1.1l.4 2.7h4l.4-2.7c.7-.3 1.3-.6 1.8-1.1l2.5 1 2-3.5-2.1-1.6Z",
|
||||
light:
|
||||
"M12 3c-2.8 0-5 2.2-5 5 0 1.5.7 2.8 1.7 3.7.1.1.3.6.3 1.3h6c0-.7.2-1.2.3-1.3a4.9 4.9 0 0 0 1.7-3.7c0-2.8-2.2-5-5-5Zm-2 16a2 2 0 0 0 4 0h-4Z",
|
||||
ac:
|
||||
"M12 4l-1.5 2H15l-1.5-2L12 4Zm0 5.5L10.5 12H15l-1.5-2.5H12Zm0 5.5L10.5 14H15l-1.5-2H12v3ZM9 6h6l1.5 2H7.5L9 6Zm3 4.5h.5L12 9h.5l1.5 2.5h-4l1.5-2.5H12Zm0 5.5h.5L12 14h.5l1.5 2h-4l1.5-2H12v2.5Z",
|
||||
mic:
|
||||
"M12 14a3 3 0 0 0 3-3V6a3 3 0 0 0-6 0v5a3 3 0 0 0 3 3Zm0-1a2 2 0 0 1-2-2V6a2 2 0 0 1 4 0v5a2 2 0 0 1-2 2ZM6 11a1 1 0 0 1 2 0c0 2.2 1.8 4 4 4s4-1.8 4-4a1 1 0 0 1 2 0c0 3.2-2.5 5.8-5.5 5.9V20h2a1 1 0 0 1 0 2H9a1 1 0 0 1 0-2h2v-3.1C7.9 16.8 6 14.2 6 11Z",
|
||||
micOff:
|
||||
"M12 14a2 2 0 0 0 2-2V6a2 2 0 0 0-4 0v6c0 .4.1.7.3 1l1.7-1.7V3H12Zm0-9a1 1 0 0 1 1 1v5l1.9-1.9A3 3 0 0 0 13 6c0-.6-.4-1-1-1Zm5 6a5 5 0 0 1-2.6 4.4L13 14a3 3 0 0 1-3-3H8.5c0 .7.1 1.3.4 1.9L7 14.8A5.2 5.2 0 0 1 5.6 11H6a1 1 0 0 1 2 0c0 .4.1.8.2 1.2l1.3-1.3A1 1 0 0 1 9.5 11H11V9.6L7.5 6.1A5 5 0 0 1 13 6l2.1 2.1A5 5 0 0 1 17 11Zm-2.9 5.4 1.4 1.4A6.9 6.9 0 0 1 13 16.9V20h2a1 1 0 0 1 0 2H9a1 1 0 0 1 0-2h2v-3.1A6.9 6.9 0 0 1 5 11l1-1 .1 1.4-1.9 1.9a1 1 0 0 0 1.4 1.4l1.8-1.8a6.9 6.9 0 0 0 3.9 2.5l4.3-4.3 1.4 1.4-4.9 4.9c.5.1.9.1 1.4.1Z",
|
||||
sun:
|
||||
"M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm0-2a3 3 0 1 1 0-6 3 3 0 0 1 0 6Zm0-15h2v3h-2V0Zm0 19h2v3h-2v-3Zm-9-8v-2H0v2h3Zm19 0v-2h3v2h-3ZM12 2.5 12.7 1 19 3l-.5 1.7L12 4.5V2.5ZM11.3 22.5 12.7 21l6.3 2.4-1.4 1.4L12 24.3v-1.8ZM2.5 12 1 12.7 3 19l1.7-.5L4.5 12H2.5ZM21.5 11.3 23 12.7 21 19l-1.7-1.4L20 12h1.5v-.7Z",
|
||||
moon:
|
||||
"M20.2 14.2A8.5 8.5 0 0 1 9.8 3.8a8.5 8.5 0 1 0 10.4 10.4Z",
|
||||
lock:
|
||||
"M12 2a4 4 0 0 0-4 4v3H7a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2h-1V6a4 4 0 0 0-4-4Zm-2 4a2 2 0 0 1 4 0v3h-4V6Zm2 8a2 2 0 0 1 1 3.7V19a1 1 0 0 1-2 0v-1.3A2 2 0 0 1 12 14Z",
|
||||
block:
|
||||
"M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2Zm0 2c4.4 0 8 3.6 8 8 0 1.9-.7 3.7-1.8 5.1L6.9 5.8A7.9 7.9 0 0 1 12 4Zm-6.2 2.9 11.3 11.3A7.9 7.9 0 0 1 12 20c-4.4 0-8-3.6-8-8 0-1.9.7-3.7 1.8-5.1Z",
|
||||
check:
|
||||
"M9.5 16.2 5.3 12l-1.4 1.4 5.6 5.7 11.6-11.6L19.7 6 9.5 16.2Z",
|
||||
close:
|
||||
"M18.3 5.7 12 12l-6.3-6.3-1.4 1.4L10.6 12l-6.3 6.3 1.4 1.4L12 13.4l6.3 6.3 1.4-1.4L13.4 12l6.3-6.3-1.4-1.4Z",
|
||||
gear:
|
||||
"M12 9.5A2.5 2.5 0 1 1 9.5 12 2.5 2.5 0 0 1 12 9.5Zm7.4 7.4a7.5 7.5 0 0 0 0-2.1l2.1-1.6-2-3.5-2.5 1a7.6 7.6 0 0 0-1.8-1.1L14 6.5h-4l-.4 2.7c-.7.3-1.3.6-1.8 1.1l-2.5-1-2 3.5 2.1 1.6a7.5 7.5 0 0 0 0 2.1l-2.1 1.6 2 3.5 2.5-1c.5.4 1.1.8 1.8 1.1l.4 2.7h4l.4-2.7c.7-.3 1.3-.6 1.8-1.1l2.5 1 2-3.5-2.1-1.6Z",
|
||||
file:
|
||||
"M6 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6H6Zm6 2.5V9h4.5L12 4.5ZM8 12h8v1.5H8V12Zm0 3.5h8v1.5H8v-1.5Z",
|
||||
package:
|
||||
"M12 3 3 8l9 5 9-5-9-5Zm-8 7v7l8 4.5V18l-8-4.5v-3.5Zm16 0L12 14.5v6.5l8-4.5v-7l-8-4.5 8-4.5Z",
|
||||
folder:
|
||||
"M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2Z",
|
||||
shield:
|
||||
"M12 2 4 5v6c0 5.6 3.4 9.7 8 10 4.6-.3 8-4.4 8-10V5l-8-3Zm0 2.2 6 2.3v4.5c0 4.4-2.6 7.9-6 8.2-3.4-.3-6-3.8-6-8.2V6.5l6-2.3ZM8.5 10l1.4-1.4L11 9.7l2.6-2.6L15 8.5 12.4 11l2.6 2.6-1.4 1.4-2.6-2.6-2.6 2.6-1.4-1.4L9.7 11 8.5 10Z",
|
||||
storage:
|
||||
"M4 5h16v4H4V5Zm0 5h16v4H4v-4Zm0 5h16v4H4v-4ZM2 7H1v2h2V7Zm0 5H1v2h2v-2Zm0 5H1v2h2v-2Zm2-10v2h14V5H4Zm0 4v2h12v-2H4Zm0 4v2h16v-2H4Z",
|
||||
refresh:
|
||||
"M17.6 6.4A8 8 0 1 0 20 12h-2a6 6 0 1 1-1.8-4.3L13 11h7V4l-2.4 2.4Z",
|
||||
vision:
|
||||
"M12 5C6.5 5 2.5 9.5 2 12c.5 2.5 4.5 7 10 7s9.5-4.5 10-7c-.5-2.5-4.5-7-10-7Zm0 2.5A4.5 4.5 0 1 1 7.5 12 4.5 4.5 0 0 1 12 7.5Zm0 2A2.5 2.5 0 1 0 14.5 12 2.5 2.5 0 0 0 12 9.5Z",
|
||||
home:
|
||||
"M12 3 2 11.5 3.3 13 5 11.6V21h5v-6h4v6h5v-9.4l1.7 1.4L22 11.5 12 3Z",
|
||||
arrow:
|
||||
"M5 11h10.2l-3.6-3.6L13 6l6 6-6 6-1.4-1.4 3.6-3.6H5v-2Z",
|
||||
terminal:
|
||||
"M4 3a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H4Zm2.5 4 3 3.5-3 3.5L4.6 12l2.4-2.5-1-1 1-1.5Zm5.5 7h5v1.5h-5V14Z",
|
||||
};
|
||||
|
||||
export interface IconProps {
|
||||
name: IconName;
|
||||
size?: number;
|
||||
title?: string;
|
||||
className?: string;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export function Icon({
|
||||
name,
|
||||
size = 18,
|
||||
title,
|
||||
className,
|
||||
}: IconProps): React.ReactNode {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden={title ? undefined : true}
|
||||
className={className}
|
||||
role={title ? "img" : undefined}
|
||||
>
|
||||
{title ? <title>{title}</title> : null}
|
||||
<path d={ICON_PATHS[name]} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,37 @@
|
||||
import { useModeStore } from "../store/useModeStore";
|
||||
import { sendUp } from "../lib/ws";
|
||||
import { MODES, type Mode } from "../lib/types";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
|
||||
const modeMeta: Record<Mode, { label: string; icon: string }> = {
|
||||
chat: { label: "对话", icon: "💬" },
|
||||
code: { label: "代码", icon: "🖥" },
|
||||
home: { label: "家居", icon: "🏠" },
|
||||
work: { label: "工作", icon: "🗂" },
|
||||
docs: { label: "知识库", icon: "📚" },
|
||||
voice: { label: "语音", icon: "🎙" },
|
||||
vision: { label: "视觉", icon: "👁" },
|
||||
agents: { label: "子代理", icon: "🧩" },
|
||||
settings: { label: "设置", icon: "⚙" },
|
||||
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);
|
||||
const setSelfMode = useModeStore((s) => s.setSelfMode);
|
||||
return (m: Mode) => {
|
||||
setMode(m);
|
||||
setSelfMode(null);
|
||||
sendUp({ type: "mode_switch", data: { mode: m } });
|
||||
};
|
||||
}
|
||||
|
||||
export function Sidebar(): React.ReactNode {
|
||||
const mode = useModeStore((s) => s.mode);
|
||||
const selfMode = useModeStore((s) => s.selfMode);
|
||||
const switchMode = useSwitchMode();
|
||||
|
||||
return (
|
||||
@@ -34,7 +39,7 @@ export function Sidebar(): React.ReactNode {
|
||||
<div className="px-2 py-2 text-xs font-semibold uppercase tracking-wider text-[var(--mu-text-lo)]">
|
||||
行为模式
|
||||
</div>
|
||||
{MODES.map((m) => {
|
||||
{NAV_MODES.map((m) => {
|
||||
const meta = modeMeta[m];
|
||||
const active = m === mode;
|
||||
return (
|
||||
@@ -47,13 +52,8 @@ export function Sidebar(): React.ReactNode {
|
||||
: "text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-4)]"
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{meta.icon}</span>
|
||||
<Icon name={meta.icon} size={18} />
|
||||
<span>{meta.label}</span>
|
||||
{m === selfMode ? (
|
||||
<span className="ml-auto rounded-full bg-[var(--mu-warn)]/15 px-1.5 py-0.5 text-[10px] text-[var(--mu-warn)]">
|
||||
自切
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -66,7 +66,7 @@ export function BottomNav(): React.ReactNode {
|
||||
const switchMode = useSwitchMode();
|
||||
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">
|
||||
{MODES.slice(0, 5).map((m) => {
|
||||
{NAV_MODES.slice(0, 5).map((m) => {
|
||||
const meta = modeMeta[m];
|
||||
const active = m === mode;
|
||||
return (
|
||||
@@ -77,7 +77,7 @@ export function BottomNav(): React.ReactNode {
|
||||
active ? "text-[var(--mu-accent-hi)]" : "text-[var(--mu-text-lo)]"
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{meta.icon}</span>
|
||||
<Icon name={meta.icon} size={18} />
|
||||
<span>{meta.label}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -2,8 +2,18 @@ import { useState } from "react";
|
||||
import { useAppStore } from "../store/useAppStore";
|
||||
import { useModeStore } from "../store/useModeStore";
|
||||
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
|
||||
import type { WsStatus } from "../lib/types";
|
||||
import type { Mode, WsStatus } from "../lib/types";
|
||||
import { Tag } from "./primitives";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
const wsMeta: Record<WsStatus, { label: string; tone: "ok" | "warn" | "neutral" | "err" }> = {
|
||||
idle: { label: "未连接", tone: "neutral" },
|
||||
@@ -33,7 +43,12 @@ export function TopBar(): React.ReactNode {
|
||||
<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)]">{mode} 模式</div>
|
||||
<div className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Icon name={MODE_ICON[mode]} size={12} />
|
||||
{MODE_LABEL[mode]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,14 +67,14 @@ export function TopBar(): React.ReactNode {
|
||||
title="管理面板 /admin"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-[var(--mu-radius)] bg-[var(--mu-surface-4)] text-sm transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-5)] active:scale-95"
|
||||
>
|
||||
🛡
|
||||
<Icon name="shield" size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
aria-label="切换主题"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-[var(--mu-radius)] bg-[var(--mu-surface-4)] text-sm transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-5)] active:scale-95"
|
||||
>
|
||||
{theme === "dark" ? "☀" : "☾"}
|
||||
<Icon name={theme === "dark" ? "sun" : "moon"} size={18} />
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import type { ChatMessage, ToolUsed } from "../lib/types";
|
||||
import type { ChatMessage, Mode, ToolUsed } from "../lib/types";
|
||||
import { Tag } from "./primitives";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
import { modeBody } from "./messages";
|
||||
|
||||
/** Collapsible inline chain-of-thought block. */
|
||||
export function ThinkingBlock({
|
||||
@@ -52,7 +54,10 @@ export function ToolCard({ tool }: { tool: ToolUsed }): React.ReactNode {
|
||||
▸
|
||||
</span>
|
||||
<Tag tone={status}>
|
||||
{tool.ok === false ? "✗" : tool.ok === true ? "✓" : "⚙"} {tool.name}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Icon name={tool.ok === false ? "close" : tool.ok === true ? "check" : "gear"} size={14} />
|
||||
{tool.name}
|
||||
</span>
|
||||
</Tag>
|
||||
{tool.summary ? (
|
||||
<span className="truncate text-[var(--mu-text-lo)]">{tool.summary}</span>
|
||||
@@ -67,36 +72,75 @@ export function ToolCard({ tool }: { tool: ToolUsed }): React.ReactNode {
|
||||
);
|
||||
}
|
||||
|
||||
/** A single chat message bubble. */
|
||||
/** A single message bubble. Per design/07 §1 the bubble shape is driven by the
|
||||
* message's own `mode`: the same role renders differently under different modes,
|
||||
* and a single session may mix shapes. Falls back to `chat` when mode is absent
|
||||
* (legacy persisted messages). */
|
||||
export function MessageBubble({ message }: { message: ChatMessage }): React.ReactNode {
|
||||
const isUser = message.role === "user";
|
||||
if (isUser) {
|
||||
return (
|
||||
<div className="mu-rise-in flex justify-end">
|
||||
<div className="max-w-[80%] whitespace-pre-wrap rounded-[var(--mu-radius-lg)] bg-[var(--mu-accent)] px-4 py-2.5 text-sm text-white shadow-[var(--mu-shadow-1)]">
|
||||
{message.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const mode = message.mode ?? "chat";
|
||||
if (message.role === "user") {
|
||||
return <UserBubble message={message} mode={mode} />;
|
||||
}
|
||||
return <AssistantBubble message={message} mode={mode} />;
|
||||
}
|
||||
|
||||
/** User-side bubble. The outer accent pill is shared; the inner content may be
|
||||
* mode-shaped (e.g. code request renders in a mono pre). */
|
||||
function UserBubble({ message, mode }: { message: ChatMessage; mode: Mode }): React.ReactNode {
|
||||
return (
|
||||
<div className="mu-rise-in flex justify-end">
|
||||
<div className="max-w-[80%] rounded-[var(--mu-radius-lg)] bg-[var(--mu-accent)] px-4 py-2.5 text-sm text-white shadow-[var(--mu-shadow-1)]">
|
||||
{mode === "code" ? (
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed">{message.content}</pre>
|
||||
) : (
|
||||
<span className="whitespace-pre-wrap">{message.content}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Assistant-side bubble. Outer layout shared; the body renders per mode via the
|
||||
* mode-specific body (chat / code / docs / home / work / vision …). */
|
||||
function AssistantBubble({ message, mode }: { message: ChatMessage; mode: Mode }): React.ReactNode {
|
||||
return (
|
||||
<div className="mu-rise-in flex gap-3">
|
||||
<img src="/icon.svg" alt="" className="mt-1 h-7 w-7 shrink-0" />
|
||||
<div className="min-w-0 max-w-[85%]">
|
||||
<ModeTag mode={mode} />
|
||||
{message.mood ? (
|
||||
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {message.mood}</div>
|
||||
) : null}
|
||||
<ThinkingBlock text={message.thinking} streaming={message.streaming} />
|
||||
{message.tools?.map((t, i) => <ToolCard key={i} tool={t} />)}
|
||||
<div className="rounded-[var(--mu-radius-lg)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">
|
||||
{message.content ? (
|
||||
<span className="whitespace-pre-wrap">{message.content}</span>
|
||||
) : message.streaming ? (
|
||||
<span className="mu-blink text-[var(--mu-text-lo)]">生成中…</span>
|
||||
) : null}
|
||||
</div>
|
||||
{modeBody(mode, message)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small chip showing the message's behavior mode. */
|
||||
function ModeTag({ mode }: { mode: Mode }): React.ReactNode {
|
||||
if (mode === "chat") return null;
|
||||
return (
|
||||
<div className="mb-1 inline-flex items-center gap-1 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-4)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--mu-text-mid)]">
|
||||
<Icon name={modeIcon(mode)} size={11} />
|
||||
{modeLabel(mode)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function modeLabel(mode: Mode): string {
|
||||
const map: Record<Mode, string> = {
|
||||
chat: "对话", code: "代码", home: "家居", work: "工作",
|
||||
docs: "知识库", voice: "语音", vision: "视觉", agents: "子代理", settings: "设置",
|
||||
};
|
||||
return map[mode];
|
||||
}
|
||||
function modeIcon(mode: Mode): IconName {
|
||||
const map: Record<Mode, IconName> = {
|
||||
chat: "chat", code: "terminal", home: "home", work: "folder",
|
||||
docs: "docs", voice: "mic", vision: "vision", agents: "agent", settings: "settings",
|
||||
};
|
||||
return map[mode];
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type { ChatMessage, Mode } from "../../lib/types";
|
||||
import { Tag } from "../primitives";
|
||||
import { Icon } from "../Icon";
|
||||
|
||||
/**
|
||||
* Mode-specific assistant message bodies (design/07 §2). The shared outer
|
||||
* layout (avatar / mood / thinking / tools) lives in MessageBubble — these
|
||||
* components only render the content region differently per mode, so the same
|
||||
* role under different modes looks different while a single session mixes them.
|
||||
*/
|
||||
|
||||
function shell(children: ReactNode): ReactNode {
|
||||
return (
|
||||
<div className="rounded-[var(--mu-radius-lg)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fallbackText(message: ChatMessage): ReactNode {
|
||||
return message.content ? (
|
||||
<span className="whitespace-pre-wrap">{message.content}</span>
|
||||
) : message.streaming ? (
|
||||
<span className="mu-blink text-[var(--mu-text-lo)]">生成中…</span>
|
||||
) : null;
|
||||
}
|
||||
|
||||
/** code — mono code block + optional terminal-ish stream, not a plain bubble. */
|
||||
export function CodeMessageBody({ message }: { message: ChatMessage }): ReactNode {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{message.content ? (
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] p-3 font-mono text-xs leading-relaxed text-[var(--mu-text-mid)]">
|
||||
{message.content}
|
||||
</pre>
|
||||
) : message.streaming ? (
|
||||
<div className="mu-blink text-xs text-[var(--mu-text-lo)]">正在执行 / 生成中…</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** docs — knowledge-base document card. */
|
||||
export function DocsMessageBody({ message }: { message: ChatMessage }): ReactNode {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{message.content ? (
|
||||
<div className="flex items-start gap-2">
|
||||
<Icon name="docs" size={18} className="mt-0.5 shrink-0 text-[var(--mu-accent)]" />
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="text-sm font-medium">{message.content}</div>
|
||||
<Tag tone="neutral">知识库</Tag>
|
||||
</div>
|
||||
</div>
|
||||
) : fallbackText(message)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** home — device card line (icons + state chips). */
|
||||
export function HomeMessageBody({ message }: { message: ChatMessage }): ReactNode {
|
||||
const devices = sampleDevices(message.content);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{message.content ? (
|
||||
<span className="whitespace-pre-wrap">{message.content}</span>
|
||||
) : null}
|
||||
{devices.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{devices.map((d) => (
|
||||
<Tag key={d} tone="accent">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Icon name="device" size={12} />
|
||||
{d}
|
||||
</span>
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{message.streaming ? <div className="mu-blink text-xs text-[var(--mu-text-lo)]">设备状态更新中…</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** work — task checklist card. */
|
||||
export function WorkMessageBody({ message }: { message: ChatMessage }): ReactNode {
|
||||
const tasks = (message.content ?? "").split("\n").filter((l) => l.trim());
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{tasks.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{tasks.map((t, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<Icon name="check" size={14} className="shrink-0 text-[var(--mu-ok)]" />
|
||||
<span className="min-w-0 whitespace-pre-wrap">{t}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : fallbackText(message)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** vision — image analysis summary card. */
|
||||
export function VisionMessageBody({ message }: { message: ChatMessage }): ReactNode {
|
||||
return shell(message.content ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--mu-text-lo)]">
|
||||
<Icon name="vision" size={14} />
|
||||
视觉分析
|
||||
</div>
|
||||
<span className="whitespace-pre-wrap">{message.content}</span>
|
||||
</div>
|
||||
) : fallbackText(message));
|
||||
}
|
||||
|
||||
/** voice — speech session card. */
|
||||
export function VoiceMessageBody({ message }: { message: ChatMessage }): ReactNode {
|
||||
return shell(message.content ? (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Icon name="mic" size={16} className="text-[var(--mu-accent)]" />
|
||||
<span className="whitespace-pre-wrap">{message.content}</span>
|
||||
</div>
|
||||
) : fallbackText(message));
|
||||
}
|
||||
|
||||
/** agents — sub-agent progress card (kept for the shared flow; monitors live in messages). */
|
||||
export function AgentsMessageBody({ message }: { message: ChatMessage }): ReactNode {
|
||||
return shell(<span className="whitespace-pre-wrap">{message.content || fallbackText(message)}</span>);
|
||||
}
|
||||
|
||||
/** Reserved — settings is managed in the admin panel; an opaque content fallback. */
|
||||
export function SettingsMessageBody({ message }: { message: ChatMessage }): ReactNode {
|
||||
return shell(fallbackText(message));
|
||||
}
|
||||
|
||||
/** Map a mode to its message body component for the dispatcher. */
|
||||
export function modeBody(mode: Mode, message: ChatMessage): ReactNode {
|
||||
switch (mode) {
|
||||
case "code": return <CodeMessageBody message={message} />;
|
||||
case "docs": return <DocsMessageBody message={message} />;
|
||||
case "home": return <HomeMessageBody message={message} />;
|
||||
case "work": return <WorkMessageBody message={message} />;
|
||||
case "vision": return <VisionMessageBody message={message} />;
|
||||
case "voice": return <VoiceMessageBody message={message} />;
|
||||
case "agents": return <AgentsMessageBody message={message} />;
|
||||
case "settings": return <SettingsMessageBody message={message} />;
|
||||
default: return <div className="rounded-[var(--mu-radius-lg)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">{fallbackText(message)}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
/** tiny sample device extraction for the home card demo (content contains names). */
|
||||
function sampleDevices(content: string): string[] {
|
||||
if (!content) return [];
|
||||
const known = ["灯", "空调", "插座", "窗帘", "电视", "加湿器"];
|
||||
return known.filter((k) => content.includes(k));
|
||||
}
|
||||
Reference in new issue
Block a user