feat(web): 前端收敛为「共享消息流 + 按消息 mode 渲染」,并补全 emoji→MD3 图标
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

架构变更(主人要求,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:
Aska committed 2026-09-06 13:32:44 +08:00
1 parent 265b207699
commit 4aab0f501a
28 files changed
+539 -950

No files matched your search

+9 -4
View File
@@ -4,6 +4,7 @@ import { getAuthToken, getStoredRole } from "../lib/api";
import { ADMIN_GROUPS, ADMIN_SECTIONS } from "./sections";
import { navigateAdmin, type AdminSectionId } from "./router";
import { Button, Tag } from "../components/primitives";
import { Icon } from "../components/Icon";
import { DashboardModule } from "./modules/Dashboard";
import { PersonasModule } from "./modules/Personas";
import { SessionsModule } from "./modules/Sessions";
@@ -57,7 +58,7 @@ function AdminSidebar({ active }: { active: AdminSectionId }): React.ReactNode {
: "text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-4)]"
}`}
>
<span className="text-base">{s.icon}</span>
<Icon name={s.icon} size={18} />
<span>{s.label}</span>
</button>
))}
@@ -93,7 +94,7 @@ function AdminTopBar({ section }: { section: AdminSectionId }): React.ReactNode
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>
<Button size="sm" variant="ghost" onClick={() => { window.location.hash = ""; }}>
返回用户端
@@ -105,7 +106,9 @@ function AdminTopBar({ section }: { section: AdminSectionId }): React.ReactNode
function LoginGate(): React.ReactNode {
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
<div className="text-4xl">🔒</div>
<div className="flex h-12 w-12 items-center justify-center rounded-[var(--mu-radius-lg)] bg-[var(--mu-surface-4)] text-[var(--mu-accent)]">
<Icon name="lock" size={32} />
</div>
<div className="text-lg font-semibold">需要登录</div>
<p className="max-w-md text-sm text-[var(--mu-text-lo)]">
管理面板需持有 JWT。请先到用户端 <span className="text-[var(--mu-text-hi)]">设置 → 连接</span> 登录获取令牌后,再返回本页。
@@ -119,7 +122,9 @@ function Forbidden403(): React.ReactNode {
const role = getStoredRole();
return (
<div className="flex h-full flex-col items-center justify-center gap-4 p-8 text-center">
<div className="text-4xl">🚫</div>
<div className="flex h-12 w-12 items-center justify-center rounded-[var(--mu-radius-lg)] bg-[var(--mu-err)]/15 text-[var(--mu-err)]">
<Icon name="block" size={32} />
</div>
<div className="text-lg font-semibold">403 · 权限不足</div>
<p className="max-w-md text-sm text-[var(--mu-text-lo)]">
当前角色 <Tag tone="neutral">{role ?? "?"}</Tag>(viewer 只读)。管理面板需 <Tag tone="accent">owner</Tag> 或 <Tag tone="ok">admin</Tag> 角色;管理端写操作仅 owner。
+2 -1
View File
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { EmptyState, Tag, type TagTone } from "../components/primitives";
import { Icon } from "../components/Icon";
export interface PageHeaderProps {
title: string;
@@ -93,7 +94,7 @@ export function AdminTable<T>({
return <div className="px-4 py-10 text-center text-xs text-[var(--mu-err)]">{error}</div>;
}
if (!rows || rows.length === 0) {
return <EmptyState icon="🗂" title={emptyTitle} />;
return <EmptyState icon={<Icon name="folder" size={32} />} title={emptyTitle} />;
}
return (
<div className="overflow-x-auto">
+5 -2
View File
@@ -5,6 +5,7 @@ import { Notice, PageHeader } from "../components";
import { Button, EmptyState, Surface, Tag, type TagTone } from "../../components/primitives";
import { sendUp } from "../../lib/ws";
import { useAuditStore } from "../audit";
import { Icon } from "../../components/Icon";
const statusTone: Record<AgentStatus, { label: string; tone: TagTone }> = {
idle: { label: "空闲", tone: "neutral" },
@@ -33,7 +34,9 @@ function AgentCard({ agent, onKill }: { agent: AgentInfo; onKill: (id: string) =
<pre className="max-h-24 overflow-auto whitespace-pre-wrap rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-2 py-1.5 text-xs text-[var(--mu-text-mid)]">{agent.thinking}</pre>
) : null}
{agent.tools?.map((t, i) => (
<div key={i} className="text-xs text-[var(--mu-text-lo)]">⚙ {t.name}{t.ok === false ? " ✗" : ""}</div>
<div key={i} className="inline-flex items-center gap-1 text-xs text-[var(--mu-text-lo)]">
<Icon name="gear" size={12} />{t.name}{t.ok === false ? <Icon name="close" size={12} className="ml-0.5" /> : ""}
</div>
))}
{agent.message ? <div className="text-xs text-[var(--mu-text-mid)]">{agent.message}</div> : null}
<div className="flex gap-2">
@@ -96,7 +99,7 @@ export function AgentsModule(): React.ReactNode {
)}
{list.length === 0 ? (
<EmptyState icon="🧩" title={tab === "main" ? "无主会话 Agent" : "暂无子代理"} />
<EmptyState icon={<Icon name="agent" size={32} />} title={tab === "main" ? "无主会话 Agent" : "暂无子代理"} />
) : (
<div className="space-y-3">
{list.map((a) => (
@@ -4,6 +4,7 @@ import { adminListAutomations, isNotImplemented } from "../../lib/api";
import { useAuditStore } from "../audit";
import { Notice, PageHeader } from "../components";
import { Button, Surface, Switch, Tag } from "../../components/primitives";
import { Icon } from "../../components/Icon";
interface ReminderRow {
id: string;
@@ -67,7 +68,7 @@ export function AutomationsModule(): React.ReactNode {
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
{["消息队列事件", "后台任务(含终端任务)", "运行日志"].map((x) => (
<div key={x} className="rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)]/60 p-4 text-center text-xs text-[var(--mu-text-mid)]">
<div className="mb-2 text-lg">📦</div>
<div className="mb-2 flex justify-center"><Icon name="package" size={20} /></div>
{x} 占位 —— 后端 M10+ 提供
</div>
))}
+2 -1
View File
@@ -4,6 +4,7 @@ import { useAuditStore } from "../audit";
import { Notice, PageHeader } from "../components";
import { Button, EmptyState, Surface, Tag } from "../../components/primitives";
import type { BackthinkItem } from "../../lib/types";
import { Icon } from "../../components/Icon";
function BackthinkRow({ item }: { item: BackthinkItem }): React.ReactNode {
const markPushed = useAppStore((s) => s.markBackthinkPushed);
@@ -78,7 +79,7 @@ export function BackthinkModule(): React.ReactNode {
</div>
{items.length === 0 ? (
<EmptyState icon="🌙" title="暂无后台思考" hint="后台任务完成时按 WS 事件实时呈现(backthink / thinking)。" />
<EmptyState icon={<Icon name="night" size={32} />} title="暂无后台思考" hint="后台任务完成时按 WS 事件实时呈现(backthink / thinking)。" />
) : (
<div className="space-y-3">
{items.map((b) => (
+11 -10
View File
@@ -11,12 +11,13 @@ import { useAppStore } from "../../store/useAppStore";
import { navigateAdmin, type AdminSectionId } from "../router";
import { CardGrid, StatCard } from "../components";
import { Surface, Tag } from "../../components/primitives";
import { Icon, type IconName } from "../../components/Icon";
import { useAutoReload } from "../hooks";
interface DashCard {
id: AdminSectionId;
label: string;
icon: string;
icon: IconName;
value: number | string;
sub?: string;
tone: "ok" | "warn" | "accent" | "neutral" | "err";
@@ -52,16 +53,16 @@ export function DashboardModule(): React.ReactNode {
const runningAgents = agents.filter((a) => a.status === "running").length;
const cards: DashCard[] = [
{ id: "personas", label: "人格", icon: "🧬", value: nPersonas, sub: "启停/默认/路由", tone: "accent" },
{ id: "sessions", label: "活跃会话", icon: "💬", value: nSessions, sub: "渠道 × actor", tone: "ok" },
{ id: "memories", label: "记忆", icon: "🧠", value: nMemories, sub: "三类作用域隔离", tone: "accent" },
{ id: "plugins", label: "插件与能力", icon: "🔌", value: nPlugins, sub: "含 MCP/渠道", tone: "neutral" },
{ id: "channels", label: "渠道", icon: "📡", value: `${nChannels - offlineChannels}/${nChannels}`,
{ id: "personas", label: "人格", icon: "dna", value: nPersonas, sub: "启停/默认/路由", tone: "accent" },
{ id: "sessions", label: "活跃会话", icon: "chat", value: nSessions, sub: "渠道 × actor", tone: "ok" },
{ id: "memories", label: "记忆", icon: "memory", value: nMemories, sub: "三类作用域隔离", tone: "accent" },
{ id: "plugins", label: "插件与能力", icon: "plugin", value: nPlugins, sub: "含 MCP/渠道", tone: "neutral" },
{ id: "channels", label: "渠道", icon: "channel", value: `${nChannels - offlineChannels}/${nChannels}`,
sub: offlineChannels > 0 ? `${offlineChannels} 离线` : "全部在线", tone: offlineChannels > 0 ? "warn" : "ok" },
{ id: "iot", label: "IoT 设备", icon: "📲", value: `${onlineDevices}/${nDevices}`,
{ id: "iot", label: "IoT 设备", icon: "device", value: `${onlineDevices}/${nDevices}`,
sub: onlineDevices === nDevices && nDevices > 0 ? "全部在线" : "设备状态", tone: nDevices > 0 && onlineDevices === nDevices ? "ok" : "neutral" },
{ id: "agents", label: "Agent 运行", icon: "🧩", value: runningAgents, sub: `${agents.length} 个已登记`, tone: runningAgents > 0 ? "accent" : "neutral" },
{ id: "backthink", label: "后台思考活跃度", icon: "🌙", value: backthink.length, sub: "实时 (WS)", tone: backthink.length > 0 ? "warn" : "neutral" },
{ id: "agents", label: "Agent 运行", icon: "agent", value: runningAgents, sub: `${agents.length} 个已登记`, tone: runningAgents > 0 ? "accent" : "neutral" },
{ id: "backthink", label: "后台思考活跃度", icon: "night", value: backthink.length, sub: "实时 (WS)", tone: backthink.length > 0 ? "warn" : "neutral" },
];
return (
@@ -82,7 +83,7 @@ export function DashboardModule(): React.ReactNode {
<CardGrid>
{cards.map((c) => (
<button key={c.id} onClick={() => navigateAdmin(c.id)} className="text-left" title="点击下钻">
<StatCard icon={c.icon} label={c.label} value={c.value} sub={c.sub} tone={c.tone} />
<StatCard icon={<Icon name={c.icon} size={18} />} label={c.label} value={c.value} sub={c.sub} tone={c.tone} />
</button>
))}
</CardGrid>
@@ -3,6 +3,7 @@ import { useAppStore } from "../../store/useAppStore";
import { useAuditStore } from "../audit";
import { CardGrid, Notice, PageHeader, StatCard } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
import { Icon } from "../../components/Icon";
type Tab = "logs" | "trace" | "metrics" | "audit";
@@ -84,10 +85,10 @@ function MetricsView(): React.ReactNode {
return (
<div className="space-y-4">
<CardGrid>
<StatCard icon="⚙" label="请求 QPS" value="—" sub="网关指标占位" tone="accent" />
<StatCard icon="🤖" label="LLM 调用次数" value="—" sub="用量/费用占位" tone="neutral" />
<StatCard icon="🧩" label="子代理事件" value="—" sub="编排指标占位" tone="ok" />
<StatCard icon="🧠" label="工时/Token" value="—" sub="成本占位" tone="warn" />
<StatCard icon={<Icon name="gear" size={18} />} label="请求 QPS" value="—" sub="网关指标占位" tone="accent" />
<StatCard icon={<Icon name="robot" size={18} />} label="LLM 调用次数" value="—" sub="用量/费用占位" tone="neutral" />
<StatCard icon={<Icon name="agent" size={18} />} label="子代理事件" value="—" sub="编排指标占位" tone="ok" />
<StatCard icon={<Icon name="memory" size={18} />} label="工时/Token" value="—" sub="成本占位" tone="warn" />
</CardGrid>
<Notice>指标时序图 / 导出需后端 M10+ 提供。</Notice>
</div>
+7 -6
View File
@@ -3,6 +3,7 @@ import { getStoredRole } from "../../lib/api";
import { useAuditStore } from "../audit";
import { KeyValueRow, Notice, PageHeader } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
import { Icon } from "../../components/Icon";
export function SystemModule(): React.ReactNode {
const isOwner = getStoredRole() === "owner";
@@ -24,7 +25,7 @@ export function SystemModule(): React.ReactNode {
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Surface className="space-y-2">
<SectionTitle icon="🔐" title="安全 & 密钥" ownerOnly />
<SectionTitle icon={<Icon name="shield" size={18} />} title="安全 & 密钥" ownerOnly />
<KeyValueRow label="JWT 密钥" value="••••••••" mono />
<KeyValueRow label="鉴权" value="JWT + RBAC + API key" />
<KeyValueRow label="审计" value="who/what/when/result" />
@@ -35,7 +36,7 @@ export function SystemModule(): React.ReactNode {
</Surface>
<Surface className="space-y-2">
<SectionTitle icon="🗃" title="存储" />
<SectionTitle icon={<Icon name="storage" size={18} />} title="存储" />
<KeyValueRow label="默认" value="SQLite" />
<KeyValueRow label="可选增强" value="PG / pgvector(语义/向量路径)" />
<KeyValueRow label="单二进制 / 单端口" value=":8080 · 零跨进程 IPC" />
@@ -46,7 +47,7 @@ export function SystemModule(): React.ReactNode {
</Surface>
<Surface className="space-y-2">
<SectionTitle icon="📦" title="环境变量" />
<SectionTitle icon={<Icon name="package" size={18} />} title="环境变量" />
<KeyValueRow label="SERVER_HOST" value="0.0.0.0" mono />
<KeyValueRow label="SERVER_PORT" value="8080" mono />
<KeyValueRow label="AUTH_ADMIN_USER/PASS" value="••••••••" mono />
@@ -56,7 +57,7 @@ export function SystemModule(): React.ReactNode {
</Surface>
<Surface className="space-y-2">
<SectionTitle icon="🔄" title="运行时" />
<SectionTitle icon={<Icon name="refresh" size={18} />} title="运行时" />
<div className="flex items-center justify-between py-1.5">
<span className="text-xs text-[var(--mu-text-lo)]">日志级别</span>
<span className="flex gap-1.5">
@@ -85,10 +86,10 @@ export function SystemModule(): React.ReactNode {
);
}
function SectionTitle({ icon, title, ownerOnly = false }: { icon: string; title: string; ownerOnly?: boolean }): React.ReactNode {
function SectionTitle({ icon, title, ownerOnly = false }: { icon: React.ReactNode; title: string; ownerOnly?: boolean }): React.ReactNode {
return (
<h3 className="flex items-center gap-2 text-sm font-semibold">
<span>{icon}</span>
<span className="text-[var(--mu-accent)]">{icon}</span>
<span>{title}</span>
{ownerOnly ? <Tag tone="warn">owner</Tag> : null}
</h3>
+36 -3
View File
@@ -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"
+152
View File
@@ -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>
);
}
+22 -22
View File
@@ -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>
);
+19 -4
View File
@@ -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>
);
+63 -19
View File
@@ -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));
}
+3 -2
View File
@@ -9,6 +9,7 @@ import type {
ChatSyncResult,
LoginResult,
MemoryRecord,
Mode,
PersonaRecord,
Role,
SessionRecord,
@@ -154,10 +155,10 @@ export function chatSync(
}
/** POST /api/v1/chat with stream:true — triggers streaming over the WS channel. */
export function chatStream(sessionId: string, text: string): Promise<{ stream: true }> {
export function chatStream(sessionId: string, text: string, mode?: Mode): Promise<{ stream: true }> {
return request<{ stream: true }>("/chat", {
method: "POST",
body: JSON.stringify({ sessionId, text, stream: true }),
body: JSON.stringify({ sessionId, text, stream: true, mode }),
});
}
+3
View File
@@ -44,6 +44,9 @@ export interface ChatMessage {
id: string;
role: "user" | "assistant" | "system";
content: string;
/** Behavior mode this message was produced in — drives its bubble rendering.
* Each message carries its own mode, so a single session can mix bubble shapes. */
mode?: Mode;
streaming?: boolean;
thinking?: string;
mood?: string;
+12 -6
View File
@@ -2,14 +2,19 @@ import { useCallback, useState } from "react";
import { chatStream } from "./api";
import { connectWs, isWsOpen } from "./ws";
import { useAppStore } from "../store/useAppStore";
import { useModeStore } from "../store/useModeStore";
import { now } from "./util";
import type { Mode } from "./types";
/** 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
* 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
* mode (design/07 §1: shared message stream, per-message mode rendering). */
export function useSendChat(): {
send: (text: string) => Promise<void>;
send: (text: string, mode?: Mode) => Promise<void>;
sending: boolean;
} {
const [sending, setSending] = useState(false);
@@ -22,17 +27,18 @@ export function useSendChat(): {
const appendTaskLog = useAppStore((s) => s.appendTaskLog);
const send = useCallback(
async (text: string) => {
async (text: string, mode?: Mode) => {
const trimmed = text.trim();
if (!trimmed || sending) return;
setSending(true);
if (!isWsOpen()) connectWs();
addUserMessage(trimmed);
beginAssistant();
const m = mode ?? "chat";
addUserMessage(trimmed, m);
beginAssistant(m);
try {
await chatStream(sessionId, trimmed);
await chatStream(sessionId, trimmed, m);
} catch (err: unknown) {
// Backend loop not wired or gateway unreachable: show the failure inline
// rather than leaving a permanently-streaming bubble.
-144
View File
@@ -1,144 +0,0 @@
import { useAppStore } from "../store/useAppStore";
import { sendUp } from "../lib/ws";
import type { AgentInfo, AgentStatus, FlowGraph } from "../lib/types";
import { Button, EmptyState, Surface, Tag, type TagTone } from "../components/primitives";
const statusTone: Record<AgentStatus, { label: string; tone: TagTone }> = {
idle: { label: "空闲", tone: "neutral" },
running: { label: "运行中", tone: "accent" },
blocked: { label: "阻塞", tone: "warn" },
done: { label: "完成", tone: "ok" },
error: { label: "错误", tone: "err" },
};
function ProgressBar({ value }: { value: number }): React.ReactNode {
return (
<div className="h-1.5 w-full overflow-hidden rounded-[var(--mu-radius-pill)] bg-[var(--mu-surface-4)]">
<div
className="h-full rounded-[var(--mu-radius-pill)] bg-[var(--mu-accent)] transition-all duration-300 ease-[var(--mu-spring)]"
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
/>
</div>
);
}
function AgentCard({ agent }: { agent: AgentInfo }): React.ReactNode {
const meta = statusTone[agent.status] ?? statusTone.idle;
return (
<Surface className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-[var(--mu-accent)]" />
<span className="text-sm font-medium">{agent.name}</span>
{agent.role ? (
<span className="text-xs text-[var(--mu-text-lo)]">{agent.role}</span>
) : null}
<span className="ml-auto">
<Tag tone={meta.tone}>{meta.label}</Tag>
</span>
</div>
{typeof agent.progress === "number" ? (
<ProgressBar value={agent.progress} />
) : null}
{agent.thinking ? (
<pre className="max-h-24 overflow-auto whitespace-pre-wrap rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-2 py-1.5 text-xs text-[var(--mu-text-mid)]">
{agent.thinking}
</pre>
) : null}
{agent.tools?.map((t, i) => (
<div key={i} className="text-xs text-[var(--mu-text-lo)]">
⚙ {t.name}
{t.ok === false ? " ✗" : ""}
</div>
))}
{agent.message ? (
<div className="text-xs text-[var(--mu-text-mid)]">{agent.message}</div>
) : null}
</Surface>
);
}
function FlowGraphView({ graph }: { graph: FlowGraph | null }): React.ReactNode {
if (!graph) {
return (
<div className="rounded-[var(--mu-radius-lg)] border border-dashed border-[var(--mu-glass-border)] px-4 py-12 text-center text-xs text-[var(--mu-text-lo)]">
流程图占位 —— 连接实时通道后按 graph_update 事件渲染
</div>
);
}
return (
<div className="flex flex-wrap items-center gap-4 rounded-[var(--mu-radius-lg)] bg-[var(--mu-surface-2)]/40 p-4">
{graph.nodes.map((n, i) => (
<div key={n.id} className="flex items-center gap-4">
<Tag tone={n.status === "running" ? "accent" : "neutral"}>{n.label}</Tag>
{i < graph.nodes.length - 1 ? <span className="text-[var(--mu-text-lo)]">→</span> : null}
</div>
))}
</div>
);
}
export function AgentsMode(): React.ReactNode {
const agents = useAppStore((s) => s.agents);
const backthink = useAppStore((s) => s.backthink);
const flowGraph = useAppStore((s) => s.flowGraph);
const markPushed = useAppStore((s) => s.markBackthinkPushed);
return (
<div className="mx-auto max-w-3xl space-y-5 p-4">
<div>
<h2 className="text-lg font-semibold">Agent 运行监控</h2>
<p className="text-sm text-[var(--mu-text-lo)]">主会话 + 子代理实时状态</p>
</div>
<FlowGraphView graph={flowGraph} />
{agents.length === 0 ? (
<EmptyState icon="🧩" title="暂无 Agent" />
) : (
<div className="space-y-3">
{agents.map((a) => (
<AgentCard key={a.id} agent={a} />
))}
</div>
)}
<div>
<div className="mb-2 flex items-center gap-2">
<h3 className="text-sm font-semibold">后台思考</h3>
<span className="text-xs text-[var(--mu-text-lo)]">backthink</span>
</div>
{backthink.length === 0 ? (
<EmptyState icon="🌙" title="暂无后台思考" hint="后台任务完成时在此呈现触发源 / 结论 / 是否打扰" />
) : (
<div className="space-y-2">
{backthink.map((b) => (
<Surface key={b.id} className="flex items-center gap-2 text-sm">
<span className="text-[var(--mu-text-lo)]">{b.trigger}</span>
<Tag tone={b.shouldNotify ? "warn" : "neutral"}>
{b.shouldNotify ? "需要打扰" : "静默"}
</Tag>
<Tag tone={b.pushed ? "ok" : "neutral"}>{b.pushed ? "已推送" : "未推送"}</Tag>
{b.conclusion ? (
<span className="truncate text-[var(--mu-text-mid)]">{b.conclusion}</span>
) : null}
<span className="ml-auto">
<Button
size="sm"
variant="ghost"
disabled={b.pushed}
onClick={() => {
markPushed(b.id);
sendUp({ type: "task_output_query", data: { taskId: b.id } });
}}
>
{b.pushed ? "已推送" : "推送结果"}
</Button>
</span>
</Surface>
))}
</div>
)}
</div>
</div>
);
}
@@ -5,12 +5,19 @@ import { useSendChat } from "../lib/useChat";
import { MessageBubble } from "../components/chat";
import { ChatInput } from "../components/ChatInput";
import { EmptyState } from "../components/primitives";
import { Icon } from "../components/Icon";
export function ChatMode(): React.ReactNode {
/**
* 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
* mode — a session can mix chat / code / home / docs / work / vision shapes.
* The "input mode" chip above the composer sets the mode of the NEXT message.
*/
export function ChatShell(): React.ReactNode {
const messages = useAppStore((s) => s.messages);
const setMode = useModeStore((s) => s.setMode);
const { send, sending } = useSendChat();
const bottomRef = useRef<HTMLDivElement>(null);
const setMode = useModeStore((s) => s.setMode);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
@@ -22,9 +29,9 @@ export function ChatMode(): React.ReactNode {
<div className="mx-auto flex max-w-2xl flex-col gap-4 p-4">
{messages.length === 0 ? (
<EmptyState
icon="💬"
icon={<Icon name="chat" size={24} />}
title="开始与昔涟对话"
hint="模式只改变呈现层,会话上下文与记忆保持不变"
hint="模式只改变消息框的呈现方式,会话上下文与记忆保持不变"
/>
) : (
messages.map((m) => <MessageBubble key={m.id} message={m} />)
@@ -32,11 +39,7 @@ export function ChatMode(): React.ReactNode {
<div ref={bottomRef} />
</div>
</div>
<ChatInput
onSend={(t) => void send(t)}
sending={sending}
onVoice={() => setMode("voice")}
/>
<ChatInput onSend={(t, mode) => void send(t, mode)} sending={sending} onVoice={() => setMode("voice")} />
</div>
);
}
-74
View File
@@ -1,74 +0,0 @@
import { useEffect, useRef } from "react";
import { useAppStore } from "../store/useAppStore";
import { sendUp } from "../lib/ws";
import { Button, Surface } from "../components/primitives";
const SAMPLE_CODE = `// cyrene/example.ts — 示例文件
export async function greet(name: string): Promise<string> {
return \`你好,\${name}!\`;
}
// 在代码模式里,昔涟可在此展示/编辑代码、
// 运行测试、查看终端输出,全程与聊天共享同一会话上下文。`;
export function CodeMode(): React.ReactNode {
const taskLogs = useAppStore((s) => s.taskLogs);
const appendTaskLog = useAppStore((s) => s.appendTaskLog);
const termRef = useRef<HTMLPreElement>(null);
const mainLog = taskLogs["main"] ?? "";
const lines = mainLog.length === 0 ? [] : mainLog.split("\n").filter(Boolean);
useEffect(() => {
termRef.current?.scrollTo({ top: termRef.current.scrollHeight, behavior: "smooth" });
}, [lines.length, mainLog]);
const runDemo = () => {
const stamp = new Date().toLocaleTimeString();
appendTaskLog(
"main",
`[${stamp}] $ cyrene run task\n[${stamp}] ▶ 后台任务已启动(占位演示,无后端时仍能展示实时输出)\n[${stamp}] ✓ 完成,0 errors\n`
);
};
return (
<div className="mx-auto flex h-full max-w-6xl gap-3 p-4">
<Surface className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">代码</span>
<span className="text-xs text-[var(--mu-text-lo)]">example.ts</span>
<Button size="sm" variant="ghost" className="ml-auto">
运行测试
</Button>
</div>
<pre className="min-h-[16rem] flex-1 overflow-auto rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] p-3 text-xs leading-relaxed text-[var(--mu-text-mid)]">
{SAMPLE_CODE}
</pre>
</Surface>
<Surface className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">终端操作日志</span>
<span className="text-xs text-[var(--mu-text-lo)]">后台任务实时输出</span>
<span className="ml-auto flex gap-1.5">
<Button size="sm" variant="ghost" onClick={runDemo}>
运行 demo
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => sendUp({ type: "task_output_query", data: { taskId: "main" } })}
>
查询输出
</Button>
</span>
</div>
<pre
ref={termRef}
className="min-h-[16rem] flex-1 overflow-auto whitespace-pre-wrap rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] p-3 font-mono text-xs text-[var(--mu-text-mid)]"
>
{mainLog || "(暂无输出 —— 点「运行 demo」或等待后台任务)"}
</pre>
</Surface>
</div>
);
}
-61
View File
@@ -1,61 +0,0 @@
import { useState } from "react";
import { Surface, Tag, EmptyState } from "../components/primitives";
const DOCS = [
{ id: "d1", title: "设计 · 前端模态 UI(主题 H)", kind: "设计", updated: "09-06" },
{ id: "d2", title: "客户端 API 契约 v0.2", kind: "API", updated: "09-05" },
{ id: "d3", title: "角色 · 人格 · 行为模式", kind: "设计", updated: "09-05" },
{ id: "d4", title: "Miuix 设计基准", kind: "规范", updated: "09-04" },
];
export function DocsMode(): React.ReactNode {
const [query, setQuery] = useState("");
const [filtered, setFiltered] = useState(DOCS);
const search = () => {
const q = query.trim().toLowerCase();
setFiltered(q ? DOCS.filter((d) => d.title.toLowerCase().includes(q)) : DOCS);
};
return (
<div className="mx-auto max-w-3xl space-y-5 p-4">
<div>
<h2 className="text-lg font-semibold">知识库</h2>
<p className="text-sm text-[var(--mu-text-lo)]">检索 · 文档管理</p>
</div>
<div className="flex gap-2">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && search()}
placeholder="检索知识库…"
className="flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none placeholder:text-[var(--mu-text-lo)]"
/>
<button
onClick={search}
className="rounded-[var(--mu-radius)] bg-[var(--mu-accent)] px-4 py-2 text-sm font-medium text-white"
>
检索
</button>
</div>
{filtered.length === 0 ? (
<EmptyState icon="📚" title="未找到相关文档" />
) : (
<div className="space-y-2">
{filtered.map((d) => (
<Surface key={d.id} className="flex items-center gap-3">
<span className="text-lg">📄</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{d.title}</div>
<div className="text-xs text-[var(--mu-text-lo)]">更新于 {d.updated}</div>
</div>
<Tag tone="neutral">{d.kind}</Tag>
</Surface>
))}
</div>
)}
</div>
);
}
-93
View File
@@ -1,93 +0,0 @@
import { useState } from "react";
import { useAppStore } from "../store/useAppStore";
import { Surface, Switch, Tag, EmptyState } from "../components/primitives";
interface Device {
id: string;
name: string;
icon: string;
on: boolean;
room: string;
}
const INITIAL_DEVICES: Device[] = [
{ id: "light1", name: "客厅吊灯", icon: "💡", on: true, room: "客厅" },
{ id: "ac1", name: "主卧空调", icon: "❄️", on: false, room: "主卧" },
{ id: "plug1", name: "智能插座", icon: "🔌", on: false, room: "书房" },
];
export function HomeMode(): React.ReactNode {
const deviceUpdates = useAppStore((s) => s.deviceUpdates);
const [devices, setDevices] = useState<Device[]>(INITIAL_DEVICES);
const toggle = (id: string) =>
setDevices((ds) => ds.map((d) => (d.id === id ? { ...d, on: !d.on } : d)));
const active = devices.filter((d) => d.on).length;
return (
<div className="mx-auto max-w-3xl space-y-5 p-4">
<div>
<h2 className="text-lg font-semibold">家居</h2>
<p className="text-sm text-[var(--mu-text-lo)]">设备 · 场景 · 能耗 · 自动化</p>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<Surface>
<div className="text-xs text-[var(--mu-text-lo)]">在线设备</div>
<div className="text-2xl font-semibold">{devices.length}</div>
</Surface>
<Surface>
<div className="text-xs text-[var(--mu-text-lo)]">已开启</div>
<div className="text-2xl font-semibold">{active}</div>
</Surface>
<Surface>
<div className="text-xs text-[var(--mu-text-lo)]">今日能耗</div>
<div className="text-2xl font-semibold">2.4 kWh</div>
</Surface>
</div>
<div>
<h3 className="mb-2 text-sm font-semibold">设备</h3>
<div className="space-y-2">
{devices.map((d) => (
<Surface key={d.id} className="flex items-center gap-3">
<span className="text-xl">{d.icon}</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{d.name}</div>
<div className="text-xs text-[var(--mu-text-lo)]">{d.room}</div>
</div>
<Switch checked={d.on} onChange={() => toggle(d.id)} label={d.name} />
</Surface>
))}
</div>
</div>
<div>
<h3 className="mb-2 text-sm font-semibold">场景</h3>
<div className="flex flex-wrap gap-2">
{["回家", "观影", "睡眠", "离家"].map((s) => (
<Tag key={s} tone="accent">
{s}
</Tag>
))}
</div>
</div>
<div>
<h3 className="mb-2 text-sm font-semibold">实时设备事件</h3>
{deviceUpdates.length === 0 ? (
<EmptyState icon="📡" title="暂无设备事件" hint="连接实时通道后,device_update 事件会在此滚动" />
) : (
<Surface className="space-y-1.5">
{deviceUpdates.slice(0, 8).map((u, i) => (
<div key={i} className="text-xs text-[var(--mu-text-mid)]">
{u}
</div>
))}
</Surface>
)}
</div>
</div>
);
}
+9 -35
View File
@@ -1,38 +1,12 @@
import { useModeStore } from "../store/useModeStore";
import { ChatMode } from "./ChatMode";
import { CodeMode } from "./CodeMode";
import { HomeMode } from "./HomeMode";
import { WorkMode } from "./WorkMode";
import { DocsMode } from "./DocsMode";
import { VoiceMode } from "./VoiceMode";
import { VisionMode } from "./VisionMode";
import { AgentsMode } from "./AgentsMode";
import { SettingsMode } from "./SettingsMode";
import { ChatShell } from "./ChatShell";
/** Renders the active mode's presentation layer. The session context / messages
* / memory live in useAppStore and are untouched by these switches. */
/**
* User frontend is ONE shared page (design/07 §1): behavior modes no longer
* switch between nine distinct pages. Instead there is a single message stream
* and each message carries its own mode, so its bubble renders per that mode.
* Switching the input mode only shapes the next message. Settings live in the
* admin panel (design/10); agent progress follows the message stream.
*/
export function ModeRouter(): React.ReactNode {
const mode = useModeStore((s) => s.mode);
switch (mode) {
case "chat":
return <ChatMode />;
case "code":
return <CodeMode />;
case "home":
return <HomeMode />;
case "work":
return <WorkMode />;
case "docs":
return <DocsMode />;
case "voice":
return <VoiceMode />;
case "vision":
return <VisionMode />;
case "agents":
return <AgentsMode />;
case "settings":
return <SettingsMode />;
default:
return <ChatMode />;
}
return <ChatShell />;
}
-190
View File
@@ -1,190 +0,0 @@
import { useState } from "react";
import { useAppStore } from "../store/useAppStore";
import {
fetchPersonas,
fetchSessions,
getAuthToken,
login,
setAuthToken,
} from "../lib/api";
import { useAsyncData } from "../lib/useAsyncData";
import { connectWs, disconnectWs, sendUp } from "../lib/ws";
import { Button, Surface, Switch, Spinner, Tag } from "../components/primitives";
export function SettingsMode(): React.ReactNode {
const wsStatus = useAppStore((s) => s.wsStatus);
const [token, setToken] = useState<string | null>(getAuthToken());
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [authMsg, setAuthMsg] = useState<string | null>(null);
const [authErr, setAuthErr] = useState<string | null>(null);
const [authLoading, setAuthLoading] = useState(false);
const personas = useAsyncData(() => fetchPersonas(), [token]);
const sessions = useAsyncData(() => fetchSessions(), [token]);
const doLogin = () => {
if (!username || !password) return;
setAuthLoading(true);
setAuthErr(null);
setAuthMsg(null);
login(username, password)
.then((r) => {
setAuthToken(r.token);
setToken(r.token);
setAuthMsg(`已登录(${r.role}),令牌 ${Math.floor(r.expiresAt / 1000)} 到期`);
connectWs();
})
.catch((err: unknown) => {
setAuthErr(err instanceof Error ? err.message : "登录失败");
})
.finally(() => setAuthLoading(false));
};
const logout = () => {
setAuthToken(null);
setToken(null);
disconnectWs();
setAuthMsg(null);
};
const prefs = [
{ key: "a", label: "自动重连", on: true },
{ key: "b", label: "后台任务推送", on: true },
{ key: "c", label: "深色优先", on: true },
];
const [toggles, setToggles] = useState<Record<string, boolean>>(
Object.fromEntries(prefs.map((x) => [x.key, x.on]))
);
return (
<div className="mx-auto max-w-3xl space-y-5 p-4">
<div>
<h2 className="text-lg font-semibold">设置</h2>
<p className="text-sm text-[var(--mu-text-lo)]">系统 · 模型 · 插件 · 渠道</p>
</div>
{/* 连接 / 认证 */}
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">连接</h3>
<Tag tone={wsStatus === "connected" ? "ok" : "neutral"}>{wsStatus}</Tag>
<span className="ml-auto flex gap-1.5">
<Button size="sm" variant="ghost" onClick={connectWs}>
连接
</Button>
<Button size="sm" variant="ghost" onClick={disconnectWs}>
断开
</Button>
</span>
</div>
{token ? (
<div className="space-y-2">
<div className="text-xs text-[var(--mu-text-mid)]">已持有 JWT(localStorage)</div>
<Button size="sm" variant="outline" onClick={logout}>
退出登录
</Button>
</div>
) : (
<div className="space-y-2">
<div className="text-xs text-[var(--mu-text-lo)]">
凭据来自后端 config(POST /api/v1/auth/login)
</div>
<div className="flex gap-2">
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="用户名"
className="flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none"
/>
<input
value={password}
type="password"
onChange={(e) => setPassword(e.target.value)}
placeholder="密码"
className="flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none"
/>
<Button variant="primary" onClick={doLogin} disabled={authLoading}>
{authLoading ? <Spinner size={14} /> : "登录"}
</Button>
</div>
{authMsg ? <div className="text-xs text-[var(--mu-ok)]">{authMsg}</div> : null}
{authErr ? <div className="text-xs text-[var(--mu-err)]">{authErr}</div> : null}
</div>
)}
</Surface>
{/* 人格 */}
<Surface className="space-y-2">
<h3 className="text-sm font-semibold">人格(GET /api/v1/personas)</h3>
{personas.loading ? (
<Spinner label="加载中…" />
) : personas.error ? (
<div className="text-xs text-[var(--mu-err)]">{personas.error}</div>
) : (personas.data?.length ?? 0) === 0 ? (
<div className="text-xs text-[var(--mu-text-lo)]">无人格数据(后端未接线)</div>
) : (
<div className="space-y-1.5">
{personas.data?.map((p) => (
<div key={p.id} className="flex items-center gap-2 text-sm">
<span className="font-medium">{p.name}</span>
{p.isDefault ? <Tag tone="accent">默认</Tag> : null}
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">{p.character}</span>
</div>
))}
</div>
)}
</Surface>
{/* 会话 */}
<Surface className="space-y-2">
<h3 className="text-sm font-semibold">会话(GET /api/v1/sessions)</h3>
{sessions.loading ? (
<Spinner label="加载中…" />
) : sessions.error ? (
<div className="text-xs text-[var(--mu-err)]">{sessions.error}</div>
) : (sessions.data?.length ?? 0) === 0 ? (
<div className="text-xs text-[var(--mu-text-lo)]">无会话数据</div>
) : (
<div className="space-y-1.5">
{sessions.data?.map((s0) => (
<div key={s0.sessionId} className="flex items-center gap-2 text-sm">
<span>{s0.platform}</span>
<Tag tone="neutral">{s0.actorRole}</Tag>
{s0.isGroup ? <Tag tone="warn">群聊</Tag> : null}
<span className="ml-auto truncate text-xs text-[var(--mu-text-lo)]">
{s0.sessionId}
</span>
</div>
))}
</div>
)}
</Surface>
{/* 模型 / 插件 / 渠道 */}
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">模型 · 插件 · 渠道</h3>
{prefs.map((x) => (
<div key={x.key} className="flex items-center justify-between">
<span className="text-sm text-[var(--mu-text-mid)]">{x.label}</span>
<Switch
checked={toggles[x.key]}
onChange={(v) => setToggles((t) => ({ ...t, [x.key]: v }))}
label={x.label}
/>
</div>
))}
<div className="pt-1 text-xs text-[var(--mu-text-lo)]">
模型路由 / 降级、插件策略、渠道健康详见
<button
onClick={() => sendUp({ type: "mode_switch", data: { mode: "agents" } })}
className="ml-1 text-[var(--mu-accent-hi)]"
>
admin API
</button>
</div>
</Surface>
</div>
);
}
-82
View File
@@ -1,82 +0,0 @@
import { useRef, useState } from "react";
import { Button, Surface, EmptyState, Tag } from "../components/primitives";
export function VisionMode(): React.ReactNode {
const [preview, setPreview] = useState<string | null>(null);
const [analyzing, setAnalyzing] = useState(false);
const [tags, setTags] = useState<string[]>([]);
const fileRef = useRef<HTMLInputElement>(null);
const onFile = (file: File | undefined) => {
if (!file) return;
setPreview(URL.createObjectURL(file));
setTags([]);
};
const analyze = () => {
if (!preview) return;
setAnalyzing(true);
// 占位分析:真实实现会调用视觉模型,此处演示加载态并给出结果。
window.setTimeout(() => {
setAnalyzing(false);
setTags(["图像", "占位分析", "视觉模型待接入"]);
}, 900);
};
return (
<div className="mx-auto max-w-3xl space-y-5 p-4">
<div>
<h2 className="text-lg font-semibold">视觉</h2>
<p className="text-sm text-[var(--mu-text-lo)]">图像分析</p>
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => onFile(e.target.files?.[0])}
/>
<button
onClick={() => fileRef.current?.click()}
className="flex min-h-[16rem] w-full flex-col items-center justify-center gap-3 rounded-[var(--mu-radius-lg)] border border-dashed border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/40 p-4"
>
{preview ? (
<img src={preview} alt="预览" className="max-h-64 rounded-[var(--mu-radius)] object-contain" />
) : (
<EmptyState icon="👁" title="点击上传图片" hint="支持拖拽/点选,占位实现,视觉模型待接入" />
)}
</button>
<div className="flex items-center gap-2">
<Button variant="primary" onClick={analyze} disabled={!preview || analyzing}>
{analyzing ? "分析中…" : "开始分析"}
</Button>
{preview ? (
<Button
variant="ghost"
onClick={() => {
if (preview) URL.revokeObjectURL(preview);
setPreview(null);
setTags([]);
}}
>
清除
</Button>
) : null}
</div>
{tags.length > 0 ? (
<Surface className="flex flex-wrap items-center gap-2">
<span className="text-xs text-[var(--mu-text-lo)]">识别结果</span>
{tags.map((t) => (
<Tag key={t} tone="accent">
{t}
</Tag>
))}
</Surface>
) : null}
</div>
);
}
-72
View File
@@ -1,72 +0,0 @@
import { useEffect, useState } from "react";
import { Button, Surface } from "../components/primitives";
export function VoiceMode(): React.ReactNode {
const [calling, setCalling] = useState(false);
const [muted, setMuted] = useState(false);
const [secs, setSecs] = useState(0);
useEffect(() => {
if (!calling) return;
const id = window.setInterval(() => setSecs((s) => s + 1), 1000);
return () => window.clearInterval(id);
}, [calling]);
const mm = String(Math.floor(secs / 60)).padStart(2, "0");
const ss = String(secs % 60).padStart(2, "0");
const bars = Array.from({ length: 18 }, (_, i) => i);
return (
<div className="mx-auto flex h-full max-w-md flex-col items-center justify-center gap-6 p-4">
<div
className={`relative flex h-40 w-40 items-center justify-center rounded-full bg-[var(--mu-surface-4)] ${
calling ? "ring-2 ring-[var(--mu-accent)]" : ""
}`}
>
{/* live2D 预留位 */}
<span className="text-6xl opacity-80">🌙</span>
<span className="absolute bottom-2 text-xs text-[var(--mu-text-lo)]">live2D 预留</span>
</div>
{calling ? (
<div className="text-sm text-[var(--mu-text-mid)]">
通话中 · {mm}:{ss}
</div>
) : (
<div className="text-sm text-[var(--mu-text-lo)]">未在通话</div>
)}
{/* 波形 */}
<div className="flex h-10 items-end gap-1">
{bars.map((i) => (
<span
key={i}
className={`w-1 rounded-[var(--mu-radius-pill)] bg-[var(--mu-accent)] ${
calling ? "animate-pulse" : "opacity-30"
}`}
style={{ height: `${8 + ((i * 7) % 16)}px` }}
/>
))}
</div>
<Surface className="flex items-center gap-3">
<Button
variant={muted ? "danger" : "soft"}
onClick={() => setMuted((v) => !v)}
aria-label="静音"
>
{muted ? "🔇 已静音" : "🎙 静音"}
</Button>
<Button
variant={calling ? "danger" : "primary"}
onClick={() => {
setCalling((v) => !v);
if (calling) setSecs(0);
}}
>
{calling ? "挂断" : "开始通话"}
</Button>
</Surface>
</div>
);
}
-101
View File
@@ -1,101 +0,0 @@
import { useState } from "react";
import { Button, Surface, Tag, EmptyState } from "../components/primitives";
import { now } from "../lib/util";
import { sendUp } from "../lib/ws";
interface WorkItem {
id: string;
title: string;
done: boolean;
due?: string;
}
export function WorkMode(): React.ReactNode {
const [title, setTitle] = useState("");
const [items, setItems] = useState<WorkItem[]>([
{ id: "w1", title: "季度汇报", done: false, due: "明日" },
{ id: "w2", title: "阅读设计稿 07-前端", done: true },
]);
const add = () => {
const t = title.trim();
if (!t) return;
setItems((xs) => [...xs, { id: `w-${now()}`, title: t, done: false }]);
setTitle("");
};
const toggle = (id: string) =>
setItems((xs) => xs.map((x) => (x.id === id ? { ...x, done: !x.done } : x)));
return (
<div className="mx-auto max-w-3xl space-y-5 p-4">
<div>
<h2 className="text-lg font-semibold">工作</h2>
<p className="text-sm text-[var(--mu-text-lo)]">任务 · 日历 · 提醒 · 文档产出</p>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{[
["任务", String(items.filter((i) => !i.done).length)],
["日历", "今日 2"],
["提醒", "1 条"],
["文档", "4 篇"],
].map(([k, v]) => (
<Surface key={k}>
<div className="text-xs text-[var(--mu-text-lo)]">{k}</div>
<div className="text-2xl font-semibold">{v}</div>
</Surface>
))}
</div>
<div>
<h3 className="mb-2 text-sm font-semibold">任务清单</h3>
<div className="mb-3 flex gap-2">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && add()}
placeholder="新增任务…"
className="flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none placeholder:text-[var(--mu-text-lo)]"
/>
<Button variant="primary" onClick={add}>
添加
</Button>
</div>
{items.length === 0 ? (
<EmptyState icon="🗂" title="暂无任务" />
) : (
<div className="space-y-2">
{items.map((it) => (
<Surface key={it.id} className="flex items-center gap-3 py-3">
<button
onClick={() => toggle(it.id)}
className={`inline-flex h-5 w-5 items-center justify-center rounded-full border text-xs ${
it.done
? "border-transparent bg-[var(--mu-accent)] text-white"
: "border-[var(--mu-surface-5)]"
}`}
>
{it.done ? "✓" : ""}
</button>
<span
className={`flex-1 text-sm ${it.done ? "line-through text-[var(--mu-text-lo)]" : ""}`}
>
{it.title}
</span>
{it.due ? <Tag tone="warn">{it.due}</Tag> : null}
</Surface>
))}
</div>
)}
</div>
<div>
<h3 className="mb-2 text-sm font-semibold">提醒</h3>
<Button size="sm" variant="ghost" onClick={() => sendUp({ type: "mode_switch", data: { mode: "chat" } })}>
通过对话设置提醒
</Button>
</div>
</div>
);
}
+7 -4
View File
@@ -5,6 +5,7 @@ import type {
BackthinkItem,
ChatMessage,
FlowGraph,
Mode,
ToolUsed,
WsStatus,
} from "../lib/types";
@@ -32,8 +33,8 @@ export interface AppStore {
setContext: (patch: Partial<ChatContext>) => void;
setMood: (mood: string | null) => void;
addUserMessage: (text: string) => void;
beginAssistant: () => string;
addUserMessage: (text: string, mode?: Mode) => void;
beginAssistant: (mode?: Mode) => string;
appendChunk: (delta: string) => void;
endStream: () => void;
setThinking: (text: string) => void;
@@ -76,7 +77,7 @@ export const useAppStore = create<AppStore>()(
set((s) => ({ context: { ...s.context, ...patch } })),
setMood: (mood) => set({ mood }),
addUserMessage: (text) =>
addUserMessage: (text, mode) =>
set((s) => ({
messages: [
...s.messages,
@@ -84,12 +85,13 @@ export const useAppStore = create<AppStore>()(
id: uid(),
role: "user",
content: text,
mode,
createdAt: now(),
},
],
})),
beginAssistant: () => {
beginAssistant: (mode) => {
const id = uid();
set((s) => ({
messages: [
@@ -98,6 +100,7 @@ export const useAppStore = create<AppStore>()(
id,
role: "assistant",
content: "",
mode,
streaming: true,
tools: [],
createdAt: now(),