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
@@ -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。
|
||||
|
||||
@@ -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,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>
|
||||
))}
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user