feat(frontend): M9 模态 UI(mode 系统+共享 store+WS 客户端 + chat(思维链/工具回显/情绪徽标)/code/Agent 监控/后台思考页 + Miuix 设计 token + PWA);tsc+vite build 通过
This commit is contained in:
1 parent
b3ffd51736
commit
4c48e94659
40 files changed
+5321
No files matched your search
@@ -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>
|
||||
Generated
+2490
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 }),
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user