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

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

No files matched your search

+144
View File
@@ -0,0 +1,144 @@
import { useAppStore } from "../store/useAppStore";
import { sendUp } from "../lib/ws";
import type { AgentInfo, AgentStatus, FlowGraph } from "../lib/types";
import { Button, EmptyState, Surface, Tag, type TagTone } from "../components/primitives";
const statusTone: Record<AgentStatus, { label: string; tone: TagTone }> = {
idle: { label: "空闲", tone: "neutral" },
running: { label: "运行中", tone: "accent" },
blocked: { label: "阻塞", tone: "warn" },
done: { label: "完成", tone: "ok" },
error: { label: "错误", tone: "err" },
};
function ProgressBar({ value }: { value: number }): React.ReactNode {
return (
<div className="h-1.5 w-full overflow-hidden rounded-[var(--mu-radius-pill)] bg-[var(--mu-surface-4)]">
<div
className="h-full rounded-[var(--mu-radius-pill)] bg-[var(--mu-accent)] transition-all duration-300 ease-[var(--mu-spring)]"
style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
/>
</div>
);
}
function AgentCard({ agent }: { agent: AgentInfo }): React.ReactNode {
const meta = statusTone[agent.status] ?? statusTone.idle;
return (
<Surface className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-[var(--mu-accent)]" />
<span className="text-sm font-medium">{agent.name}</span>
{agent.role ? (
<span className="text-xs text-[var(--mu-text-lo)]">{agent.role}</span>
) : null}
<span className="ml-auto">
<Tag tone={meta.tone}>{meta.label}</Tag>
</span>
</div>
{typeof agent.progress === "number" ? (
<ProgressBar value={agent.progress} />
) : null}
{agent.thinking ? (
<pre className="max-h-24 overflow-auto whitespace-pre-wrap rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-2 py-1.5 text-xs text-[var(--mu-text-mid)]">
{agent.thinking}
</pre>
) : null}
{agent.tools?.map((t, i) => (
<div key={i} className="text-xs text-[var(--mu-text-lo)]">
⚙ {t.name}
{t.ok === false ? " ✗" : ""}
</div>
))}
{agent.message ? (
<div className="text-xs text-[var(--mu-text-mid)]">{agent.message}</div>
) : null}
</Surface>
);
}
function FlowGraphView({ graph }: { graph: FlowGraph | null }): React.ReactNode {
if (!graph) {
return (
<div className="rounded-[var(--mu-radius-lg)] border border-dashed border-[var(--mu-glass-border)] px-4 py-12 text-center text-xs text-[var(--mu-text-lo)]">
流程图占位 —— 连接实时通道后按 graph_update 事件渲染
</div>
);
}
return (
<div className="flex flex-wrap items-center gap-4 rounded-[var(--mu-radius-lg)] bg-[var(--mu-surface-2)]/40 p-4">
{graph.nodes.map((n, i) => (
<div key={n.id} className="flex items-center gap-4">
<Tag tone={n.status === "running" ? "accent" : "neutral"}>{n.label}</Tag>
{i < graph.nodes.length - 1 ? <span className="text-[var(--mu-text-lo)]">→</span> : null}
</div>
))}
</div>
);
}
export function AgentsMode(): React.ReactNode {
const agents = useAppStore((s) => s.agents);
const backthink = useAppStore((s) => s.backthink);
const flowGraph = useAppStore((s) => s.flowGraph);
const markPushed = useAppStore((s) => s.markBackthinkPushed);
return (
<div className="mx-auto max-w-3xl space-y-5 p-4">
<div>
<h2 className="text-lg font-semibold">Agent 运行监控</h2>
<p className="text-sm text-[var(--mu-text-lo)]">主会话 + 子代理实时状态</p>
</div>
<FlowGraphView graph={flowGraph} />
{agents.length === 0 ? (
<EmptyState icon="🧩" title="暂无 Agent" />
) : (
<div className="space-y-3">
{agents.map((a) => (
<AgentCard key={a.id} agent={a} />
))}
</div>
)}
<div>
<div className="mb-2 flex items-center gap-2">
<h3 className="text-sm font-semibold">后台思考</h3>
<span className="text-xs text-[var(--mu-text-lo)]">backthink</span>
</div>
{backthink.length === 0 ? (
<EmptyState icon="🌙" title="暂无后台思考" hint="后台任务完成时在此呈现触发源 / 结论 / 是否打扰" />
) : (
<div className="space-y-2">
{backthink.map((b) => (
<Surface key={b.id} className="flex items-center gap-2 text-sm">
<span className="text-[var(--mu-text-lo)]">{b.trigger}</span>
<Tag tone={b.shouldNotify ? "warn" : "neutral"}>
{b.shouldNotify ? "需要打扰" : "静默"}
</Tag>
<Tag tone={b.pushed ? "ok" : "neutral"}>{b.pushed ? "已推送" : "未推送"}</Tag>
{b.conclusion ? (
<span className="truncate text-[var(--mu-text-mid)]">{b.conclusion}</span>
) : null}
<span className="ml-auto">
<Button
size="sm"
variant="ghost"
disabled={b.pushed}
onClick={() => {
markPushed(b.id);
sendUp({ type: "task_output_query", data: { taskId: b.id } });
}}
>
{b.pushed ? "已推送" : "推送结果"}
</Button>
</span>
</Surface>
))}
</div>
)}
</div>
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { useEffect, useRef } from "react";
import { useAppStore } from "../store/useAppStore";
import { useModeStore } from "../store/useModeStore";
import { useSendChat } from "../lib/useChat";
import { MessageBubble } from "../components/chat";
import { ChatInput } from "../components/ChatInput";
import { EmptyState } from "../components/primitives";
export function ChatMode(): React.ReactNode {
const messages = useAppStore((s) => s.messages);
const setMode = useModeStore((s) => s.setMode);
const { send, sending } = useSendChat();
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [messages]);
return (
<div className="flex h-full flex-col">
<div className="flex-1 overflow-y-auto">
<div className="mx-auto flex max-w-2xl flex-col gap-4 p-4">
{messages.length === 0 ? (
<EmptyState
icon="💬"
title="开始与昔涟对话"
hint="模式只改变呈现层,会话上下文与记忆保持不变"
/>
) : (
messages.map((m) => <MessageBubble key={m.id} message={m} />)
)}
<div ref={bottomRef} />
</div>
</div>
<ChatInput
onSend={(t) => void send(t)}
sending={sending}
onVoice={() => setMode("voice")}
/>
</div>
);
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useRef } from "react";
import { useAppStore } from "../store/useAppStore";
import { sendUp } from "../lib/ws";
import { Button, Surface } from "../components/primitives";
const SAMPLE_CODE = `// cyrene/example.ts — 示例文件
export async function greet(name: string): Promise<string> {
return \`你好,\${name}!\`;
}
// 在代码模式里,昔涟可在此展示/编辑代码、
// 运行测试、查看终端输出,全程与聊天共享同一会话上下文。`;
export function CodeMode(): React.ReactNode {
const taskLogs = useAppStore((s) => s.taskLogs);
const appendTaskLog = useAppStore((s) => s.appendTaskLog);
const termRef = useRef<HTMLPreElement>(null);
const mainLog = taskLogs["main"] ?? "";
const lines = mainLog.length === 0 ? [] : mainLog.split("\n").filter(Boolean);
useEffect(() => {
termRef.current?.scrollTo({ top: termRef.current.scrollHeight, behavior: "smooth" });
}, [lines.length, mainLog]);
const runDemo = () => {
const stamp = new Date().toLocaleTimeString();
appendTaskLog(
"main",
`[${stamp}] $ cyrene run task\n[${stamp}] ▶ 后台任务已启动(占位演示,无后端时仍能展示实时输出)\n[${stamp}] ✓ 完成,0 errors\n`
);
};
return (
<div className="mx-auto flex h-full max-w-6xl gap-3 p-4">
<Surface className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">代码</span>
<span className="text-xs text-[var(--mu-text-lo)]">example.ts</span>
<Button size="sm" variant="ghost" className="ml-auto">
运行测试
</Button>
</div>
<pre className="min-h-[16rem] flex-1 overflow-auto rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] p-3 text-xs leading-relaxed text-[var(--mu-text-mid)]">
{SAMPLE_CODE}
</pre>
</Surface>
<Surface className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">终端操作日志</span>
<span className="text-xs text-[var(--mu-text-lo)]">后台任务实时输出</span>
<span className="ml-auto flex gap-1.5">
<Button size="sm" variant="ghost" onClick={runDemo}>
运行 demo
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => sendUp({ type: "task_output_query", data: { taskId: "main" } })}
>
查询输出
</Button>
</span>
</div>
<pre
ref={termRef}
className="min-h-[16rem] flex-1 overflow-auto whitespace-pre-wrap rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] p-3 font-mono text-xs text-[var(--mu-text-mid)]"
>
{mainLog || "(暂无输出 —— 点「运行 demo」或等待后台任务)"}
</pre>
</Surface>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { useState } from "react";
import { Surface, Tag, EmptyState } from "../components/primitives";
const DOCS = [
{ id: "d1", title: "设计 · 前端模态 UI(主题 H)", kind: "设计", updated: "09-06" },
{ id: "d2", title: "客户端 API 契约 v0.2", kind: "API", updated: "09-05" },
{ id: "d3", title: "角色 · 人格 · 行为模式", kind: "设计", updated: "09-05" },
{ id: "d4", title: "Miuix 设计基准", kind: "规范", updated: "09-04" },
];
export function DocsMode(): React.ReactNode {
const [query, setQuery] = useState("");
const [filtered, setFiltered] = useState(DOCS);
const search = () => {
const q = query.trim().toLowerCase();
setFiltered(q ? DOCS.filter((d) => d.title.toLowerCase().includes(q)) : DOCS);
};
return (
<div className="mx-auto max-w-3xl space-y-5 p-4">
<div>
<h2 className="text-lg font-semibold">知识库</h2>
<p className="text-sm text-[var(--mu-text-lo)]">检索 · 文档管理</p>
</div>
<div className="flex gap-2">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && search()}
placeholder="检索知识库…"
className="flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none placeholder:text-[var(--mu-text-lo)]"
/>
<button
onClick={search}
className="rounded-[var(--mu-radius)] bg-[var(--mu-accent)] px-4 py-2 text-sm font-medium text-white"
>
检索
</button>
</div>
{filtered.length === 0 ? (
<EmptyState icon="📚" title="未找到相关文档" />
) : (
<div className="space-y-2">
{filtered.map((d) => (
<Surface key={d.id} className="flex items-center gap-3">
<span className="text-lg">📄</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{d.title}</div>
<div className="text-xs text-[var(--mu-text-lo)]">更新于 {d.updated}</div>
</div>
<Tag tone="neutral">{d.kind}</Tag>
</Surface>
))}
</div>
)}
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
import { useState } from "react";
import { useAppStore } from "../store/useAppStore";
import { Surface, Switch, Tag, EmptyState } from "../components/primitives";
interface Device {
id: string;
name: string;
icon: string;
on: boolean;
room: string;
}
const INITIAL_DEVICES: Device[] = [
{ id: "light1", name: "客厅吊灯", icon: "💡", on: true, room: "客厅" },
{ id: "ac1", name: "主卧空调", icon: "❄️", on: false, room: "主卧" },
{ id: "plug1", name: "智能插座", icon: "🔌", on: false, room: "书房" },
];
export function HomeMode(): React.ReactNode {
const deviceUpdates = useAppStore((s) => s.deviceUpdates);
const [devices, setDevices] = useState<Device[]>(INITIAL_DEVICES);
const toggle = (id: string) =>
setDevices((ds) => ds.map((d) => (d.id === id ? { ...d, on: !d.on } : d)));
const active = devices.filter((d) => d.on).length;
return (
<div className="mx-auto max-w-3xl space-y-5 p-4">
<div>
<h2 className="text-lg font-semibold">家居</h2>
<p className="text-sm text-[var(--mu-text-lo)]">设备 · 场景 · 能耗 · 自动化</p>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
<Surface>
<div className="text-xs text-[var(--mu-text-lo)]">在线设备</div>
<div className="text-2xl font-semibold">{devices.length}</div>
</Surface>
<Surface>
<div className="text-xs text-[var(--mu-text-lo)]">已开启</div>
<div className="text-2xl font-semibold">{active}</div>
</Surface>
<Surface>
<div className="text-xs text-[var(--mu-text-lo)]">今日能耗</div>
<div className="text-2xl font-semibold">2.4 kWh</div>
</Surface>
</div>
<div>
<h3 className="mb-2 text-sm font-semibold">设备</h3>
<div className="space-y-2">
{devices.map((d) => (
<Surface key={d.id} className="flex items-center gap-3">
<span className="text-xl">{d.icon}</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium">{d.name}</div>
<div className="text-xs text-[var(--mu-text-lo)]">{d.room}</div>
</div>
<Switch checked={d.on} onChange={() => toggle(d.id)} label={d.name} />
</Surface>
))}
</div>
</div>
<div>
<h3 className="mb-2 text-sm font-semibold">场景</h3>
<div className="flex flex-wrap gap-2">
{["回家", "观影", "睡眠", "离家"].map((s) => (
<Tag key={s} tone="accent">
{s}
</Tag>
))}
</div>
</div>
<div>
<h3 className="mb-2 text-sm font-semibold">实时设备事件</h3>
{deviceUpdates.length === 0 ? (
<EmptyState icon="📡" title="暂无设备事件" hint="连接实时通道后,device_update 事件会在此滚动" />
) : (
<Surface className="space-y-1.5">
{deviceUpdates.slice(0, 8).map((u, i) => (
<div key={i} className="text-xs text-[var(--mu-text-mid)]">
{u}
</div>
))}
</Surface>
)}
</div>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { useModeStore } from "../store/useModeStore";
import { ChatMode } from "./ChatMode";
import { CodeMode } from "./CodeMode";
import { HomeMode } from "./HomeMode";
import { WorkMode } from "./WorkMode";
import { DocsMode } from "./DocsMode";
import { VoiceMode } from "./VoiceMode";
import { VisionMode } from "./VisionMode";
import { AgentsMode } from "./AgentsMode";
import { SettingsMode } from "./SettingsMode";
/** Renders the active mode's presentation layer. The session context / messages
* / memory live in useAppStore and are untouched by these switches. */
export function ModeRouter(): React.ReactNode {
const mode = useModeStore((s) => s.mode);
switch (mode) {
case "chat":
return <ChatMode />;
case "code":
return <CodeMode />;
case "home":
return <HomeMode />;
case "work":
return <WorkMode />;
case "docs":
return <DocsMode />;
case "voice":
return <VoiceMode />;
case "vision":
return <VisionMode />;
case "agents":
return <AgentsMode />;
case "settings":
return <SettingsMode />;
default:
return <ChatMode />;
}
}
+190
View File
@@ -0,0 +1,190 @@
import { useState } from "react";
import { useAppStore } from "../store/useAppStore";
import {
fetchPersonas,
fetchSessions,
getAuthToken,
login,
setAuthToken,
} from "../lib/api";
import { useAsyncData } from "../lib/useAsyncData";
import { connectWs, disconnectWs, sendUp } from "../lib/ws";
import { Button, Surface, Switch, Spinner, Tag } from "../components/primitives";
export function SettingsMode(): React.ReactNode {
const wsStatus = useAppStore((s) => s.wsStatus);
const [token, setToken] = useState<string | null>(getAuthToken());
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [authMsg, setAuthMsg] = useState<string | null>(null);
const [authErr, setAuthErr] = useState<string | null>(null);
const [authLoading, setAuthLoading] = useState(false);
const personas = useAsyncData(() => fetchPersonas(), [token]);
const sessions = useAsyncData(() => fetchSessions(), [token]);
const doLogin = () => {
if (!username || !password) return;
setAuthLoading(true);
setAuthErr(null);
setAuthMsg(null);
login(username, password)
.then((r) => {
setAuthToken(r.token);
setToken(r.token);
setAuthMsg(`已登录(${r.role}),令牌 ${Math.floor(r.expiresAt / 1000)} 到期`);
connectWs();
})
.catch((err: unknown) => {
setAuthErr(err instanceof Error ? err.message : "登录失败");
})
.finally(() => setAuthLoading(false));
};
const logout = () => {
setAuthToken(null);
setToken(null);
disconnectWs();
setAuthMsg(null);
};
const prefs = [
{ key: "a", label: "自动重连", on: true },
{ key: "b", label: "后台任务推送", on: true },
{ key: "c", label: "深色优先", on: true },
];
const [toggles, setToggles] = useState<Record<string, boolean>>(
Object.fromEntries(prefs.map((x) => [x.key, x.on]))
);
return (
<div className="mx-auto max-w-3xl space-y-5 p-4">
<div>
<h2 className="text-lg font-semibold">设置</h2>
<p className="text-sm text-[var(--mu-text-lo)]">系统 · 模型 · 插件 · 渠道</p>
</div>
{/* 连接 / 认证 */}
<Surface className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">连接</h3>
<Tag tone={wsStatus === "connected" ? "ok" : "neutral"}>{wsStatus}</Tag>
<span className="ml-auto flex gap-1.5">
<Button size="sm" variant="ghost" onClick={connectWs}>
连接
</Button>
<Button size="sm" variant="ghost" onClick={disconnectWs}>
断开
</Button>
</span>
</div>
{token ? (
<div className="space-y-2">
<div className="text-xs text-[var(--mu-text-mid)]">已持有 JWT(localStorage)</div>
<Button size="sm" variant="outline" onClick={logout}>
退出登录
</Button>
</div>
) : (
<div className="space-y-2">
<div className="text-xs text-[var(--mu-text-lo)]">
凭据来自后端 config(POST /api/v1/auth/login)
</div>
<div className="flex gap-2">
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="用户名"
className="flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none"
/>
<input
value={password}
type="password"
onChange={(e) => setPassword(e.target.value)}
placeholder="密码"
className="flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none"
/>
<Button variant="primary" onClick={doLogin} disabled={authLoading}>
{authLoading ? <Spinner size={14} /> : "登录"}
</Button>
</div>
{authMsg ? <div className="text-xs text-[var(--mu-ok)]">{authMsg}</div> : null}
{authErr ? <div className="text-xs text-[var(--mu-err)]">{authErr}</div> : null}
</div>
)}
</Surface>
{/* 人格 */}
<Surface className="space-y-2">
<h3 className="text-sm font-semibold">人格(GET /api/v1/personas)</h3>
{personas.loading ? (
<Spinner label="加载中…" />
) : personas.error ? (
<div className="text-xs text-[var(--mu-err)]">{personas.error}</div>
) : (personas.data?.length ?? 0) === 0 ? (
<div className="text-xs text-[var(--mu-text-lo)]">无人格数据(后端未接线)</div>
) : (
<div className="space-y-1.5">
{personas.data?.map((p) => (
<div key={p.id} className="flex items-center gap-2 text-sm">
<span className="font-medium">{p.name}</span>
{p.isDefault ? <Tag tone="accent">默认</Tag> : null}
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">{p.character}</span>
</div>
))}
</div>
)}
</Surface>
{/* 会话 */}
<Surface className="space-y-2">
<h3 className="text-sm font-semibold">会话(GET /api/v1/sessions)</h3>
{sessions.loading ? (
<Spinner label="加载中…" />
) : sessions.error ? (
<div className="text-xs text-[var(--mu-err)]">{sessions.error}</div>
) : (sessions.data?.length ?? 0) === 0 ? (
<div className="text-xs text-[var(--mu-text-lo)]">无会话数据</div>
) : (
<div className="space-y-1.5">
{sessions.data?.map((s0) => (
<div key={s0.sessionId} className="flex items-center gap-2 text-sm">
<span>{s0.platform}</span>
<Tag tone="neutral">{s0.actorRole}</Tag>
{s0.isGroup ? <Tag tone="warn">群聊</Tag> : null}
<span className="ml-auto truncate text-xs text-[var(--mu-text-lo)]">
{s0.sessionId}
</span>
</div>
))}
</div>
)}
</Surface>
{/* 模型 / 插件 / 渠道 */}
<Surface className="space-y-3">
<h3 className="text-sm font-semibold">模型 · 插件 · 渠道</h3>
{prefs.map((x) => (
<div key={x.key} className="flex items-center justify-between">
<span className="text-sm text-[var(--mu-text-mid)]">{x.label}</span>
<Switch
checked={toggles[x.key]}
onChange={(v) => setToggles((t) => ({ ...t, [x.key]: v }))}
label={x.label}
/>
</div>
))}
<div className="pt-1 text-xs text-[var(--mu-text-lo)]">
模型路由 / 降级、插件策略、渠道健康详见
<button
onClick={() => sendUp({ type: "mode_switch", data: { mode: "agents" } })}
className="ml-1 text-[var(--mu-accent-hi)]"
>
admin API
</button>
</div>
</Surface>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { useRef, useState } from "react";
import { Button, Surface, EmptyState, Tag } from "../components/primitives";
export function VisionMode(): React.ReactNode {
const [preview, setPreview] = useState<string | null>(null);
const [analyzing, setAnalyzing] = useState(false);
const [tags, setTags] = useState<string[]>([]);
const fileRef = useRef<HTMLInputElement>(null);
const onFile = (file: File | undefined) => {
if (!file) return;
setPreview(URL.createObjectURL(file));
setTags([]);
};
const analyze = () => {
if (!preview) return;
setAnalyzing(true);
// 占位分析:真实实现会调用视觉模型,此处演示加载态并给出结果。
window.setTimeout(() => {
setAnalyzing(false);
setTags(["图像", "占位分析", "视觉模型待接入"]);
}, 900);
};
return (
<div className="mx-auto max-w-3xl space-y-5 p-4">
<div>
<h2 className="text-lg font-semibold">视觉</h2>
<p className="text-sm text-[var(--mu-text-lo)]">图像分析</p>
</div>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => onFile(e.target.files?.[0])}
/>
<button
onClick={() => fileRef.current?.click()}
className="flex min-h-[16rem] w-full flex-col items-center justify-center gap-3 rounded-[var(--mu-radius-lg)] border border-dashed border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/40 p-4"
>
{preview ? (
<img src={preview} alt="预览" className="max-h-64 rounded-[var(--mu-radius)] object-contain" />
) : (
<EmptyState icon="👁" title="点击上传图片" hint="支持拖拽/点选,占位实现,视觉模型待接入" />
)}
</button>
<div className="flex items-center gap-2">
<Button variant="primary" onClick={analyze} disabled={!preview || analyzing}>
{analyzing ? "分析中…" : "开始分析"}
</Button>
{preview ? (
<Button
variant="ghost"
onClick={() => {
if (preview) URL.revokeObjectURL(preview);
setPreview(null);
setTags([]);
}}
>
清除
</Button>
) : null}
</div>
{tags.length > 0 ? (
<Surface className="flex flex-wrap items-center gap-2">
<span className="text-xs text-[var(--mu-text-lo)]">识别结果</span>
{tags.map((t) => (
<Tag key={t} tone="accent">
{t}
</Tag>
))}
</Surface>
) : null}
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useState } from "react";
import { Button, Surface } from "../components/primitives";
export function VoiceMode(): React.ReactNode {
const [calling, setCalling] = useState(false);
const [muted, setMuted] = useState(false);
const [secs, setSecs] = useState(0);
useEffect(() => {
if (!calling) return;
const id = window.setInterval(() => setSecs((s) => s + 1), 1000);
return () => window.clearInterval(id);
}, [calling]);
const mm = String(Math.floor(secs / 60)).padStart(2, "0");
const ss = String(secs % 60).padStart(2, "0");
const bars = Array.from({ length: 18 }, (_, i) => i);
return (
<div className="mx-auto flex h-full max-w-md flex-col items-center justify-center gap-6 p-4">
<div
className={`relative flex h-40 w-40 items-center justify-center rounded-full bg-[var(--mu-surface-4)] ${
calling ? "ring-2 ring-[var(--mu-accent)]" : ""
}`}
>
{/* live2D 预留位 */}
<span className="text-6xl opacity-80">🌙</span>
<span className="absolute bottom-2 text-xs text-[var(--mu-text-lo)]">live2D 预留</span>
</div>
{calling ? (
<div className="text-sm text-[var(--mu-text-mid)]">
通话中 · {mm}:{ss}
</div>
) : (
<div className="text-sm text-[var(--mu-text-lo)]">未在通话</div>
)}
{/* 波形 */}
<div className="flex h-10 items-end gap-1">
{bars.map((i) => (
<span
key={i}
className={`w-1 rounded-[var(--mu-radius-pill)] bg-[var(--mu-accent)] ${
calling ? "animate-pulse" : "opacity-30"
}`}
style={{ height: `${8 + ((i * 7) % 16)}px` }}
/>
))}
</div>
<Surface className="flex items-center gap-3">
<Button
variant={muted ? "danger" : "soft"}
onClick={() => setMuted((v) => !v)}
aria-label="静音"
>
{muted ? "🔇 已静音" : "🎙 静音"}
</Button>
<Button
variant={calling ? "danger" : "primary"}
onClick={() => {
setCalling((v) => !v);
if (calling) setSecs(0);
}}
>
{calling ? "挂断" : "开始通话"}
</Button>
</Surface>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { useState } from "react";
import { Button, Surface, Tag, EmptyState } from "../components/primitives";
import { now } from "../lib/util";
import { sendUp } from "../lib/ws";
interface WorkItem {
id: string;
title: string;
done: boolean;
due?: string;
}
export function WorkMode(): React.ReactNode {
const [title, setTitle] = useState("");
const [items, setItems] = useState<WorkItem[]>([
{ id: "w1", title: "季度汇报", done: false, due: "明日" },
{ id: "w2", title: "阅读设计稿 07-前端", done: true },
]);
const add = () => {
const t = title.trim();
if (!t) return;
setItems((xs) => [...xs, { id: `w-${now()}`, title: t, done: false }]);
setTitle("");
};
const toggle = (id: string) =>
setItems((xs) => xs.map((x) => (x.id === id ? { ...x, done: !x.done } : x)));
return (
<div className="mx-auto max-w-3xl space-y-5 p-4">
<div>
<h2 className="text-lg font-semibold">工作</h2>
<p className="text-sm text-[var(--mu-text-lo)]">任务 · 日历 · 提醒 · 文档产出</p>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{[
["任务", String(items.filter((i) => !i.done).length)],
["日历", "今日 2"],
["提醒", "1 条"],
["文档", "4 篇"],
].map(([k, v]) => (
<Surface key={k}>
<div className="text-xs text-[var(--mu-text-lo)]">{k}</div>
<div className="text-2xl font-semibold">{v}</div>
</Surface>
))}
</div>
<div>
<h3 className="mb-2 text-sm font-semibold">任务清单</h3>
<div className="mb-3 flex gap-2">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && add()}
placeholder="新增任务…"
className="flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none placeholder:text-[var(--mu-text-lo)]"
/>
<Button variant="primary" onClick={add}>
添加
</Button>
</div>
{items.length === 0 ? (
<EmptyState icon="🗂" title="暂无任务" />
) : (
<div className="space-y-2">
{items.map((it) => (
<Surface key={it.id} className="flex items-center gap-3 py-3">
<button
onClick={() => toggle(it.id)}
className={`inline-flex h-5 w-5 items-center justify-center rounded-full border text-xs ${
it.done
? "border-transparent bg-[var(--mu-accent)] text-white"
: "border-[var(--mu-surface-5)]"
}`}
>
{it.done ? "✓" : ""}
</button>
<span
className={`flex-1 text-sm ${it.done ? "line-through text-[var(--mu-text-lo)]" : ""}`}
>
{it.title}
</span>
{it.due ? <Tag tone="warn">{it.due}</Tag> : null}
</Surface>
))}
</div>
)}
</div>
<div>
<h3 className="mb-2 text-sm font-semibold">提醒</h3>
<Button size="sm" variant="ghost" onClick={() => sendUp({ type: "mode_switch", data: { mode: "chat" } })}>
通过对话设置提醒
</Button>
</div>
</div>
);
}