feat(frontend): M9 模态 UI(mode 系统+共享 store+WS 客户端 + chat(思维链/工具回显/情绪徽标)/code/Agent 监控/后台思考页 + Miuix 设计 token + PWA);tsc+vite build 通过

This commit is contained in:
Aska committed 2026-09-06 07:44:40 +08:00
1 parent b3ffd51736
commit 4c48e94659
40 files changed
+5321

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "./primitives";
export interface ChatInputProps {
onSend: (text: string) => void;
sending: boolean;
onVoice: () => void;
placeholder?: string;
}
export function ChatInput({
onSend,
sending,
onVoice,
placeholder = "给昔涟发消息…",
}: ChatInputProps): React.ReactNode {
const [value, setValue] = useState("");
const taRef = useRef<HTMLTextAreaElement>(null);
// auto-grow the textarea up to a max height
useEffect(() => {
const el = taRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 140) + "px";
}, [value]);
const submit = () => {
const t = value.trim();
if (!t || sending) return;
onSend(t);
setValue("");
};
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 items-end gap-2 rounded-[var(--mu-radius-lg)] bg-[var(--mu-surface-2)] p-2 shadow-[var(--mu-shadow-1)]">
<textarea
ref={taRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
rows={1}
placeholder={placeholder}
className="max-h-[140px] flex-1 resize-none bg-transparent px-2 py-2 text-sm text-[var(--mu-text-hi)] outline-none placeholder:text-[var(--mu-text-lo)]"
/>
<button
onClick={onVoice}
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"
>
🎙
</button>
<Button
variant="primary"
onClick={submit}
disabled={sending || value.trim().length === 0}
className="shrink-0"
>
{sending ? "发送中…" : "发送"}
</Button>
</div>
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useModeStore } from "../store/useModeStore";
import { sendUp } from "../lib/ws";
import { MODES, type Mode } from "../lib/types";
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: "⚙" },
};
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 (
<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>
{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)]"
}`}
>
<span className="text-base">{meta.icon}</span>
<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>
);
})}
</nav>
);
}
export function BottomNav(): React.ReactNode {
const mode = useModeStore((s) => s.mode);
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) => {
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)]"
}`}
>
<span className="text-base">{meta.icon}</span>
<span>{meta.label}</span>
</button>
);
})}
</nav>
);
}
+58
View File
@@ -0,0 +1,58 @@
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 { Tag } from "./primitives";
const wsMeta: Record<WsStatus, { label: string; tone: "ok" | "warn" | "neutral" | "err" }> = {
idle: { label: "未连接", tone: "neutral" },
connecting: { label: "连接中", tone: "warn" },
connected: { label: "实时", tone: "ok" },
reconnecting: { label: "重连中", tone: "warn" },
disconnected: { label: "已断开", tone: "err" },
};
export function TopBar(): React.ReactNode {
const wsStatus = useAppStore((s) => s.wsStatus);
const mood = useAppStore((s) => s.mood);
const mode = useModeStore((s) => s.mode);
const [theme, setTheme] = useState<ThemeMode>(readTheme);
const toggleTheme = () => {
const next: ThemeMode = theme === "dark" ? "light" : "dark";
applyTheme(next);
setTheme(next);
};
const meta = wsMeta[wsStatus];
return (
<header className="glass sticky top-0 z-30 flex h-[var(--mu-topbar-h)] items-center gap-3 border-b border-[var(--mu-glass-border)] px-4">
<div className="flex items-center gap-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)]">{mode} 模式</div>
</div>
</div>
<div className="flex-1" />
{mood ? <Tag tone="accent">心情 · {mood}</Tag> : null}
<Tag tone={meta.tone}>
<span className={`inline-block h-1.5 w-1.5 rounded-full ${wsStatus === "connected" ? "bg-[var(--mu-ok)]" : "mu-blink"}`} />
{meta.label}
</Tag>
<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" ? "☀" : "☾"}
</button>
</header>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { useState } from "react";
import type { ChatMessage, ToolUsed } from "../lib/types";
import { Tag } from "./primitives";
/** Collapsible inline chain-of-thought block. */
export function ThinkingBlock({
text,
streaming,
}: {
text?: string;
streaming?: boolean;
}): React.ReactNode | null {
const [open, setOpen] = useState(false);
if (!text && !streaming) return null;
return (
<div className="mb-2 overflow-hidden rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/60">
<button
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-xs text-[var(--mu-text-lo)]"
>
<span
className={`inline-block transition-transform duration-200 ${open ? "rotate-90" : ""}`}
>
▸
</span>
<span>思维链</span>
{streaming && !text ? <span className="mu-blink">思考中…</span> : null}
</button>
{open && text ? (
<pre className="max-h-40 overflow-auto whitespace-pre-wrap px-3 pb-2 text-xs leading-relaxed text-[var(--mu-text-mid)]">
{text}
</pre>
) : null}
</div>
);
}
/** Tool echo card (工具回显). */
export function ToolCard({ tool }: { tool: ToolUsed }): React.ReactNode {
const [open, setOpen] = useState(false);
const status = tool.ok === undefined ? "neutral" : tool.ok ? "ok" : "err";
return (
<div className="mb-2 overflow-hidden rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/60">
<button
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs"
>
<span
className={`inline-block transition-transform duration-200 ${open ? "rotate-90" : ""}`}
>
▸
</span>
<Tag tone={status}>
{tool.ok === false ? "✗" : tool.ok === true ? "✓" : "⚙"} {tool.name}
</Tag>
{tool.summary ? (
<span className="truncate text-[var(--mu-text-lo)]">{tool.summary}</span>
) : null}
</button>
{(open || tool.detail) && tool.detail ? (
<pre className="max-h-40 overflow-auto whitespace-pre-wrap px-3 pb-2 text-xs text-[var(--mu-text-mid)]">
{tool.detail}
</pre>
) : null}
</div>
);
}
/** A single chat message bubble. */
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>
);
}
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%]">
{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>
</div>
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
/** Miuix-style primitives: Button / Tag / Switch / Spinner / Surface / Empty.
* Springs are applied only to interactive elements (cubic-bezier(0.34,1.56,0.64,1)). */
type ButtonVariant = "primary" | "soft" | "ghost" | "outline" | "danger";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: "sm" | "md";
children: ReactNode;
}
const buttonVariant: Record<ButtonVariant, string> = {
primary: "bg-[var(--mu-accent)] text-white hover:bg-[var(--mu-accent-hi)] shadow-[var(--mu-shadow-2)]",
soft: "bg-[var(--mu-surface-4)] text-[var(--mu-text-hi)] hover:bg-[var(--mu-surface-5)]",
ghost: "text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-4)]",
outline:
"border border-[var(--mu-glass-border)] text-[var(--mu-text-hi)] hover:bg-[var(--mu-surface-4)]",
danger: "bg-[var(--mu-err)] text-white",
};
export function Button({
variant = "soft",
size = "md",
className = "",
children,
...rest
}: ButtonProps): React.ReactNode {
const pad = size === "sm" ? "px-2.5 py-1 text-xs" : "px-4 py-2 text-sm";
return (
<button
className={`inline-flex items-center justify-center gap-1.5 font-medium transition-all duration-200 ease-[var(--mu-spring)] active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none ${pad} ${buttonVariant[variant]} ${className}`}
{...rest}
>
{children}
</button>
);
}
export type TagTone = "accent" | "ok" | "warn" | "err" | "neutral";
export interface TagProps {
tone?: TagTone;
children: ReactNode;
className?: string;
}
const tagTone: Record<TagTone, string> = {
accent: "bg-[var(--mu-accent)]/15 text-[var(--mu-accent-hi)]",
ok: "bg-[var(--mu-ok)]/15 text-[var(--mu-ok)]",
warn: "bg-[var(--mu-warn)]/15 text-[var(--mu-warn)]",
err: "bg-[var(--mu-err)]/15 text-[var(--mu-err)]",
neutral: "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)]",
};
export function Tag({ tone = "neutral", children, className = "" }: TagProps): React.ReactNode {
return (
<span
className={`inline-flex items-center gap-1 rounded-[var(--mu-radius-sm)] px-2 py-0.5 text-xs font-medium ${tagTone[tone]} ${className}`}
>
{children}
</span>
);
}
export interface SwitchProps {
checked: boolean;
onChange: (next: boolean) => void;
disabled?: boolean;
label?: string;
}
export function Switch({ checked, onChange, disabled, label }: SwitchProps): React.ReactNode {
return (
<label
className={`flex items-center gap-2 select-none ${disabled ? "opacity-50 pointer-events-none" : ""}`}
>
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={() => onChange(!checked)}
className={`relative h-6 w-11 shrink-0 rounded-[var(--mu-radius-pill)] transition-colors duration-200 ${
checked ? "bg-[var(--mu-accent)]" : "bg-[var(--mu-surface-5)]"
}`}
>
<span
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow-md transition-transform duration-300 ease-[var(--mu-spring)] ${
checked ? "translate-x-[22px]" : "translate-x-0.5"
}`}
/>
</button>
{label ? <span className="text-sm text-[var(--mu-text-mid)]">{label}</span> : null}
</label>
);
}
export interface SpinnerProps {
size?: number;
label?: string;
}
export function Spinner({ size = 18, label }: SpinnerProps): React.ReactNode {
return (
<span className="inline-flex items-center gap-2 text-[var(--mu-text-lo)]">
<span
className="inline-block animate-spin rounded-full border-2 border-[var(--mu-surface-5)] border-t-[var(--mu-accent)]"
style={{ width: size, height: size }}
/>
{label ? <span className="text-xs">{label}</span> : null}
</span>
);
}
export interface SurfaceProps extends HTMLAttributes<HTMLDivElement> {
glass?: boolean;
liquid?: boolean;
gap?: "none" | "sm" | "md";
children: ReactNode;
}
export function Surface({
glass = false,
liquid = false,
gap = "md",
className = "",
children,
...rest
}: SurfaceProps): React.ReactNode {
const pad = gap === "none" ? "" : gap === "sm" ? "p-3" : "p-5";
const base = glass
? "glass"
: liquid
? "liquid-glass"
: "bg-[var(--mu-surface-3)] border border-[var(--mu-glass-border)] shadow-[var(--mu-shadow-1)]";
return (
<div className={`rounded-[var(--mu-radius-lg)] ${base} ${pad} ${className}`} {...rest}>
{children}
</div>
);
}
export interface EmptyStateProps {
icon?: ReactNode;
title: string;
hint?: string;
}
export function EmptyState({ icon, title, hint }: EmptyStateProps): React.ReactNode {
return (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
{icon ? <div className="text-3xl opacity-60">{icon}</div> : null}
<div className="text-sm font-medium text-[var(--mu-text-mid)]">{title}</div>
{hint ? <div className="text-xs text-[var(--mu-text-lo)]">{hint}</div> : null}
</div>
);
}