feat(web): 前端收敛为「共享消息流 + 按消息 mode 渲染」,并补全 emoji→MD3 图标
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

架构变更(主人要求,design/07 §1/§2 已改定稿):把「9 个行为模式 = 9 个独立页面」改为
「一个共享页面 + 每条消息绑 mode,消息框按对应 mode 渲染」。
- ChatMessage 增 mode?(types.ts);useAppStore addUserMessage/beginAssistant 带 mode;
  useSendChat.send(text, mode) / chatStream body 带 mode(通知后端按 mode 回复)。
- MessageBubble 按 message.mode 分发到 components/messages/ 各形态渲染器
  (chat 气泡 / code 等宽代码块 / home 设备卡 / docs 文档卡 / work 任务卡 / vision 视觉卡)。
- ChatInput 顶部加「行为模式」chip 切换下一条消息的 mode(默认 chat);输入框上方显示当前模式。
- ModeRouter 收敛为只渲染 ChatShell;删除 9 个旧独立 mode 页面;Nav/TopBar 的 mode 改为
  「输入模式」语义;settings 归管理面板(design/10)。

同时补全上一轮 emoji→MD3 图标(约 23 文件):新增 Icon.tsx(MD3 SVG、IconName、零依赖、
离线优先),全部界面 emoji 替换为 <Icon name=... />;正文说明箭头保留。

npm run build 绿(tsc 0 错,79→71 模块,JS 284KB/gzip 86KB)。
This commit is contained in:
Aska committed 2026-09-06 13:32:44 +08:00
1 parent 265b207699
commit 4aab0f501a
28 files changed
+539 -950

No files matched your search

@@ -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 (
<div className="rounded-[var(--mu-radius-lg)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">
{children}
</div>
);
}
function fallbackText(message: ChatMessage): ReactNode {
return message.content ? (
<span className="whitespace-pre-wrap">{message.content}</span>
) : message.streaming ? (
<span className="mu-blink text-[var(--mu-text-lo)]">生成中…</span>
) : null;
}
/** code — mono code block + optional terminal-ish stream, not a plain bubble. */
export function CodeMessageBody({ message }: { message: ChatMessage }): ReactNode {
return (
<div className="space-y-2">
{message.content ? (
<pre className="overflow-x-auto whitespace-pre-wrap rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] p-3 font-mono text-xs leading-relaxed text-[var(--mu-text-mid)]">
{message.content}
</pre>
) : message.streaming ? (
<div className="mu-blink text-xs text-[var(--mu-text-lo)]">正在执行 / 生成中…</div>
) : null}
</div>
);
}
/** docs — knowledge-base document card. */
export function DocsMessageBody({ message }: { message: ChatMessage }): ReactNode {
return (
<div className="space-y-2">
{message.content ? (
<div className="flex items-start gap-2">
<Icon name="docs" size={18} className="mt-0.5 shrink-0 text-[var(--mu-accent)]" />
<div className="min-w-0 space-y-1">
<div className="text-sm font-medium">{message.content}</div>
<Tag tone="neutral">知识库</Tag>
</div>
</div>
) : fallbackText(message)}
</div>
);
}
/** home — device card line (icons + state chips). */
export function HomeMessageBody({ message }: { message: ChatMessage }): ReactNode {
const devices = sampleDevices(message.content);
return (
<div className="space-y-2">
{message.content ? (
<span className="whitespace-pre-wrap">{message.content}</span>
) : null}
{devices.length > 0 ? (
<div className="flex flex-wrap gap-2 pt-1">
{devices.map((d) => (
<Tag key={d} tone="accent">
<span className="inline-flex items-center gap-1">
<Icon name="device" size={12} />
{d}
</span>
</Tag>
))}
</div>
) : null}
{message.streaming ? <div className="mu-blink text-xs text-[var(--mu-text-lo)]">设备状态更新中…</div> : null}
</div>
);
}
/** work — task checklist card. */
export function WorkMessageBody({ message }: { message: ChatMessage }): ReactNode {
const tasks = (message.content ?? "").split("\n").filter((l) => l.trim());
return (
<div className="space-y-2">
{tasks.length > 0 ? (
<div className="space-y-1">
{tasks.map((t, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<Icon name="check" size={14} className="shrink-0 text-[var(--mu-ok)]" />
<span className="min-w-0 whitespace-pre-wrap">{t}</span>
</div>
))}
</div>
) : fallbackText(message)}
</div>
);
}
/** vision — image analysis summary card. */
export function VisionMessageBody({ message }: { message: ChatMessage }): ReactNode {
return shell(message.content ? (
<div className="space-y-1.5">
<div className="flex items-center gap-2 text-xs text-[var(--mu-text-lo)]">
<Icon name="vision" size={14} />
视觉分析
</div>
<span className="whitespace-pre-wrap">{message.content}</span>
</div>
) : fallbackText(message));
}
/** voice — speech session card. */
export function VoiceMessageBody({ message }: { message: ChatMessage }): ReactNode {
return shell(message.content ? (
<div className="flex items-center gap-2 text-sm">
<Icon name="mic" size={16} className="text-[var(--mu-accent)]" />
<span className="whitespace-pre-wrap">{message.content}</span>
</div>
) : 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(<span className="whitespace-pre-wrap">{message.content || fallbackText(message)}</span>);
}
/** 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 <CodeMessageBody message={message} />;
case "docs": return <DocsMessageBody message={message} />;
case "home": return <HomeMessageBody message={message} />;
case "work": return <WorkMessageBody message={message} />;
case "vision": return <VisionMessageBody message={message} />;
case "voice": return <VoiceMessageBody message={message} />;
case "agents": return <AgentsMessageBody message={message} />;
case "settings": return <SettingsMessageBody message={message} />;
default: return <div className="rounded-[var(--mu-radius-lg)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">{fallbackText(message)}</div>;
}
}
/** 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));
}