-
🚫
+
+
+
403 · 权限不足
当前角色 {role ?? "?"}(viewer 只读)。管理面板需 owner 或 admin 角色;管理端写操作仅 owner。
diff --git a/frontend/web/src/admin/components.tsx b/frontend/web/src/admin/components.tsx
index 6c4fdcc..ed6dca5 100644
--- a/frontend/web/src/admin/components.tsx
+++ b/frontend/web/src/admin/components.tsx
@@ -1,5 +1,6 @@
import type { ReactNode } from "react";
import { EmptyState, Tag, type TagTone } from "../components/primitives";
+import { Icon } from "../components/Icon";
export interface PageHeaderProps {
title: string;
@@ -93,7 +94,7 @@ export function AdminTable({
return {error}
;
}
if (!rows || rows.length === 0) {
- return ;
+ return } title={emptyTitle} />;
}
return (
diff --git a/frontend/web/src/admin/modules/Agents.tsx b/frontend/web/src/admin/modules/Agents.tsx
index 863288c..d2f88dc 100644
--- a/frontend/web/src/admin/modules/Agents.tsx
+++ b/frontend/web/src/admin/modules/Agents.tsx
@@ -5,6 +5,7 @@ import { Notice, PageHeader } from "../components";
import { Button, EmptyState, Surface, Tag, type TagTone } from "../../components/primitives";
import { sendUp } from "../../lib/ws";
import { useAuditStore } from "../audit";
+import { Icon } from "../../components/Icon";
const statusTone: Record
= {
idle: { label: "空闲", tone: "neutral" },
@@ -33,7 +34,9 @@ function AgentCard({ agent, onKill }: { agent: AgentInfo; onKill: (id: string) =
{agent.thinking}
) : null}
{agent.tools?.map((t, i) => (
- ⚙ {t.name}{t.ok === false ? " ✗" : ""}
+
+ {t.name}{t.ok === false ? : ""}
+
))}
{agent.message ? {agent.message}
: null}
@@ -96,7 +99,7 @@ export function AgentsModule(): React.ReactNode {
)}
{list.length === 0 ? (
-
+
} title={tab === "main" ? "无主会话 Agent" : "暂无子代理"} />
) : (
{list.map((a) => (
diff --git a/frontend/web/src/admin/modules/Automations.tsx b/frontend/web/src/admin/modules/Automations.tsx
index 901ba5e..787ba0d 100644
--- a/frontend/web/src/admin/modules/Automations.tsx
+++ b/frontend/web/src/admin/modules/Automations.tsx
@@ -4,6 +4,7 @@ import { adminListAutomations, isNotImplemented } from "../../lib/api";
import { useAuditStore } from "../audit";
import { Notice, PageHeader } from "../components";
import { Button, Surface, Switch, Tag } from "../../components/primitives";
+import { Icon } from "../../components/Icon";
interface ReminderRow {
id: string;
@@ -67,7 +68,7 @@ export function AutomationsModule(): React.ReactNode {
{["消息队列事件", "后台任务(含终端任务)", "运行日志"].map((x) => (
-
📦
+
{x} 占位 —— 后端 M10+ 提供
))}
diff --git a/frontend/web/src/admin/modules/Backthink.tsx b/frontend/web/src/admin/modules/Backthink.tsx
index 495726f..9852303 100644
--- a/frontend/web/src/admin/modules/Backthink.tsx
+++ b/frontend/web/src/admin/modules/Backthink.tsx
@@ -4,6 +4,7 @@ import { useAuditStore } from "../audit";
import { Notice, PageHeader } from "../components";
import { Button, EmptyState, Surface, Tag } from "../../components/primitives";
import type { BackthinkItem } from "../../lib/types";
+import { Icon } from "../../components/Icon";
function BackthinkRow({ item }: { item: BackthinkItem }): React.ReactNode {
const markPushed = useAppStore((s) => s.markBackthinkPushed);
@@ -78,7 +79,7 @@ export function BackthinkModule(): React.ReactNode {
{items.length === 0 ? (
-
+
} title="暂无后台思考" hint="后台任务完成时按 WS 事件实时呈现(backthink / thinking)。" />
) : (
{items.map((b) => (
diff --git a/frontend/web/src/admin/modules/Dashboard.tsx b/frontend/web/src/admin/modules/Dashboard.tsx
index 4f962a7..1fa997e 100644
--- a/frontend/web/src/admin/modules/Dashboard.tsx
+++ b/frontend/web/src/admin/modules/Dashboard.tsx
@@ -11,12 +11,13 @@ import { useAppStore } from "../../store/useAppStore";
import { navigateAdmin, type AdminSectionId } from "../router";
import { CardGrid, StatCard } from "../components";
import { Surface, Tag } from "../../components/primitives";
+import { Icon, type IconName } from "../../components/Icon";
import { useAutoReload } from "../hooks";
interface DashCard {
id: AdminSectionId;
label: string;
- icon: string;
+ icon: IconName;
value: number | string;
sub?: string;
tone: "ok" | "warn" | "accent" | "neutral" | "err";
@@ -52,16 +53,16 @@ export function DashboardModule(): React.ReactNode {
const runningAgents = agents.filter((a) => a.status === "running").length;
const cards: DashCard[] = [
- { id: "personas", label: "人格", icon: "🧬", value: nPersonas, sub: "启停/默认/路由", tone: "accent" },
- { id: "sessions", label: "活跃会话", icon: "💬", value: nSessions, sub: "渠道 × actor", tone: "ok" },
- { id: "memories", label: "记忆", icon: "🧠", value: nMemories, sub: "三类作用域隔离", tone: "accent" },
- { id: "plugins", label: "插件与能力", icon: "🔌", value: nPlugins, sub: "含 MCP/渠道", tone: "neutral" },
- { id: "channels", label: "渠道", icon: "📡", value: `${nChannels - offlineChannels}/${nChannels}`,
+ { id: "personas", label: "人格", icon: "dna", value: nPersonas, sub: "启停/默认/路由", tone: "accent" },
+ { id: "sessions", label: "活跃会话", icon: "chat", value: nSessions, sub: "渠道 × actor", tone: "ok" },
+ { id: "memories", label: "记忆", icon: "memory", value: nMemories, sub: "三类作用域隔离", tone: "accent" },
+ { id: "plugins", label: "插件与能力", icon: "plugin", value: nPlugins, sub: "含 MCP/渠道", tone: "neutral" },
+ { id: "channels", label: "渠道", icon: "channel", value: `${nChannels - offlineChannels}/${nChannels}`,
sub: offlineChannels > 0 ? `${offlineChannels} 离线` : "全部在线", tone: offlineChannels > 0 ? "warn" : "ok" },
- { id: "iot", label: "IoT 设备", icon: "📲", value: `${onlineDevices}/${nDevices}`,
+ { id: "iot", label: "IoT 设备", icon: "device", value: `${onlineDevices}/${nDevices}`,
sub: onlineDevices === nDevices && nDevices > 0 ? "全部在线" : "设备状态", tone: nDevices > 0 && onlineDevices === nDevices ? "ok" : "neutral" },
- { id: "agents", label: "Agent 运行", icon: "🧩", value: runningAgents, sub: `${agents.length} 个已登记`, tone: runningAgents > 0 ? "accent" : "neutral" },
- { id: "backthink", label: "后台思考活跃度", icon: "🌙", value: backthink.length, sub: "实时 (WS)", tone: backthink.length > 0 ? "warn" : "neutral" },
+ { id: "agents", label: "Agent 运行", icon: "agent", value: runningAgents, sub: `${agents.length} 个已登记`, tone: runningAgents > 0 ? "accent" : "neutral" },
+ { id: "backthink", label: "后台思考活跃度", icon: "night", value: backthink.length, sub: "实时 (WS)", tone: backthink.length > 0 ? "warn" : "neutral" },
];
return (
@@ -82,7 +83,7 @@ export function DashboardModule(): React.ReactNode {
{cards.map((c) => (
))}
diff --git a/frontend/web/src/admin/modules/Observability.tsx b/frontend/web/src/admin/modules/Observability.tsx
index 549019f..f136a2e 100644
--- a/frontend/web/src/admin/modules/Observability.tsx
+++ b/frontend/web/src/admin/modules/Observability.tsx
@@ -3,6 +3,7 @@ import { useAppStore } from "../../store/useAppStore";
import { useAuditStore } from "../audit";
import { CardGrid, Notice, PageHeader, StatCard } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
+import { Icon } from "../../components/Icon";
type Tab = "logs" | "trace" | "metrics" | "audit";
@@ -84,10 +85,10 @@ function MetricsView(): React.ReactNode {
return (
-
-
-
-
+ } label="请求 QPS" value="—" sub="网关指标占位" tone="accent" />
+ } label="LLM 调用次数" value="—" sub="用量/费用占位" tone="neutral" />
+ } label="子代理事件" value="—" sub="编排指标占位" tone="ok" />
+ } label="工时/Token" value="—" sub="成本占位" tone="warn" />
指标时序图 / 导出需后端 M10+ 提供。
diff --git a/frontend/web/src/admin/modules/System.tsx b/frontend/web/src/admin/modules/System.tsx
index 1a5e9b5..08b6d5e 100644
--- a/frontend/web/src/admin/modules/System.tsx
+++ b/frontend/web/src/admin/modules/System.tsx
@@ -3,6 +3,7 @@ import { getStoredRole } from "../../lib/api";
import { useAuditStore } from "../audit";
import { KeyValueRow, Notice, PageHeader } from "../components";
import { Button, Surface, Tag } from "../../components/primitives";
+import { Icon } from "../../components/Icon";
export function SystemModule(): React.ReactNode {
const isOwner = getStoredRole() === "owner";
@@ -24,7 +25,7 @@ export function SystemModule(): React.ReactNode {
-
+ } title="安全 & 密钥" ownerOnly />
@@ -35,7 +36,7 @@ export function SystemModule(): React.ReactNode {
-
+ } title="存储" />
@@ -46,7 +47,7 @@ export function SystemModule(): React.ReactNode {
-
+ } title="环境变量" />
@@ -56,7 +57,7 @@ export function SystemModule(): React.ReactNode {
-
+ } title="运行时" />
日志级别
@@ -85,10 +86,10 @@ export function SystemModule(): React.ReactNode {
);
}
-function SectionTitle({ icon, title, ownerOnly = false }: { icon: string; title: string; ownerOnly?: boolean }): React.ReactNode {
+function SectionTitle({ icon, title, ownerOnly = false }: { icon: React.ReactNode; title: string; ownerOnly?: boolean }): React.ReactNode {
return (
- {icon}
+ {icon}
{title}
{ownerOnly ? owner : null}
diff --git a/frontend/web/src/components/ChatInput.tsx b/frontend/web/src/components/ChatInput.tsx
index 63683b6..b395af9 100644
--- a/frontend/web/src/components/ChatInput.tsx
+++ b/frontend/web/src/components/ChatInput.tsx
@@ -1,13 +1,25 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "./primitives";
+import { Icon, type IconName } from "./Icon";
+import { useModeStore } from "../store/useModeStore";
+import type { Mode } from "../lib/types";
export interface ChatInputProps {
- onSend: (text: string) => void;
+ onSend: (text: string, mode: Mode) => void;
sending: boolean;
onVoice: () => void;
placeholder?: string;
}
+const INPUT_MODES: ReadonlyArray<{ mode: Mode; label: string; icon: IconName }> = [
+ { mode: "chat", label: "对话", icon: "chat" },
+ { mode: "code", label: "代码", icon: "terminal" },
+ { mode: "home", label: "家居", icon: "home" },
+ { mode: "work", label: "工作", icon: "folder" },
+ { mode: "docs", label: "知识库", icon: "docs" },
+ { mode: "vision", label: "视觉", icon: "vision" },
+];
+
export function ChatInput({
onSend,
sending,
@@ -16,6 +28,8 @@ export function ChatInput({
}: ChatInputProps): React.ReactNode {
const [value, setValue] = useState("");
const taRef = useRef(null);
+ const mode = useModeStore((s) => s.mode);
+ const setMode = useModeStore((s) => s.setMode);
// auto-grow the textarea up to a max height
useEffect(() => {
@@ -28,12 +42,31 @@ export function ChatInput({
const submit = () => {
const t = value.trim();
if (!t || sending) return;
- onSend(t);
+ onSend(t, mode);
setValue("");
};
return (
+ {/* input-mode switcher: sets the mode for the NEXT message (design/07 §1). */}
+
+ 行为模式
+ {INPUT_MODES.map(({ mode: m, label, icon }) => (
+
+ ))}
+
+
@@ -52,14 +67,14 @@ export function TopBar(): React.ReactNode {
title="管理面板 /admin"
className="inline-flex h-8 w-8 items-center justify-center rounded-[var(--mu-radius)] bg-[var(--mu-surface-4)] text-sm transition-all duration-200 ease-[var(--mu-spring)] hover:bg-[var(--mu-surface-5)] active:scale-95"
>
- 🛡
+
);
diff --git a/frontend/web/src/components/chat.tsx b/frontend/web/src/components/chat.tsx
index 8055303..6c7252f 100644
--- a/frontend/web/src/components/chat.tsx
+++ b/frontend/web/src/components/chat.tsx
@@ -1,6 +1,8 @@
import { useState } from "react";
-import type { ChatMessage, ToolUsed } from "../lib/types";
+import type { ChatMessage, Mode, ToolUsed } from "../lib/types";
import { Tag } from "./primitives";
+import { Icon, type IconName } from "./Icon";
+import { modeBody } from "./messages";
/** Collapsible inline chain-of-thought block. */
export function ThinkingBlock({
@@ -52,7 +54,10 @@ export function ToolCard({ tool }: { tool: ToolUsed }): React.ReactNode {
▸
- {tool.ok === false ? "✗" : tool.ok === true ? "✓" : "⚙"} {tool.name}
+
+
+ {tool.name}
+
{tool.summary ? (
{tool.summary}
@@ -67,36 +72,75 @@ export function ToolCard({ tool }: { tool: ToolUsed }): React.ReactNode {
);
}
-/** A single chat message bubble. */
+/** A single message bubble. Per design/07 §1 the bubble shape is driven by the
+ * message's own `mode`: the same role renders differently under different modes,
+ * and a single session may mix shapes. Falls back to `chat` when mode is absent
+ * (legacy persisted messages). */
export function MessageBubble({ message }: { message: ChatMessage }): React.ReactNode {
- const isUser = message.role === "user";
- if (isUser) {
- return (
-
-
- {message.content}
-
-
- );
+ const mode = message.mode ?? "chat";
+ if (message.role === "user") {
+ return
;
}
+ return
;
+}
+/** User-side bubble. The outer accent pill is shared; the inner content may be
+ * mode-shaped (e.g. code request renders in a mono pre). */
+function UserBubble({ message, mode }: { message: ChatMessage; mode: Mode }): React.ReactNode {
+ return (
+
+
+ {mode === "code" ? (
+
{message.content}
+ ) : (
+
{message.content}
+ )}
+
+
+ );
+}
+
+/** Assistant-side bubble. Outer layout shared; the body renders per mode via the
+ * mode-specific body (chat / code / docs / home / work / vision …). */
+function AssistantBubble({ message, mode }: { message: ChatMessage; mode: Mode }): React.ReactNode {
return (
+
{message.mood ? (
心情 · {message.mood}
) : null}
{message.tools?.map((t, i) =>
)}
-
- {message.content ? (
- {message.content}
- ) : message.streaming ? (
- 生成中…
- ) : null}
-
+ {modeBody(mode, message)}
);
}
+
+/** Small chip showing the message's behavior mode. */
+function ModeTag({ mode }: { mode: Mode }): React.ReactNode {
+ if (mode === "chat") return null;
+ return (
+
+
+ {modeLabel(mode)}
+
+ );
+}
+
+function modeLabel(mode: Mode): string {
+ const map: Record
= {
+ chat: "对话", code: "代码", home: "家居", work: "工作",
+ docs: "知识库", voice: "语音", vision: "视觉", agents: "子代理", settings: "设置",
+ };
+ return map[mode];
+}
+function modeIcon(mode: Mode): IconName {
+ const map: Record = {
+ chat: "chat", code: "terminal", home: "home", work: "folder",
+ docs: "docs", voice: "mic", vision: "vision", agents: "agent", settings: "settings",
+ };
+ return map[mode];
+}
diff --git a/frontend/web/src/components/messages/index.tsx b/frontend/web/src/components/messages/index.tsx
new file mode 100644
index 0000000..a3e582e
--- /dev/null
+++ b/frontend/web/src/components/messages/index.tsx
@@ -0,0 +1,158 @@
+import type { ReactNode } from "react";
+import type { ChatMessage, Mode } from "../../lib/types";
+import { Tag } from "../primitives";
+import { Icon } from "../Icon";
+
+/**
+ * Mode-specific assistant message bodies (design/07 §2). The shared outer
+ * layout (avatar / mood / thinking / tools) lives in MessageBubble — these
+ * components only render the content region differently per mode, so the same
+ * role under different modes looks different while a single session mixes them.
+ */
+
+function shell(children: ReactNode): ReactNode {
+ return (
+
+ {children}
+
+ );
+}
+
+function fallbackText(message: ChatMessage): ReactNode {
+ return message.content ? (
+ {message.content}
+ ) : message.streaming ? (
+ 生成中…
+ ) : null;
+}
+
+/** code — mono code block + optional terminal-ish stream, not a plain bubble. */
+export function CodeMessageBody({ message }: { message: ChatMessage }): ReactNode {
+ return (
+
+ {message.content ? (
+
+ {message.content}
+
+ ) : message.streaming ? (
+
正在执行 / 生成中…
+ ) : null}
+
+ );
+}
+
+/** docs — knowledge-base document card. */
+export function DocsMessageBody({ message }: { message: ChatMessage }): ReactNode {
+ return (
+
+ {message.content ? (
+
+
+
+
{message.content}
+
知识库
+
+
+ ) : fallbackText(message)}
+
+ );
+}
+
+/** home — device card line (icons + state chips). */
+export function HomeMessageBody({ message }: { message: ChatMessage }): ReactNode {
+ const devices = sampleDevices(message.content);
+ return (
+
+ {message.content ? (
+
{message.content}
+ ) : null}
+ {devices.length > 0 ? (
+
+ {devices.map((d) => (
+
+
+
+ {d}
+
+
+ ))}
+
+ ) : null}
+ {message.streaming ?
设备状态更新中…
: null}
+
+ );
+}
+
+/** work — task checklist card. */
+export function WorkMessageBody({ message }: { message: ChatMessage }): ReactNode {
+ const tasks = (message.content ?? "").split("\n").filter((l) => l.trim());
+ return (
+
+ {tasks.length > 0 ? (
+
+ {tasks.map((t, i) => (
+
+
+ {t}
+
+ ))}
+
+ ) : fallbackText(message)}
+
+ );
+}
+
+/** vision — image analysis summary card. */
+export function VisionMessageBody({ message }: { message: ChatMessage }): ReactNode {
+ return shell(message.content ? (
+
+
+
+ 视觉分析
+
+
{message.content}
+
+ ) : fallbackText(message));
+}
+
+/** voice — speech session card. */
+export function VoiceMessageBody({ message }: { message: ChatMessage }): ReactNode {
+ return shell(message.content ? (
+
+
+ {message.content}
+
+ ) : fallbackText(message));
+}
+
+/** agents — sub-agent progress card (kept for the shared flow; monitors live in messages). */
+export function AgentsMessageBody({ message }: { message: ChatMessage }): ReactNode {
+ return shell({message.content || fallbackText(message)});
+}
+
+/** Reserved — settings is managed in the admin panel; an opaque content fallback. */
+export function SettingsMessageBody({ message }: { message: ChatMessage }): ReactNode {
+ return shell(fallbackText(message));
+}
+
+/** Map a mode to its message body component for the dispatcher. */
+export function modeBody(mode: Mode, message: ChatMessage): ReactNode {
+ switch (mode) {
+ case "code": return ;
+ case "docs": return ;
+ case "home": return ;
+ case "work": return ;
+ case "vision": return ;
+ case "voice": return ;
+ case "agents": return ;
+ case "settings": return ;
+ default: return {fallbackText(message)}
;
+ }
+}
+
+/** tiny sample device extraction for the home card demo (content contains names). */
+function sampleDevices(content: string): string[] {
+ if (!content) return [];
+ const known = ["灯", "空调", "插座", "窗帘", "电视", "加湿器"];
+ return known.filter((k) => content.includes(k));
+}
diff --git a/frontend/web/src/lib/api.ts b/frontend/web/src/lib/api.ts
index 7df664c..422b10d 100644
--- a/frontend/web/src/lib/api.ts
+++ b/frontend/web/src/lib/api.ts
@@ -9,6 +9,7 @@ import type {
ChatSyncResult,
LoginResult,
MemoryRecord,
+ Mode,
PersonaRecord,
Role,
SessionRecord,
@@ -154,10 +155,10 @@ export function chatSync(
}
/** POST /api/v1/chat with stream:true — triggers streaming over the WS channel. */
-export function chatStream(sessionId: string, text: string): Promise<{ stream: true }> {
+export function chatStream(sessionId: string, text: string, mode?: Mode): Promise<{ stream: true }> {
return request<{ stream: true }>("/chat", {
method: "POST",
- body: JSON.stringify({ sessionId, text, stream: true }),
+ body: JSON.stringify({ sessionId, text, stream: true, mode }),
});
}
diff --git a/frontend/web/src/lib/types.ts b/frontend/web/src/lib/types.ts
index 6d1639b..b3d3efa 100644
--- a/frontend/web/src/lib/types.ts
+++ b/frontend/web/src/lib/types.ts
@@ -44,6 +44,9 @@ export interface ChatMessage {
id: string;
role: "user" | "assistant" | "system";
content: string;
+ /** Behavior mode this message was produced in — drives its bubble rendering.
+ * Each message carries its own mode, so a single session can mix bubble shapes. */
+ mode?: Mode;
streaming?: boolean;
thinking?: string;
mood?: string;
diff --git a/frontend/web/src/lib/useChat.ts b/frontend/web/src/lib/useChat.ts
index 24e7454..9a8c453 100644
--- a/frontend/web/src/lib/useChat.ts
+++ b/frontend/web/src/lib/useChat.ts
@@ -2,14 +2,19 @@ import { useCallback, useState } from "react";
import { chatStream } from "./api";
import { connectWs, isWsOpen } from "./ws";
import { useAppStore } from "../store/useAppStore";
+import { useModeStore } from "../store/useModeStore";
import { now } from "./util";
+import type { Mode } from "./types";
/** Drives one user turn: push the user bubble, blanket a streaming assistant
* bubble, trigger the backend loop over REST (`stream:true`), and let the WS
* stream render into that bubble. Falls back to the WS `chat` channel when the
- * REST trigger fails so the demo still works without a wired loop. */
+ * REST trigger fails so the demo still works without a wired loop.
+ *
+ * Each message carries the current input mode, so its bubble renders per that
+ * mode (design/07 §1: shared message stream, per-message mode rendering). */
export function useSendChat(): {
- send: (text: string) => Promise;
+ send: (text: string, mode?: Mode) => Promise;
sending: boolean;
} {
const [sending, setSending] = useState(false);
@@ -22,17 +27,18 @@ export function useSendChat(): {
const appendTaskLog = useAppStore((s) => s.appendTaskLog);
const send = useCallback(
- async (text: string) => {
+ async (text: string, mode?: Mode) => {
const trimmed = text.trim();
if (!trimmed || sending) return;
setSending(true);
if (!isWsOpen()) connectWs();
- addUserMessage(trimmed);
- beginAssistant();
+ const m = mode ?? "chat";
+ addUserMessage(trimmed, m);
+ beginAssistant(m);
try {
- await chatStream(sessionId, trimmed);
+ await chatStream(sessionId, trimmed, m);
} catch (err: unknown) {
// Backend loop not wired or gateway unreachable: show the failure inline
// rather than leaving a permanently-streaming bubble.
diff --git a/frontend/web/src/modes/AgentsMode.tsx b/frontend/web/src/modes/AgentsMode.tsx
deleted file mode 100644
index 96e54ef..0000000
--- a/frontend/web/src/modes/AgentsMode.tsx
+++ /dev/null
@@ -1,144 +0,0 @@
-import { useAppStore } from "../store/useAppStore";
-import { sendUp } from "../lib/ws";
-import type { AgentInfo, AgentStatus, FlowGraph } from "../lib/types";
-import { Button, EmptyState, Surface, Tag, type TagTone } from "../components/primitives";
-
-const statusTone: Record = {
- 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 (
-
- );
-}
-
-function AgentCard({ agent }: { agent: AgentInfo }): React.ReactNode {
- const meta = statusTone[agent.status] ?? statusTone.idle;
- return (
-
-
-
- {agent.name}
- {agent.role ? (
- {agent.role}
- ) : null}
-
- {meta.label}
-
-
- {typeof agent.progress === "number" ? (
-
- ) : null}
- {agent.thinking ? (
-
- {agent.thinking}
-
- ) : null}
- {agent.tools?.map((t, i) => (
-
- ⚙ {t.name}
- {t.ok === false ? " ✗" : ""}
-
- ))}
- {agent.message ? (
- {agent.message}
- ) : null}
-
- );
-}
-
-function FlowGraphView({ graph }: { graph: FlowGraph | null }): React.ReactNode {
- if (!graph) {
- return (
-
- 流程图占位 —— 连接实时通道后按 graph_update 事件渲染
-
- );
- }
- return (
-
- {graph.nodes.map((n, i) => (
-
- {n.label}
- {i < graph.nodes.length - 1 ? → : null}
-
- ))}
-
- );
-}
-
-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 (
-
-
-
Agent 运行监控
-
主会话 + 子代理实时状态
-
-
-
-
- {agents.length === 0 ? (
-
- ) : (
-
- {agents.map((a) => (
-
- ))}
-
- )}
-
-
-
-
后台思考
- backthink
-
- {backthink.length === 0 ? (
-
- ) : (
-
- {backthink.map((b) => (
-
- {b.trigger}
-
- {b.shouldNotify ? "需要打扰" : "静默"}
-
- {b.pushed ? "已推送" : "未推送"}
- {b.conclusion ? (
- {b.conclusion}
- ) : null}
-
-
-
-
- ))}
-
- )}
-
-
- );
-}
diff --git a/frontend/web/src/modes/ChatMode.tsx b/frontend/web/src/modes/ChatShell.tsx
similarity index 62%
rename from frontend/web/src/modes/ChatMode.tsx
rename to frontend/web/src/modes/ChatShell.tsx
index 614734b..6b24e7b 100644
--- a/frontend/web/src/modes/ChatMode.tsx
+++ b/frontend/web/src/modes/ChatShell.tsx
@@ -5,12 +5,19 @@ import { useSendChat } from "../lib/useChat";
import { MessageBubble } from "../components/chat";
import { ChatInput } from "../components/ChatInput";
import { EmptyState } from "../components/primitives";
+import { Icon } from "../components/Icon";
-export function ChatMode(): React.ReactNode {
+/**
+ * The single shared page (design/07 §1). All behavior modes live in ONE message
+ * stream; each message carries its own `mode`, so its bubble renders per that
+ * mode — a session can mix chat / code / home / docs / work / vision shapes.
+ * The "input mode" chip above the composer sets the mode of the NEXT message.
+ */
+export function ChatShell(): React.ReactNode {
const messages = useAppStore((s) => s.messages);
- const setMode = useModeStore((s) => s.setMode);
const { send, sending } = useSendChat();
const bottomRef = useRef(null);
+ const setMode = useModeStore((s) => s.setMode);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
@@ -22,9 +29,9 @@ export function ChatMode(): React.ReactNode {
{messages.length === 0 ? (
}
title="开始与昔涟对话"
- hint="模式只改变呈现层,会话上下文与记忆保持不变"
+ hint="模式只改变消息框的呈现方式,会话上下文与记忆保持不变"
/>
) : (
messages.map((m) =>
)
@@ -32,11 +39,7 @@ export function ChatMode(): React.ReactNode {
- void send(t)}
- sending={sending}
- onVoice={() => setMode("voice")}
- />
+ void send(t, mode)} sending={sending} onVoice={() => setMode("voice")} />
);
}
diff --git a/frontend/web/src/modes/CodeMode.tsx b/frontend/web/src/modes/CodeMode.tsx
deleted file mode 100644
index 1ea506a..0000000
--- a/frontend/web/src/modes/CodeMode.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-import { useEffect, useRef } from "react";
-import { useAppStore } from "../store/useAppStore";
-import { sendUp } from "../lib/ws";
-import { Button, Surface } from "../components/primitives";
-
-const SAMPLE_CODE = `// cyrene/example.ts — 示例文件
-export async function greet(name: string): Promise {
- return \`你好,\${name}!\`;
-}
-
-// 在代码模式里,昔涟可在此展示/编辑代码、
-// 运行测试、查看终端输出,全程与聊天共享同一会话上下文。`;
-
-export function CodeMode(): React.ReactNode {
- const taskLogs = useAppStore((s) => s.taskLogs);
- const appendTaskLog = useAppStore((s) => s.appendTaskLog);
- const termRef = useRef(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 (
-
-
-
- 代码
- example.ts
-
-
-
- {SAMPLE_CODE}
-
-
-
-
-
- 终端操作日志
- 后台任务实时输出
-
-
-
-
-
-
- {mainLog || "(暂无输出 —— 点「运行 demo」或等待后台任务)"}
-
-
-
- );
-}
diff --git a/frontend/web/src/modes/DocsMode.tsx b/frontend/web/src/modes/DocsMode.tsx
deleted file mode 100644
index 34ef64e..0000000
--- a/frontend/web/src/modes/DocsMode.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import { useState } from "react";
-import { Surface, Tag, EmptyState } from "../components/primitives";
-
-const DOCS = [
- { id: "d1", title: "设计 · 前端模态 UI(主题 H)", kind: "设计", updated: "09-06" },
- { id: "d2", title: "客户端 API 契约 v0.2", kind: "API", updated: "09-05" },
- { id: "d3", title: "角色 · 人格 · 行为模式", kind: "设计", updated: "09-05" },
- { id: "d4", title: "Miuix 设计基准", kind: "规范", updated: "09-04" },
-];
-
-export function DocsMode(): React.ReactNode {
- const [query, setQuery] = useState("");
- const [filtered, setFiltered] = useState(DOCS);
-
- const search = () => {
- const q = query.trim().toLowerCase();
- setFiltered(q ? DOCS.filter((d) => d.title.toLowerCase().includes(q)) : DOCS);
- };
-
- return (
-
-
-
-
- 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)]"
- />
-
-
-
- {filtered.length === 0 ? (
-
- ) : (
-
- {filtered.map((d) => (
-
- 📄
-
-
{d.title}
-
更新于 {d.updated}
-
- {d.kind}
-
- ))}
-
- )}
-
- );
-}
diff --git a/frontend/web/src/modes/HomeMode.tsx b/frontend/web/src/modes/HomeMode.tsx
deleted file mode 100644
index 6921edb..0000000
--- a/frontend/web/src/modes/HomeMode.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import { useState } from "react";
-import { useAppStore } from "../store/useAppStore";
-import { Surface, Switch, Tag, EmptyState } from "../components/primitives";
-
-interface Device {
- id: string;
- name: string;
- icon: string;
- on: boolean;
- room: string;
-}
-
-const INITIAL_DEVICES: Device[] = [
- { id: "light1", name: "客厅吊灯", icon: "💡", on: true, room: "客厅" },
- { id: "ac1", name: "主卧空调", icon: "❄️", on: false, room: "主卧" },
- { id: "plug1", name: "智能插座", icon: "🔌", on: false, room: "书房" },
-];
-
-export function HomeMode(): React.ReactNode {
- const deviceUpdates = useAppStore((s) => s.deviceUpdates);
- const [devices, setDevices] = useState(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 (
-
-
-
家居
-
设备 · 场景 · 能耗 · 自动化
-
-
-
-
- 在线设备
- {devices.length}
-
-
- 已开启
- {active}
-
-
- 今日能耗
- 2.4 kWh
-
-
-
-
-
设备
-
- {devices.map((d) => (
-
- {d.icon}
-
- toggle(d.id)} label={d.name} />
-
- ))}
-
-
-
-
-
场景
-
- {["回家", "观影", "睡眠", "离家"].map((s) => (
-
- {s}
-
- ))}
-
-
-
-
-
实时设备事件
- {deviceUpdates.length === 0 ? (
-
- ) : (
-
- {deviceUpdates.slice(0, 8).map((u, i) => (
-
- {u}
-
- ))}
-
- )}
-
-
- );
-}
diff --git a/frontend/web/src/modes/ModeRouter.tsx b/frontend/web/src/modes/ModeRouter.tsx
index b0f7a8a..1ab1c34 100644
--- a/frontend/web/src/modes/ModeRouter.tsx
+++ b/frontend/web/src/modes/ModeRouter.tsx
@@ -1,38 +1,12 @@
-import { useModeStore } from "../store/useModeStore";
-import { ChatMode } from "./ChatMode";
-import { CodeMode } from "./CodeMode";
-import { HomeMode } from "./HomeMode";
-import { WorkMode } from "./WorkMode";
-import { DocsMode } from "./DocsMode";
-import { VoiceMode } from "./VoiceMode";
-import { VisionMode } from "./VisionMode";
-import { AgentsMode } from "./AgentsMode";
-import { SettingsMode } from "./SettingsMode";
+import { ChatShell } from "./ChatShell";
-/** Renders the active mode's presentation layer. The session context / messages
- * / memory live in useAppStore and are untouched by these switches. */
+/**
+ * User frontend is ONE shared page (design/07 §1): behavior modes no longer
+ * switch between nine distinct pages. Instead there is a single message stream
+ * and each message carries its own mode, so its bubble renders per that mode.
+ * Switching the input mode only shapes the next message. Settings live in the
+ * admin panel (design/10); agent progress follows the message stream.
+ */
export function ModeRouter(): React.ReactNode {
- const mode = useModeStore((s) => s.mode);
- switch (mode) {
- case "chat":
- return ;
- case "code":
- return ;
- case "home":
- return ;
- case "work":
- return ;
- case "docs":
- return ;
- case "voice":
- return ;
- case "vision":
- return ;
- case "agents":
- return ;
- case "settings":
- return ;
- default:
- return ;
- }
+ return ;
}
diff --git a/frontend/web/src/modes/SettingsMode.tsx b/frontend/web/src/modes/SettingsMode.tsx
deleted file mode 100644
index 35232c9..0000000
--- a/frontend/web/src/modes/SettingsMode.tsx
+++ /dev/null
@@ -1,190 +0,0 @@
-import { useState } from "react";
-import { useAppStore } from "../store/useAppStore";
-import {
- fetchPersonas,
- fetchSessions,
- getAuthToken,
- login,
- setAuthToken,
-} from "../lib/api";
-import { useAsyncData } from "../lib/useAsyncData";
-import { connectWs, disconnectWs, sendUp } from "../lib/ws";
-import { Button, Surface, Switch, Spinner, Tag } from "../components/primitives";
-
-export function SettingsMode(): React.ReactNode {
- const wsStatus = useAppStore((s) => s.wsStatus);
- const [token, setToken] = useState(getAuthToken());
- const [username, setUsername] = useState("");
- const [password, setPassword] = useState("");
- const [authMsg, setAuthMsg] = useState(null);
- const [authErr, setAuthErr] = useState(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>(
- Object.fromEntries(prefs.map((x) => [x.key, x.on]))
- );
-
- return (
-
-
-
设置
-
系统 · 模型 · 插件 · 渠道
-
-
- {/* 连接 / 认证 */}
-
-
-
连接
- {wsStatus}
-
-
-
-
-
-
- {token ? (
-
-
已持有 JWT(localStorage)
-
-
- ) : (
-
-
- 凭据来自后端 config(POST /api/v1/auth/login)
-
-
- 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"
- />
- 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"
- />
-
-
- {authMsg ?
{authMsg}
: null}
- {authErr ?
{authErr}
: null}
-
- )}
-
-
- {/* 人格 */}
-
- 人格(GET /api/v1/personas)
- {personas.loading ? (
-
- ) : personas.error ? (
- {personas.error}
- ) : (personas.data?.length ?? 0) === 0 ? (
- 无人格数据(后端未接线)
- ) : (
-
- {personas.data?.map((p) => (
-
- {p.name}
- {p.isDefault ? 默认 : null}
- {p.character}
-
- ))}
-
- )}
-
-
- {/* 会话 */}
-
- 会话(GET /api/v1/sessions)
- {sessions.loading ? (
-
- ) : sessions.error ? (
- {sessions.error}
- ) : (sessions.data?.length ?? 0) === 0 ? (
- 无会话数据
- ) : (
-
- {sessions.data?.map((s0) => (
-
- {s0.platform}
- {s0.actorRole}
- {s0.isGroup ? 群聊 : null}
-
- {s0.sessionId}
-
-
- ))}
-
- )}
-
-
- {/* 模型 / 插件 / 渠道 */}
-
- 模型 · 插件 · 渠道
- {prefs.map((x) => (
-
- {x.label}
- setToggles((t) => ({ ...t, [x.key]: v }))}
- label={x.label}
- />
-
- ))}
-
- 模型路由 / 降级、插件策略、渠道健康详见
-
-
-
-
- );
-}
diff --git a/frontend/web/src/modes/VisionMode.tsx b/frontend/web/src/modes/VisionMode.tsx
deleted file mode 100644
index 51587b2..0000000
--- a/frontend/web/src/modes/VisionMode.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import { useRef, useState } from "react";
-import { Button, Surface, EmptyState, Tag } from "../components/primitives";
-
-export function VisionMode(): React.ReactNode {
- const [preview, setPreview] = useState(null);
- const [analyzing, setAnalyzing] = useState(false);
- const [tags, setTags] = useState([]);
- const fileRef = useRef(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 (
-
-
-
-
onFile(e.target.files?.[0])}
- />
-
-
-
-
-
- {preview ? (
-
- ) : null}
-
-
- {tags.length > 0 ? (
-
- 识别结果
- {tags.map((t) => (
-
- {t}
-
- ))}
-
- ) : null}
-
- );
-}
diff --git a/frontend/web/src/modes/VoiceMode.tsx b/frontend/web/src/modes/VoiceMode.tsx
deleted file mode 100644
index 16e9332..0000000
--- a/frontend/web/src/modes/VoiceMode.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import { useEffect, useState } from "react";
-import { Button, Surface } from "../components/primitives";
-
-export function VoiceMode(): React.ReactNode {
- const [calling, setCalling] = useState(false);
- const [muted, setMuted] = useState(false);
- const [secs, setSecs] = useState(0);
-
- useEffect(() => {
- if (!calling) return;
- const id = window.setInterval(() => setSecs((s) => s + 1), 1000);
- return () => window.clearInterval(id);
- }, [calling]);
-
- const mm = String(Math.floor(secs / 60)).padStart(2, "0");
- const ss = String(secs % 60).padStart(2, "0");
- const bars = Array.from({ length: 18 }, (_, i) => i);
-
- return (
-
-
- {/* live2D 预留位 */}
- 🌙
- live2D 预留
-
-
- {calling ? (
-
- 通话中 · {mm}:{ss}
-
- ) : (
-
未在通话
- )}
-
- {/* 波形 */}
-
- {bars.map((i) => (
-
- ))}
-
-
-
-
-
-
-
- );
-}
diff --git a/frontend/web/src/modes/WorkMode.tsx b/frontend/web/src/modes/WorkMode.tsx
deleted file mode 100644
index 4e7df77..0000000
--- a/frontend/web/src/modes/WorkMode.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import { useState } from "react";
-import { Button, Surface, Tag, EmptyState } from "../components/primitives";
-import { now } from "../lib/util";
-import { sendUp } from "../lib/ws";
-
-interface WorkItem {
- id: string;
- title: string;
- done: boolean;
- due?: string;
-}
-
-export function WorkMode(): React.ReactNode {
- const [title, setTitle] = useState("");
- const [items, setItems] = useState([
- { 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 (
-
-
-
工作
-
任务 · 日历 · 提醒 · 文档产出
-
-
-
- {[
- ["任务", String(items.filter((i) => !i.done).length)],
- ["日历", "今日 2"],
- ["提醒", "1 条"],
- ["文档", "4 篇"],
- ].map(([k, v]) => (
-
- {k}
- {v}
-
- ))}
-
-
-
-
任务清单
-
- 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)]"
- />
-
-
- {items.length === 0 ? (
-
- ) : (
-
- {items.map((it) => (
-
-
-
- {it.title}
-
- {it.due ? {it.due} : null}
-
- ))}
-
- )}
-
-
-
-
提醒
-
-
-
- );
-}
diff --git a/frontend/web/src/store/useAppStore.ts b/frontend/web/src/store/useAppStore.ts
index e07351e..ee29668 100644
--- a/frontend/web/src/store/useAppStore.ts
+++ b/frontend/web/src/store/useAppStore.ts
@@ -5,6 +5,7 @@ import type {
BackthinkItem,
ChatMessage,
FlowGraph,
+ Mode,
ToolUsed,
WsStatus,
} from "../lib/types";
@@ -32,8 +33,8 @@ export interface AppStore {
setContext: (patch: Partial) => void;
setMood: (mood: string | null) => void;
- addUserMessage: (text: string) => void;
- beginAssistant: () => string;
+ addUserMessage: (text: string, mode?: Mode) => void;
+ beginAssistant: (mode?: Mode) => string;
appendChunk: (delta: string) => void;
endStream: () => void;
setThinking: (text: string) => void;
@@ -76,7 +77,7 @@ export const useAppStore = create()(
set((s) => ({ context: { ...s.context, ...patch } })),
setMood: (mood) => set({ mood }),
- addUserMessage: (text) =>
+ addUserMessage: (text, mode) =>
set((s) => ({
messages: [
...s.messages,
@@ -84,12 +85,13 @@ export const useAppStore = create()(
id: uid(),
role: "user",
content: text,
+ mode,
createdAt: now(),
},
],
})),
- beginAssistant: () => {
+ beginAssistant: (mode) => {
const id = uid();
set((s) => ({
messages: [
@@ -98,6 +100,7 @@ export const useAppStore = create()(
id,
role: "assistant",
content: "",
+ mode,
streaming: true,
tools: [],
createdAt: now(),