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

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

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#161616" />
<meta name="description" content="Cyrene v2 用户前端(M9 模态 UI)" />
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<title>Cyrene · 昔涟</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2490
View File
File diff suppressed because it is too large. Load diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "cyrene-web",
"private": true,
"version": "0.2.0",
"type": "module",
"description": "Cyrene v2 user frontend (M9 modal UI) — Miuix design language",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zustand": "^5.0.2"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.6",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.6",
"typescript": "^5.7.2",
"vite": "^6.0.0"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48" height="48">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#3d6bff" />
<stop offset="1" stop-color="#6a8dff" />
</linearGradient>
</defs>
<rect x="4" y="4" width="40" height="40" rx="12" fill="url(#g)" />
<circle cx="24" cy="24" r="10" fill="#0c0c0c" />
<circle cx="24" cy="24" r="4" fill="#f5f5f7" />
</svg>

After

Width:  |  Height:  |  Size: 452 B

+18
View File
@@ -0,0 +1,18 @@
{
"name": "Cyrene · 昔涟",
"short_name": "Cyrene",
"description": "Cyrene v2 用户前端(M9 模态 UI)",
"start_url": "/",
"display": "standalone",
"background_color": "#0c0c0c",
"theme_color": "#161616",
"orientation": "any",
"icons": [
{
"src": "/icon.svg",
"type": "image/svg+xml",
"sizes": "any",
"purpose": "any maskable"
}
]
}
+62
View File
@@ -0,0 +1,62 @@
/* Cyrene v2 PWA service worker — core-resource caching + offline shell. */
const CACHE = "cyrene-web-v1";
const PRECACHE = ["/", "/index.html", "/manifest.json", "/icon.svg"];
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE)
.then((cache) => cache.addAll(PRECACHE))
.then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
)
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return; // let API/WS pass through
// Network-first for document navigations so the shell stays fresh, falling
// back to cached index.html when offline.
if (req.mode === "navigate") {
event.respondWith(
fetch(req)
.then((res) => {
const copy = res.clone();
caches.open(CACHE).then((cache) => cache.put("/index.html", copy));
return res;
})
.catch(() => caches.match("/index.html"))
);
return;
}
// Cache-first for static assets (hashed by Vite) with background refresh.
event.respondWith(
caches.match(req).then((cached) => {
const fetched = fetch(req)
.then((res) => {
if (res && res.status === 200) {
const copy = res.clone();
caches.open(CACHE).then((cache) => cache.put(req, copy));
}
return res;
})
.catch(() => cached);
return cached || fetched;
})
);
});
+30
View File
@@ -0,0 +1,30 @@
import { useEffect } from "react";
import { TopBar } from "./components/TopBar";
import { Sidebar, BottomNav } from "./components/Nav";
import { ModeRouter } from "./modes/ModeRouter";
import { connectWs, disconnectWs } from "./lib/ws";
import { initTheme } from "./lib/theme";
import { getAuthToken } from "./lib/api";
export default function App(): React.ReactNode {
// Apply the persisted (dark-first) theme and open the realtime channel only
// when we hold a token — otherwise the gateway rejects the handshake with 401.
useEffect(() => {
initTheme();
if (getAuthToken()) connectWs();
return () => disconnectWs();
}, []);
return (
<div className="flex h-full flex-col">
<TopBar />
<div className="flex min-h-0 flex-1">
<Sidebar />
<main className="relative flex-1 overflow-y-auto pb-[var(--mu-bottomnav-h)] md:pb-0">
<ModeRouter />
</main>
</div>
<BottomNav />
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "./primitives";
export interface ChatInputProps {
onSend: (text: string) => void;
sending: boolean;
onVoice: () => void;
placeholder?: string;
}
export function ChatInput({
onSend,
sending,
onVoice,
placeholder = "给昔涟发消息…",
}: ChatInputProps): React.ReactNode {
const [value, setValue] = useState("");
const taRef = useRef<HTMLTextAreaElement>(null);
// auto-grow the textarea up to a max height
useEffect(() => {
const el = taRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 140) + "px";
}, [value]);
const submit = () => {
const t = value.trim();
if (!t || sending) return;
onSend(t);
setValue("");
};
return (
<div className="glass sticky bottom-0 border-t border-[var(--mu-glass-border)] p-3 md:p-4">
<div className="mx-auto flex max-w-2xl items-end gap-2 rounded-[var(--mu-radius-lg)] bg-[var(--mu-surface-2)] p-2 shadow-[var(--mu-shadow-1)]">
<textarea
ref={taRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
rows={1}
placeholder={placeholder}
className="max-h-[140px] flex-1 resize-none bg-transparent px-2 py-2 text-sm text-[var(--mu-text-hi)] outline-none placeholder:text-[var(--mu-text-lo)]"
/>
<button
onClick={onVoice}
aria-label="语音"
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--mu-radius)] bg-[var(--mu-surface-4)] text-base transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-5)] active:scale-95"
>
🎙
</button>
<Button
variant="primary"
onClick={submit}
disabled={sending || value.trim().length === 0}
className="shrink-0"
>
{sending ? "发送中…" : "发送"}
</Button>
</div>
</div>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useModeStore } from "../store/useModeStore";
import { sendUp } from "../lib/ws";
import { MODES, type Mode } from "../lib/types";
const modeMeta: Record<Mode, { label: string; icon: string }> = {
chat: { label: "对话", icon: "💬" },
code: { label: "代码", icon: "🖥" },
home: { label: "家居", icon: "🏠" },
work: { label: "工作", icon: "🗂" },
docs: { label: "知识库", icon: "📚" },
voice: { label: "语音", icon: "🎙" },
vision: { label: "视觉", icon: "👁" },
agents: { label: "子代理", icon: "🧩" },
settings: { label: "设置", icon: "⚙" },
};
function useSwitchMode(): (m: Mode) => void {
const setMode = useModeStore((s) => s.setMode);
const setSelfMode = useModeStore((s) => s.setSelfMode);
return (m: Mode) => {
setMode(m);
setSelfMode(null);
sendUp({ type: "mode_switch", data: { mode: m } });
};
}
export function Sidebar(): React.ReactNode {
const mode = useModeStore((s) => s.mode);
const selfMode = useModeStore((s) => s.selfMode);
const switchMode = useSwitchMode();
return (
<nav className="glass hidden h-full w-[var(--mu-sidebar-w)] shrink-0 flex-col gap-1 border-r border-[var(--mu-glass-border)] p-3 md:flex">
<div className="px-2 py-2 text-xs font-semibold uppercase tracking-wider text-[var(--mu-text-lo)]">
行为模式
</div>
{MODES.map((m) => {
const meta = modeMeta[m];
const active = m === mode;
return (
<button
key={m}
onClick={() => switchMode(m)}
className={`flex items-center gap-2.5 rounded-[var(--mu-radius)] px-3 py-2 text-sm transition-all duration-200 ease-[var(--mu-spring)] active:scale-[0.98] ${
active
? "bg-[var(--mu-accent)]/15 font-medium text-[var(--mu-accent-hi)]"
: "text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-4)]"
}`}
>
<span className="text-base">{meta.icon}</span>
<span>{meta.label}</span>
{m === selfMode ? (
<span className="ml-auto rounded-full bg-[var(--mu-warn)]/15 px-1.5 py-0.5 text-[10px] text-[var(--mu-warn)]">
自切
</span>
) : null}
</button>
);
})}
</nav>
);
}
export function BottomNav(): React.ReactNode {
const mode = useModeStore((s) => s.mode);
const switchMode = useSwitchMode();
return (
<nav className="glass fixed inset-x-0 bottom-0 z-30 flex h-[var(--mu-bottomnav-h)] items-stretch border-t border-[var(--mu-glass-border)] md:hidden">
{MODES.slice(0, 5).map((m) => {
const meta = modeMeta[m];
const active = m === mode;
return (
<button
key={m}
onClick={() => switchMode(m)}
className={`flex flex-1 flex-col items-center justify-center gap-0.5 text-[11px] transition-colors duration-200 ${
active ? "text-[var(--mu-accent-hi)]" : "text-[var(--mu-text-lo)]"
}`}
>
<span className="text-base">{meta.icon}</span>
<span>{meta.label}</span>
</button>
);
})}
</nav>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { useState } from "react";
import { useAppStore } from "../store/useAppStore";
import { useModeStore } from "../store/useModeStore";
import { applyTheme, readTheme, type ThemeMode } from "../lib/theme";
import type { WsStatus } from "../lib/types";
import { Tag } from "./primitives";
const wsMeta: Record<WsStatus, { label: string; tone: "ok" | "warn" | "neutral" | "err" }> = {
idle: { label: "未连接", tone: "neutral" },
connecting: { label: "连接中", tone: "warn" },
connected: { label: "实时", tone: "ok" },
reconnecting: { label: "重连中", tone: "warn" },
disconnected: { label: "已断开", tone: "err" },
};
export function TopBar(): React.ReactNode {
const wsStatus = useAppStore((s) => s.wsStatus);
const mood = useAppStore((s) => s.mood);
const mode = useModeStore((s) => s.mode);
const [theme, setTheme] = useState<ThemeMode>(readTheme);
const toggleTheme = () => {
const next: ThemeMode = theme === "dark" ? "light" : "dark";
applyTheme(next);
setTheme(next);
};
const meta = wsMeta[wsStatus];
return (
<header className="glass sticky top-0 z-30 flex h-[var(--mu-topbar-h)] items-center gap-3 border-b border-[var(--mu-glass-border)] px-4">
<div className="flex items-center gap-2">
<img src="/icon.svg" alt="Cyrene" className="h-7 w-7" />
<div className="leading-tight">
<div className="text-sm font-semibold">Cyrene · 昔涟</div>
<div className="text-[11px] text-[var(--mu-text-lo)]">{mode} 模式</div>
</div>
</div>
<div className="flex-1" />
{mood ? <Tag tone="accent">心情 · {mood}</Tag> : null}
<Tag tone={meta.tone}>
<span className={`inline-block h-1.5 w-1.5 rounded-full ${wsStatus === "connected" ? "bg-[var(--mu-ok)]" : "mu-blink"}`} />
{meta.label}
</Tag>
<button
onClick={toggleTheme}
aria-label="切换主题"
className="inline-flex h-8 w-8 items-center justify-center rounded-[var(--mu-radius)] bg-[var(--mu-surface-4)] text-sm transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-5)] active:scale-95"
>
{theme === "dark" ? "☀" : "☾"}
</button>
</header>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { useState } from "react";
import type { ChatMessage, ToolUsed } from "../lib/types";
import { Tag } from "./primitives";
/** Collapsible inline chain-of-thought block. */
export function ThinkingBlock({
text,
streaming,
}: {
text?: string;
streaming?: boolean;
}): React.ReactNode | null {
const [open, setOpen] = useState(false);
if (!text && !streaming) return null;
return (
<div className="mb-2 overflow-hidden rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/60">
<button
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-xs text-[var(--mu-text-lo)]"
>
<span
className={`inline-block transition-transform duration-200 ${open ? "rotate-90" : ""}`}
>
▸
</span>
<span>思维链</span>
{streaming && !text ? <span className="mu-blink">思考中…</span> : null}
</button>
{open && text ? (
<pre className="max-h-40 overflow-auto whitespace-pre-wrap px-3 pb-2 text-xs leading-relaxed text-[var(--mu-text-mid)]">
{text}
</pre>
) : null}
</div>
);
}
/** Tool echo card (工具回显). */
export function ToolCard({ tool }: { tool: ToolUsed }): React.ReactNode {
const [open, setOpen] = useState(false);
const status = tool.ok === undefined ? "neutral" : tool.ok ? "ok" : "err";
return (
<div className="mb-2 overflow-hidden rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/60">
<button
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs"
>
<span
className={`inline-block transition-transform duration-200 ${open ? "rotate-90" : ""}`}
>
▸
</span>
<Tag tone={status}>
{tool.ok === false ? "✗" : tool.ok === true ? "✓" : "⚙"} {tool.name}
</Tag>
{tool.summary ? (
<span className="truncate text-[var(--mu-text-lo)]">{tool.summary}</span>
) : null}
</button>
{(open || tool.detail) && tool.detail ? (
<pre className="max-h-40 overflow-auto whitespace-pre-wrap px-3 pb-2 text-xs text-[var(--mu-text-mid)]">
{tool.detail}
</pre>
) : null}
</div>
);
}
/** A single chat message bubble. */
export function MessageBubble({ message }: { message: ChatMessage }): React.ReactNode {
const isUser = message.role === "user";
if (isUser) {
return (
<div className="mu-rise-in flex justify-end">
<div className="max-w-[80%] whitespace-pre-wrap rounded-[var(--mu-radius-lg)] bg-[var(--mu-accent)] px-4 py-2.5 text-sm text-white shadow-[var(--mu-shadow-1)]">
{message.content}
</div>
</div>
);
}
return (
<div className="mu-rise-in flex gap-3">
<img src="/icon.svg" alt="" className="mt-1 h-7 w-7 shrink-0" />
<div className="min-w-0 max-w-[85%]">
{message.mood ? (
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {message.mood}</div>
) : null}
<ThinkingBlock text={message.thinking} streaming={message.streaming} />
{message.tools?.map((t, i) => <ToolCard key={i} tool={t} />)}
<div className="rounded-[var(--mu-radius-lg)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">
{message.content ? (
<span className="whitespace-pre-wrap">{message.content}</span>
) : message.streaming ? (
<span className="mu-blink text-[var(--mu-text-lo)]">生成中…</span>
) : null}
</div>
</div>
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
/** Miuix-style primitives: Button / Tag / Switch / Spinner / Surface / Empty.
* Springs are applied only to interactive elements (cubic-bezier(0.34,1.56,0.64,1)). */
type ButtonVariant = "primary" | "soft" | "ghost" | "outline" | "danger";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: "sm" | "md";
children: ReactNode;
}
const buttonVariant: Record<ButtonVariant, string> = {
primary: "bg-[var(--mu-accent)] text-white hover:bg-[var(--mu-accent-hi)] shadow-[var(--mu-shadow-2)]",
soft: "bg-[var(--mu-surface-4)] text-[var(--mu-text-hi)] hover:bg-[var(--mu-surface-5)]",
ghost: "text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-4)]",
outline:
"border border-[var(--mu-glass-border)] text-[var(--mu-text-hi)] hover:bg-[var(--mu-surface-4)]",
danger: "bg-[var(--mu-err)] text-white",
};
export function Button({
variant = "soft",
size = "md",
className = "",
children,
...rest
}: ButtonProps): React.ReactNode {
const pad = size === "sm" ? "px-2.5 py-1 text-xs" : "px-4 py-2 text-sm";
return (
<button
className={`inline-flex items-center justify-center gap-1.5 font-medium transition-all duration-200 ease-[var(--mu-spring)] active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none ${pad} ${buttonVariant[variant]} ${className}`}
{...rest}
>
{children}
</button>
);
}
export type TagTone = "accent" | "ok" | "warn" | "err" | "neutral";
export interface TagProps {
tone?: TagTone;
children: ReactNode;
className?: string;
}
const tagTone: Record<TagTone, string> = {
accent: "bg-[var(--mu-accent)]/15 text-[var(--mu-accent-hi)]",
ok: "bg-[var(--mu-ok)]/15 text-[var(--mu-ok)]",
warn: "bg-[var(--mu-warn)]/15 text-[var(--mu-warn)]",
err: "bg-[var(--mu-err)]/15 text-[var(--mu-err)]",
neutral: "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)]",
};
export function Tag({ tone = "neutral", children, className = "" }: TagProps): React.ReactNode {
return (
<span
className={`inline-flex items-center gap-1 rounded-[var(--mu-radius-sm)] px-2 py-0.5 text-xs font-medium ${tagTone[tone]} ${className}`}
>
{children}
</span>
);
}
export interface SwitchProps {
checked: boolean;
onChange: (next: boolean) => void;
disabled?: boolean;
label?: string;
}
export function Switch({ checked, onChange, disabled, label }: SwitchProps): React.ReactNode {
return (
<label
className={`flex items-center gap-2 select-none ${disabled ? "opacity-50 pointer-events-none" : ""}`}
>
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={() => onChange(!checked)}
className={`relative h-6 w-11 shrink-0 rounded-[var(--mu-radius-pill)] transition-colors duration-200 ${
checked ? "bg-[var(--mu-accent)]" : "bg-[var(--mu-surface-5)]"
}`}
>
<span
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow-md transition-transform duration-300 ease-[var(--mu-spring)] ${
checked ? "translate-x-[22px]" : "translate-x-0.5"
}`}
/>
</button>
{label ? <span className="text-sm text-[var(--mu-text-mid)]">{label}</span> : null}
</label>
);
}
export interface SpinnerProps {
size?: number;
label?: string;
}
export function Spinner({ size = 18, label }: SpinnerProps): React.ReactNode {
return (
<span className="inline-flex items-center gap-2 text-[var(--mu-text-lo)]">
<span
className="inline-block animate-spin rounded-full border-2 border-[var(--mu-surface-5)] border-t-[var(--mu-accent)]"
style={{ width: size, height: size }}
/>
{label ? <span className="text-xs">{label}</span> : null}
</span>
);
}
export interface SurfaceProps extends HTMLAttributes<HTMLDivElement> {
glass?: boolean;
liquid?: boolean;
gap?: "none" | "sm" | "md";
children: ReactNode;
}
export function Surface({
glass = false,
liquid = false,
gap = "md",
className = "",
children,
...rest
}: SurfaceProps): React.ReactNode {
const pad = gap === "none" ? "" : gap === "sm" ? "p-3" : "p-5";
const base = glass
? "glass"
: liquid
? "liquid-glass"
: "bg-[var(--mu-surface-3)] border border-[var(--mu-glass-border)] shadow-[var(--mu-shadow-1)]";
return (
<div className={`rounded-[var(--mu-radius-lg)] ${base} ${pad} ${className}`} {...rest}>
{children}
</div>
);
}
export interface EmptyStateProps {
icon?: ReactNode;
title: string;
hint?: string;
}
export function EmptyState({ icon, title, hint }: EmptyStateProps): React.ReactNode {
return (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
{icon ? <div className="text-3xl opacity-60">{icon}</div> : null}
<div className="text-sm font-medium text-[var(--mu-text-mid)]">{title}</div>
{hint ? <div className="text-xs text-[var(--mu-text-lo)]">{hint}</div> : null}
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
/*
* Cyrene v2 — Miuix design tokens (source of truth).
*
* 六大核心(Miuix / HyperOS):
* 1. Smooth Corner —— 贝塞尔连续曲率 k=0.55 的圆角(--mu-radius-*)
* 2. 多层 Surface —— 5 级色阶 #161616 → #0C0C0C → #1C1C1C → #242424 → #2C2C2C
* 3. 弹簧动效 —— cubic-bezier(0.34,1.56,0.64,1)(仅交互,--mu-spring)
* 4. 深色优先 —— 默认深色,Light/Dark 无感切换(--mu-light 覆盖)
* 5. 毛玻璃 —— backdrop-filter(--mu-glass-*)
* 6. Liquid Glass —— 液体玻璃寓意(折射/内高光)
*/
:root {
color-scheme: dark;
/* 多层 Surface — 5 级色阶(深色基准) */
--mu-surface-1: #161616;
--mu-surface-2: #0c0c0c;
--mu-surface-3: #1c1c1c;
--mu-surface-4: #242424;
--mu-surface-5: #2c2c2c;
/* 文本层级 */
--mu-text-hi: #f5f5f7;
--mu-text-mid: #b4b4b8;
--mu-text-lo: #7a7a80;
--mu-text-disabled: #55555c;
/* 品牌 / 强调色(HyperOS 风格中性偏暖) */
--mu-accent: #3d6bff;
--mu-accent-hi: #6a8dff;
--mu-ok: #35c26b;
--mu-warn: #ff9f43;
--mu-err: #ff5c5c;
/* Smooth Corner:k=0.55 —— 直线到圆弧连续过渡的半径近似 */
--mu-radius-xs: 0.375rem; /* 6px */
--mu-radius-sm: 0.5rem; /* 8px */
--mu-radius: 0.625rem; /* 10px —— 组件主体 */
--mu-radius-lg: 1rem; /* 16px —— 卡片/弹层 */
--mu-radius-xl: 1.5rem; /* 24px —— 大容器 */
--mu-radius-pill: 62.5rem; /* 全圆角胶囊 */
/* 弹簧动效:仅用于 Switch / BottomNav / Sidebar 等交互 */
--mu-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--mu-ease: cubic-bezier(0.22, 1, 0.36, 1); /* 标准过渡 */
--mu-dur: 240ms;
--mu-dur-slow: 420ms;
/* 毛玻璃 */
--mu-glass-blur: 18px;
--mu-glass-alpha: 0.66;
--mu-glass-border: rgba(255, 255, 255, 0.08);
/* Liquid Glass 寓意:内高光 / 折射描边 */
--mu-liquid-highlight: rgba(255, 255, 255, 0.14);
--mu-liquid-rim: rgba(255, 255, 255, 0.06);
/* 间距节奏 */
--mu-space-1: 0.25rem;
--mu-space-2: 0.5rem;
--mu-space-3: 0.75rem;
--mu-space-4: 1rem;
--mu-space-5: 1.5rem;
--mu-space-6: 2rem;
--mu-space-8: 3rem;
/* 布局 */
--mu-topbar-h: 3.25rem;
--mu-sidebar-w: 15rem;
--mu-bottomnav-h: 4rem;
/* 阴影(深度) */
--mu-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.3);
--mu-shadow-2: 0 6px 16px rgba(0, 0, 0, 0.35);
--mu-shadow-3: 0 16px 40px rgba(0, 0, 0, 0.4);
--mu-shadow-pop: 0 24px 64px rgba(0, 0, 0, 0.5);
}
/* Light 无感切换:仅覆盖表面/文本,保留同一套角/动效/玻璃 */
.theme-light {
color-scheme: light;
--mu-surface-1: #f6f6f8;
--mu-surface-2: #ececef;
--mu-surface-3: #ffffff;
--mu-surface-4: #e4e4e8;
--mu-surface-5: #dcdce1;
--mu-text-hi: #17171a;
--mu-text-mid: #4a4a52;
--mu-text-lo: #84848c;
--mu-text-disabled: #b9b9c0;
--mu-glass-alpha: 0.72;
--mu-glass-border: rgba(0, 0, 0, 0.06);
--mu-liquid-highlight: rgba(255, 255, 255, 0.6);
--mu-liquid-rim: rgba(255, 255, 255, 0.7);
}
+117
View File
@@ -0,0 +1,117 @@
import type {
ApiErrorShape,
ChatSyncResult,
LoginResult,
PersonaRecord,
SessionRecord,
} from "./types";
/**
* REST client for the Cyrene v2 gateway. The gateway wraps every success in
* `{data}` and every failure in `{error:{code,message}}` (Docs/api-client.md).
* We use same-origin relative paths so the same code works behind the Vite dev
* proxy and inside the single-binary gateway on :8080.
*/
export class ApiError extends Error {
readonly status: number;
readonly code?: string;
constructor(status: number, code: string | undefined, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
}
}
const API_BASE = "/api/v1";
const TOKEN_KEY = "cyrene.jwt";
export function getAuthToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setAuthToken(token: string | null): void {
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
}
interface Envelope {
data?: unknown;
error?: ApiErrorShape;
}
async function request<T>(
path: string,
init: RequestInit = {}
): Promise<T> {
const headers = new Headers(init.headers);
headers.set("Content-Type", "application/json");
headers.set("X-API-Version", "1");
const token = getAuthToken();
if (token) headers.set("Authorization", `Bearer ${token}`);
let res: Response;
try {
res = await fetch(`${API_BASE}${path}`, { ...init, headers });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "网络请求失败";
throw new ApiError(0, "network_error", msg);
}
let body: Envelope;
try {
body = (await res.json()) as Envelope;
} catch {
throw new ApiError(res.status, "bad_response", "服务端返回了无法解析的响应");
}
if (!res.ok || body.error) {
throw new ApiError(
res.status,
body.error?.code ?? "unknown",
body.error?.message ?? `请求失败(${res.status})`
);
}
return body.data as T;
}
/** POST /api/v1/auth/login — exchanges user/password for a JWT. */
export function login(username: string, password: string): Promise<LoginResult> {
return request<LoginResult>("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
}
/** GET /api/v1/health — liveness probe. */
export function health(): Promise<{ status: string }> {
return request<{ status: string }>("/health");
}
/** POST /api/v1/chat — one synchronous agent turn. */
export function chatSync(
sessionId: string,
text: string
): Promise<ChatSyncResult> {
return request<ChatSyncResult>("/chat", {
method: "POST",
body: JSON.stringify({ sessionId, text }),
});
}
/** POST /api/v1/chat with stream:true — triggers streaming over the WS channel. */
export function chatStream(sessionId: string, text: string): Promise<{ stream: true }> {
return request<{ stream: true }>("/chat", {
method: "POST",
body: JSON.stringify({ sessionId, text, stream: true }),
});
}
/** GET /api/v1/sessions — known channel sessions. */
export function fetchSessions(): Promise<SessionRecord[]> {
return request<SessionRecord[]>("/sessions");
}
/** GET /api/v1/personas — persona summaries. */
export function fetchPersonas(): Promise<PersonaRecord[]> {
return request<PersonaRecord[]>("/personas");
}
+22
View File
@@ -0,0 +1,22 @@
export type ThemeMode = "dark" | "light";
const THEME_KEY = "cyrene.theme";
/** Read the persisted theme, defaulting to dark-first (Miuix). */
export function readTheme(): ThemeMode {
const saved = localStorage.getItem(THEME_KEY);
return saved === "light" ? "light" : "dark";
}
/** Apply a theme by toggling the `.theme-light` class on <html>. */
export function applyTheme(theme: ThemeMode): void {
document.documentElement.classList.toggle("theme-light", theme === "light");
localStorage.setItem(THEME_KEY, theme);
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute("content", theme === "light" ? "#f6f6f8" : "#161616");
}
/** Apply the persisted theme on app start. */
export function initTheme(): void {
applyTheme(readTheme());
}
+162
View File
@@ -0,0 +1,162 @@
// Shared wire & UI model types for the Cyrene v2 user frontend (M9).
// Strict mode: no `any`; unknown payloads are narrowed at the parse boundary.
/** The 9 behavior modes. Switching only changes the presentation layer. */
export type Mode =
| "chat"
| "code"
| "home"
| "work"
| "docs"
| "voice"
| "vision"
| "agents"
| "settings";
export const MODES: ReadonlyArray<Mode> = [
"chat",
"code",
"home",
"work",
"docs",
"voice",
"vision",
"agents",
"settings",
];
export type WsStatus =
| "idle"
| "connecting"
| "connected"
| "disconnected"
| "reconnecting";
export interface ToolUsed {
name: string;
summary?: string;
ok?: boolean;
detail?: string;
at: number;
}
export interface ChatMessage {
id: string;
role: "user" | "assistant" | "system";
content: string;
streaming?: boolean;
thinking?: string;
mood?: string;
tools?: ToolUsed[];
createdAt: number;
}
export type AgentStatus =
| "idle"
| "running"
| "blocked"
| "done"
| "error";
export interface AgentInfo {
id: string;
name: string;
role?: string;
mode?: string;
status: AgentStatus;
progress?: number;
thinking?: string;
tools?: ToolUsed[];
message?: string;
}
export interface SubagentEventPayload {
id: string;
name?: string;
state?: string;
status?: AgentStatus;
progress?: number;
message?: string;
thinking?: string;
tool?: ToolUsed;
}
/** A "backthink" (后台思考) item — agent reasoning done in the background. */
export interface BackthinkItem {
id: string;
trigger: string;
conclusion?: string;
shouldNotify?: boolean;
pushed?: boolean;
at: number;
}
export interface FlowNode {
id: string;
label: string;
kind?: string;
status?: AgentStatus;
}
export interface FlowEdge {
from: string;
to: string;
}
export interface FlowGraph {
id: string;
nodes: FlowNode[];
edges: FlowEdge[];
}
export interface SessionRecord {
platform: string;
channelId: string;
sessionId: string;
actorRole: string;
owner: boolean;
isGroup: boolean;
memWritable: boolean;
}
export interface PersonaRecord {
id: string;
name: string;
character: string;
isDefault: boolean;
mode: string;
memoryNamespace: string;
}
export interface LoginResult {
token: string;
expiresAt: number;
role: string;
}
export interface ChatSyncResult {
reply: string;
steps: number;
stopReason: string;
}
/**
* Downstream WS frame (server → client). `data` is intentionally `unknown`;
* every consumer narrows it. The backend emits `{type, data}` and types include
* stream_chunk / stream_end / thinking / mood / tool_used / subagent_event /
* graph_update / reminder / device_update / auth / error / task_output.
*/
export interface WsDownEvent {
type: string;
data: unknown;
}
/** Upstream WS frame (client → server). */
export interface WsUpEvent {
type: "mode_switch" | "chat" | "voice_input" | "subagent_ask" | "task_output_query";
reqId?: string;
data?: unknown;
}
export interface ApiErrorShape {
code?: string;
message?: string;
}
+46
View File
@@ -0,0 +1,46 @@
import { useCallback, useEffect, useRef, useState } from "react";
interface AsyncState<T> {
data: T | null;
loading: boolean;
error: string | null;
reload: () => void;
}
/** Runs an async factory on mount (and on reload), tracking loading/error so
* every API-backed surface has a complete state. Best-effort, no `any`. */
export function useAsyncData<T>(fn: () => Promise<T>, deps: readonly unknown[] = []): AsyncState<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fnRef = useRef(fn);
fnRef.current = fn;
const depsKey = deps.join("\u0000");
const run = useCallback(() => {
let cancelled = false;
setLoading(true);
setError(null);
fnRef
.current()
.then((res) => {
if (!cancelled) setData(res);
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof Error ? err.message : "加载失败");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [depsKey]);
useEffect(() => {
const cancel = run();
return cancel;
}, [run]);
return { data, loading, error, reload: run };
}
+52
View File
@@ -0,0 +1,52 @@
import { useCallback, useState } from "react";
import { chatStream } from "./api";
import { connectWs, isWsOpen } from "./ws";
import { useAppStore } from "../store/useAppStore";
import { now } from "./util";
/** 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. */
export function useSendChat(): {
send: (text: string) => Promise<void>;
sending: boolean;
} {
const [sending, setSending] = useState(false);
const sessionId = useAppStore((s) => s.sessionId);
const addUserMessage = useAppStore((s) => s.addUserMessage);
const beginAssistant = useAppStore((s) => s.beginAssistant);
const endStream = useAppStore((s) => s.endStream);
const appendChunk = useAppStore((s) => s.appendChunk);
const addTool = useAppStore((s) => s.addTool);
const appendTaskLog = useAppStore((s) => s.appendTaskLog);
const send = useCallback(
async (text: string) => {
const trimmed = text.trim();
if (!trimmed || sending) return;
setSending(true);
if (!isWsOpen()) connectWs();
addUserMessage(trimmed);
beginAssistant();
try {
await chatStream(sessionId, trimmed);
} catch (err: unknown) {
// Backend loop not wired or gateway unreachable: show the failure inline
// rather than leaving a permanently-streaming bubble.
endStream();
const msg = err instanceof Error ? err.message : "触发失败";
appendChunk(`[stream 触发失败] ${msg}`);
appendTaskLog("main", `[error] ${msg}\n`);
addTool({ name: "stream_trigger", summary: "失败", ok: false, detail: msg, at: now() });
} finally {
setSending(false);
}
},
[sending, sessionId, addUserMessage, beginAssistant, endStream, appendChunk, addTool, appendTaskLog]
);
return { send, sending };
}
+12
View File
@@ -0,0 +1,12 @@
/** Generate a reasonably-unique id without external deps. */
export function uid(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
/** Current epoch milliseconds. */
export function now(): number {
return Date.now();
}
+275
View File
@@ -0,0 +1,275 @@
import { useAppStore } from "../store/useAppStore";
import { useModeStore } from "../store/useModeStore";
import { getAuthToken } from "./api";
import type { AgentInfo, FlowGraph, ToolUsed, WsUpEvent } from "./types";
import { now, uid } from "./util";
/**
* Realtime client for `/ws/chat`. Reconnects with exponential backoff and
* decodes the downstream event stream into the app store. All `data` payloads
* are `unknown` and narrowed here; nothing leaks `any` into components.
*/
let ws: WebSocket | null = null;
let shouldRun = false;
let reconnectTimer: number | null = null;
let attempt = 0;
const MAX_BACKOFF = 30_000;
const BASE_BACKOFF = 1_000;
function wsUrl(): string {
const proto = window.location.protocol === "https:" ? "wss" : "ws";
const token = getAuthToken() ?? "";
const qs = token ? `?token=${encodeURIComponent(token)}` : "";
return `${proto}://${window.location.host}/ws/chat${qs}`;
}
function toRecord(v: unknown): Record<string, unknown> | null {
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
return v as Record<string, unknown>;
}
return null;
}
function parseTool(v: unknown): ToolUsed | null {
const r = toRecord(v);
if (!r) return null;
if (typeof r.name !== "string") return null;
return {
name: r.name,
summary: typeof r.summary === "string" ? r.summary : undefined,
detail: typeof r.detail === "string" ? r.detail : undefined,
ok: typeof r.ok === "boolean" ? r.ok : undefined,
at: now(),
};
}
function parseAgent(payload: unknown): AgentInfo | null {
const r = toRecord(payload);
if (!r) return null;
const id = typeof r.id === "string" ? r.id : uid();
const status =
typeof r.status === "string"
? (r.status as AgentInfo["status"])
: typeof r.state === "string"
? (r.state as AgentInfo["status"])
: "running";
const tool = parseTool(r.tool);
return {
id,
name: typeof r.name === "string" ? r.name : `子代理 ${id.slice(0, 4)}`,
role: typeof r.role === "string" ? r.role : undefined,
mode: typeof r.mode === "string" ? r.mode : undefined,
status,
progress: typeof r.progress === "number" ? r.progress : undefined,
thinking: typeof r.thinking === "string" ? r.thinking : undefined,
message: typeof r.message === "string" ? r.message : undefined,
tools: tool ? [tool] : undefined,
};
}
function parseGraph(payload: unknown): FlowGraph | null {
const r = toRecord(payload);
if (!r || !Array.isArray(r.nodes)) return null;
const nodes = r.nodes
.map((n) => toRecord(n))
.filter((n): n is Record<string, unknown> => n !== null)
.map((n) => ({
id: typeof n.id === "string" ? n.id : uid(),
label: typeof n.label === "string" ? n.label : "节点",
kind: typeof n.kind === "string" ? n.kind : undefined,
status:
typeof n.status === "string"
? (n.status as FlowGraph["nodes"][number]["status"])
: undefined,
}));
const edges = Array.isArray(r.edges)
? r.edges
.map((e) => toRecord(e))
.filter((e): e is Record<string, unknown> => e !== null)
.map((e) => ({
from: typeof e.from === "string" ? e.from : "",
to: typeof e.to === "string" ? e.to : "",
}))
: [];
return { id: typeof r.id === "string" ? r.id : "graph", nodes, edges };
}
function dispatch(ev: { type: string; data: unknown }): void {
const s = useAppStore.getState();
const r = toRecord(ev.data);
switch (ev.type) {
case "stream_chunk": {
if (r && typeof r.delta === "string") s.appendChunk(r.delta);
break;
}
case "stream_end": {
s.endStream();
if (r && typeof r.stopReason === "string") {
s.setContext({ lastAgentMode: s.context.lastAgentMode });
}
break;
}
case "thinking": {
if (typeof ev.data === "string") {
s.setThinking(ev.data);
} else if (r && typeof r.text === "string") {
s.setThinking(r.text);
}
break;
}
case "mood": {
if (typeof ev.data === "string") s.setMood(ev.data);
else if (r && typeof r.mood === "string") s.setMood(r.mood);
break;
}
case "tool_used": {
const tool = parseTool(ev.data);
if (tool) s.addTool(tool);
break;
}
case "subagent_event": {
const agent = parseAgent(ev.data ?? r);
if (agent) s.upsertAgent(agent);
break;
}
case "graph_update": {
const g = parseGraph(ev.data);
if (g) s.setFlowGraph(g);
break;
}
case "task_output": {
if (r) {
const taskId = typeof r.taskId === "string" ? r.taskId : "main";
const output =
typeof r.output === "string"
? r.output
: typeof r.line === "string"
? r.line
: typeof ev.data === "string"
? ev.data
: JSON.stringify(ev.data);
s.appendTaskLog(taskId, output + "\n");
}
break;
}
case "device_update": {
if (typeof ev.data === "string") s.addDeviceUpdate(ev.data);
else if (r && typeof r.message === "string") s.addDeviceUpdate(r.message);
break;
}
case "reminder": {
if (typeof ev.data === "string") s.addDeviceUpdate(`提醒:${ev.data}`);
else if (r && typeof r.message === "string") s.addDeviceUpdate(`提醒:${r.message}`);
break;
}
case "error": {
const msg = r && typeof r.message === "string" ? r.message : "未知错误";
s.addDeviceUpdate(`WS 错误:${msg}`);
break;
}
case "auth": {
// handshake acknowledgement; nothing to persist yet.
break;
}
default: {
// Unknown future event: keep it in the context log so no frame is lost.
const text = typeof ev.data === "string" ? ev.data : JSON.stringify(ev.data);
s.addDeviceUpdate(`[${ev.type}] ${text}`);
}
}
}
function scheduleReconnect(): void {
if (!shouldRun) return;
if (reconnectTimer !== null) return;
const delay = Math.min(BASE_BACKOFF * 2 ** attempt, MAX_BACKOFF);
attempt += 1;
useAppStore.getState().setWsStatus("reconnecting");
reconnectTimer = window.setTimeout(() => {
reconnectTimer = null;
openSocket();
}, delay);
}
function openSocket(): void {
const s = useAppStore.getState();
try {
ws = new WebSocket(wsUrl());
} catch {
s.setWsStatus("disconnected");
scheduleReconnect();
return;
}
s.setWsStatus(attempt === 0 ? "connecting" : "reconnecting");
ws.onopen = () => {
attempt = 0;
useAppStore.getState().setWsStatus("connected");
// Sync the current mode on every (re)connect so the agent's shell matches.
const mode = useModeStore.getState().mode;
sendUp({ type: "mode_switch", data: { mode } });
};
ws.onmessage = (msg) => {
try {
const parsed = JSON.parse(String(msg.data)) as unknown;
const r = toRecord(parsed);
if (!r) return;
dispatch({ type: typeof r.type === "string" ? r.type : "", data: r.data });
} catch {
// ignore malformed frames
}
};
ws.onerror = () => {
useAppStore.getState().setWsStatus("disconnected");
};
ws.onclose = () => {
ws = null;
useAppStore.getState().setWsStatus("disconnected");
scheduleReconnect();
};
}
/** Open (or reopen) the realtime channel. Safe to call repeatedly. */
export function connectWs(): void {
if (shouldRun && (ws?.readyState === WebSocket.OPEN || ws?.readyState === WebSocket.CONNECTING)) {
return;
}
shouldRun = true;
openSocket();
}
/** Close the channel and stop reconnecting. */
export function disconnectWs(): void {
shouldRun = false;
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer);
reconnectTimer = null;
}
const cur = ws;
ws = null;
if (cur) {
cur.onclose = null;
cur.close();
}
useAppStore.getState().setWsStatus("idle");
}
/** Send an upstream frame; returns false when the socket is not open. */
export function sendUp(ev: WsUpEvent): boolean {
const cur = ws;
if (!cur || cur.readyState !== WebSocket.OPEN) return false;
cur.send(JSON.stringify(ev));
return true;
}
/** Is the realtime channel open? */
export function isWsOpen(): boolean {
return ws?.readyState === WebSocket.OPEN;
}
+23
View File
@@ -0,0 +1,23 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles/index.css";
// PWA: register the service worker (production only — the dev server's caching
// would otherwise serve stale modules).
if ("serviceWorker" in navigator && import.meta.env.PROD) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(() => {
// Non-fatal: the app still works without the offline shell.
});
});
}
const container = document.getElementById("root");
if (!container) throw new Error("#root not found");
createRoot(container).render(
<StrictMode>
<App />
</StrictMode>
);
+144
View File
@@ -0,0 +1,144 @@
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>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { useEffect, useRef } from "react";
import { useAppStore } from "../store/useAppStore";
import { useModeStore } from "../store/useModeStore";
import { useSendChat } from "../lib/useChat";
import { MessageBubble } from "../components/chat";
import { ChatInput } from "../components/ChatInput";
import { EmptyState } from "../components/primitives";
export function ChatMode(): React.ReactNode {
const messages = useAppStore((s) => s.messages);
const setMode = useModeStore((s) => s.setMode);
const { send, sending } = useSendChat();
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [messages]);
return (
<div className="flex h-full flex-col">
<div className="flex-1 overflow-y-auto">
<div className="mx-auto flex max-w-2xl flex-col gap-4 p-4">
{messages.length === 0 ? (
<EmptyState
icon="💬"
title="开始与昔涟对话"
hint="模式只改变呈现层,会话上下文与记忆保持不变"
/>
) : (
messages.map((m) => <MessageBubble key={m.id} message={m} />)
)}
<div ref={bottomRef} />
</div>
</div>
<ChatInput
onSend={(t) => void send(t)}
sending={sending}
onVoice={() => setMode("voice")}
/>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
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
@@ -0,0 +1,61 @@
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
@@ -0,0 +1,93 @@
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>
);
}
+38
View File
@@ -0,0 +1,38 @@
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";
/** Renders the active mode's presentation layer. The session context / messages
* / memory live in useAppStore and are untouched by these switches. */
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 />;
}
}
+190
View File
@@ -0,0 +1,190 @@
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
@@ -0,0 +1,82 @@
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
@@ -0,0 +1,72 @@
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
@@ -0,0 +1,101 @@
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>
);
}
+219
View File
@@ -0,0 +1,219 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type {
AgentInfo,
BackthinkItem,
ChatMessage,
FlowGraph,
ToolUsed,
WsStatus,
} from "../lib/types";
import { now, uid } from "../lib/util";
interface ChatContext {
topic?: string;
lastAgentMode?: string;
notes?: string[];
}
export interface AppStore {
sessionId: string;
wsStatus: WsStatus;
messages: ChatMessage[];
mood: string | null;
context: ChatContext;
agents: AgentInfo[];
backthink: BackthinkItem[];
flowGraph: FlowGraph | null;
deviceUpdates: string[];
setWsStatus: (status: WsStatus) => void;
setSessionId: (id: string) => void;
setContext: (patch: Partial<ChatContext>) => void;
setMood: (mood: string | null) => void;
addUserMessage: (text: string) => void;
beginAssistant: () => string;
appendChunk: (delta: string) => void;
endStream: () => void;
setThinking: (text: string) => void;
addTool: (tool: ToolUsed) => void;
setAgents: (agents: AgentInfo[]) => void;
upsertAgent: (agent: AgentInfo) => void;
addBackthink: (item: BackthinkItem) => void;
revealBackthink: (id: string, conclusion: string) => void;
markBackthinkPushed: (id: string) => void;
setFlowGraph: (graph: FlowGraph) => void;
addDeviceUpdate: (text: string) => void;
taskLogs: Record<string, string>;
appendTaskLog: (taskId: string, text: string) => void;
clearTaskLog: (taskId: string) => void;
resetChat: () => void;
}
export const useAppStore = create<AppStore>()(
persist(
(set, get) => ({
sessionId: "web",
wsStatus: "idle",
messages: [],
mood: null,
context: {},
agents: [
{ id: "main", name: "昔涟", status: "idle", role: "owner" },
],
backthink: [],
flowGraph: null,
deviceUpdates: [],
taskLogs: {},
setWsStatus: (status) => set({ wsStatus: status }),
setSessionId: (id) => set({ sessionId: id }),
setContext: (patch) =>
set((s) => ({ context: { ...s.context, ...patch } })),
setMood: (mood) => set({ mood }),
addUserMessage: (text) =>
set((s) => ({
messages: [
...s.messages,
{
id: uid(),
role: "user",
content: text,
createdAt: now(),
},
],
})),
beginAssistant: () => {
const id = uid();
set((s) => ({
messages: [
...s.messages,
{
id,
role: "assistant",
content: "",
streaming: true,
tools: [],
createdAt: now(),
},
],
}));
return id;
},
appendChunk: (delta) =>
set((s) => {
const messages = s.messages.slice();
const last = messages[messages.length - 1];
if (!last || last.role !== "assistant") return s;
messages[messages.length - 1] = { ...last, content: last.content + delta };
return { messages };
}),
endStream: () =>
set((s) => {
const messages = s.messages.slice();
const last = messages[messages.length - 1];
if (!last || last.role !== "assistant") return s;
messages[messages.length - 1] = { ...last, streaming: false };
return { messages };
}),
setThinking: (text) =>
set((s) => {
const messages = s.messages.slice();
const last = messages[messages.length - 1];
if (!last || last.role !== "assistant") return s;
messages[messages.length - 1] = { ...last, thinking: text };
return { messages };
}),
addTool: (tool) =>
set((s) => {
const messages = s.messages.slice();
const last = messages[messages.length - 1];
if (!last || last.role !== "assistant") return s;
messages[messages.length - 1] = {
...last,
tools: [...(last.tools ?? []), tool],
};
return { messages };
}),
setAgents: (agents) => set({ agents }),
upsertAgent: (agent) =>
set((s) => {
const idx = s.agents.findIndex((a) => a.id === agent.id);
if (idx === -1) return { agents: [...s.agents, agent] };
const agents = s.agents.slice();
agents[idx] = { ...agents[idx], ...agent };
return { agents };
}),
addBackthink: (item) =>
set((s) => ({ backthink: [item, ...s.backthink] })),
revealBackthink: (id, conclusion) =>
set((s) => ({
backthink: s.backthink.map((b) =>
b.id === id ? { ...b, conclusion, shouldNotify: true } : b
),
})),
markBackthinkPushed: (id) =>
set((s) => ({
backthink: s.backthink.map((b) =>
b.id === id ? { ...b, pushed: true } : b
),
})),
setFlowGraph: (graph) => set({ flowGraph: graph }),
addDeviceUpdate: (text) =>
set((s) => ({ deviceUpdates: [text, ...s.deviceUpdates].slice(0, 50) })),
appendTaskLog: (taskId, text) =>
set((s) => ({
taskLogs: {
...s.taskLogs,
[taskId]: (s.taskLogs[taskId] ?? "") + text,
},
})),
clearTaskLog: (taskId) =>
set((s) => {
if (!(taskId in s.taskLogs)) return s;
const taskLogs = { ...s.taskLogs };
delete taskLogs[taskId];
return { taskLogs };
}),
resetChat: () =>
set((s) => ({
messages: [],
mood: null,
flowGraph: null,
backthink: s.backthink,
})),
}),
{
name: "cyrene.app",
version: 1,
partialize: (s) => ({
sessionId: s.sessionId,
messages: s.messages,
mood: s.mood,
context: s.context,
backthink: s.backthink,
}),
}
)
);
/** Convenience selector for the last streaming assistant message id. */
export function lastStreamingMessage(s: AppStore): ChatMessage | undefined {
const last = s.messages[s.messages.length - 1];
return last?.role === "assistant" ? last : undefined;
}
+22
View File
@@ -0,0 +1,22 @@
import { create } from "zustand";
import type { Mode } from "../lib/types";
/**
* Behavior-mode store. Mode only drives the presentation layer: switching it
* never touches the session context / messages / memory in useAppStore.
*/
export interface ModeStore {
mode: Mode;
/** Set by the user explicitly. */
setMode: (mode: Mode) => void;
/** Last mode the agent chose for herself (self-switch), nullable. */
selfMode: Mode | null;
setSelfMode: (mode: Mode | null) => void;
}
export const useModeStore = create<ModeStore>()((set) => ({
mode: "chat",
setMode: (mode) => set({ mode }),
selfMode: null,
setSelfMode: (mode) => set({ selfMode: mode }),
}));
+137
View File
@@ -0,0 +1,137 @@
@import "../design/tokens.css";
@import "tailwindcss";
/* 让 Tailwind 通过对 token 的映射生成工具类(bg-surface-1 / rounded-smooth / ease-spring 等)。
tokens.css 里的 surface 是动态值(Light 会覆盖),这里用静态 hex 生成工具类。 */
@theme {
--color-surface-1: #161616;
--color-surface-2: #0c0c0c;
--color-surface-3: #1c1c1c;
--color-surface-4: #242424;
--color-surface-5: #2c2c2c;
--color-text-hi: #f5f5f7;
--color-text-mid: #b4b4b8;
--color-text-lo: #7a7a80;
--color-accent: #3d6bff;
--color-accent-hi: #6a8dff;
--color-ok: #35c26b;
--color-warn: #ff9f43;
--color-err: #ff5c5c;
--radius-smooth: 0.625rem;
--radius-pill: 62.5rem;
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-smooth: cubic-bezier(0.22, 1, 0.36, 1);
}
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
background: var(--mu-surface-2);
color: var(--mu-text-hi);
font-family:
"MiSans",
system-ui,
-apple-system,
"PingFang SC",
"Microsoft YaHei",
sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
overscroll-behavior: none;
}
/* 毛玻璃表面 —— TopBar / Sidebar / BottomNav 用 */
.glass {
background: color-mix(in srgb, var(--mu-surface-1) calc(var(--mu-glass-alpha) * 100%), transparent);
backdrop-filter: blur(var(--mu-glass-blur)) saturate(1.4);
-webkit-backdrop-filter: blur(var(--mu-glass-blur)) saturate(1.4);
border: 1px solid var(--mu-glass-border);
}
/* Liquid Glass 寓意:内高光 + 折射描边 + 深层玻璃阴影 */
.liquid-glass {
position: relative;
background:
linear-gradient(135deg, var(--mu-liquid-highlight), transparent 42%),
color-mix(in srgb, var(--mu-surface-4) 88%, transparent);
box-shadow:
var(--mu-shadow-2),
inset 0 1px 0 var(--mu-liquid-rim),
inset 0 0 0 1px var(--mu-liquid-rim);
backdrop-filter: blur(var(--mu-glass-blur)) saturate(1.3);
-webkit-backdrop-filter: blur(var(--mu-glass-blur)) saturate(1.3);
transition: transform 0.4s var(--mu-spring), box-shadow 0.4s var(--mu-ease);
}
.liquid-glass:hover {
transform: translateY(-1px);
box-shadow: var(--mu-shadow-3), inset 0 1px 0 var(--mu-liquid-rim);
}
/* Smooth Corner 优先:全局圆角走 token(k=0.55 近似值),落地在组件里用 rounded-smooth */
* {
border-radius: var(--mu-radius);
scrollbar-width: thin;
scrollbar-color: var(--mu-surface-5) transparent;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-thumb {
background: var(--mu-surface-5);
border-radius: var(--mu-radius-pill);
}
*::-webkit-scrollbar-track {
background: transparent;
}
button {
font: inherit;
color: inherit;
background: none;
border: none;
cursor: pointer;
}
input,
textarea {
font: inherit;
color: inherit;
}
/* 欢迎出现动画(仅交互/入场) */
@keyframes mu-rise-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.mu-rise-in {
animation: mu-rise-in 0.36s var(--mu-spring) both;
}
/* 思维链呼吸点 */
@keyframes mu-blink {
0%,
100% {
opacity: 0.35;
}
50% {
opacity: 1;
}
}
.mu-blink {
animation: mu-blink 1.2s ease-in-out infinite;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+35
View File
@@ -0,0 +1,35 @@
import type { Config } from "tailwindcss";
// Miuix design tokens (Cyrene v2 frontend). Tailwind v4 is CSS-first, so the
// canonical token source is src/design/tokens.css (@theme). This config mirrors
// the same token values for any tooling that reads a JS config, and is loaded by
// the CSS via `@config "../../tailwind.config.ts"`. The six Miuix cores are
// encoded directly in the values below.
const config: Config = {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
// 多层 Surface:5 级色阶 #161616 → #0C0C0C → #1C1C1C → #242424 → #2C2C2C
surface: {
D1: "#161616",
D2: "#0C0C0C",
D3: "#1C1C1C",
D4: "#242424",
D5: "#2C2C2C",
},
},
borderRadius: {
// Smooth Corner:贝塞尔连续曲率 k=0.55 的圆角近似
smooth: "0.625rem",
glass: "1rem",
},
transitionTimingFunction: {
// 弹簧动效 cubic-bezier(0.34,1.56,0.64,1),仅用于交互组件
spring: "cubic-bezier(0.34, 1.56, 0.64, 1)",
},
},
},
};
export default config;
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"useUnknownInCatchVariables": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true,
"types": ["node"]
},
"include": ["vite.config.ts", "tailwind.config.ts"]
}
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// Cyrene v2 frontend (M9). Dev server proxies REST + WebSocket to the single
// backend gateway on :8080 so the SPA can stay same-origin. The production
// build is served by the gateway itself (single binary), so `base` is relative
// and there is no runtime proxy.
const devProxy = {
"/api": { target: "http://localhost:8080", changeOrigin: true },
"/ws": { target: "ws://localhost:8080", ws: true },
};
export default defineConfig({
plugins: [react()],
base: "./",
server: {
port: 5173,
proxy: devProxy,
},
preview: {
port: 4173,
},
build: {
outDir: "dist",
sourcemap: false,
target: "es2022",
},
});