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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user