feat(web): 聊天背景渲染 + 思维链/工具卡富化

- persona.ts: 新增 personaChatBackground(p, orientation),只取本角色对应方向的图
- ChatShell.tsx: 按横/竖屏渲染角色聊天背景(衬底+scrim),null/无图回退纯 Miuix 面
- chat.tsx: 思维链块加图标 chip+spinner+收起预览;工具卡加状态色 chip+ok/fail Tag
- 红线: 绝不 fallback 昔涟/其它角色;TemplateCard detail 折叠判定等行为不变
This commit is contained in:
Aska committed 2026-09-07 02:57:37 +08:00
1 parent 7455d09d05
commit 4ad1e465da
3 files changed
+139 -49

No files matched your search

+72 -27
View File
@@ -8,7 +8,9 @@ import { WakePath } from "./status";
import { modeBody } from "./messages";
import { renderContent } from "../lib/renderContent";
/** Collapsible inline chain-of-thought block. */
/** Collapsible inline chain-of-thought block. A Miuix disclosure row: icon chip
* + label, spinner when still thinking, and a single-line muted preview of the
* reasoning while collapsed so the block reads at a glance. */
export function ThinkingBlock({
text,
streaming,
@@ -23,26 +25,39 @@ export function ThinkingBlock({
<div className="mb-2 overflow-hidden rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/60">
<button
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-xs text-[var(--mu-text-lo)]"
className="flex w-full items-center gap-2 px-3 py-2 text-xs transition-colors duration-200 hover:bg-[var(--mu-surface-4)]/40"
>
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[var(--mu-radius-xs)] bg-[var(--mu-accent)]/15 text-[var(--mu-accent)]">
<Icon name="memory" size={13} />
</span>
<span className="font-medium text-[var(--mu-text-mid)]">思维链</span>
{streaming && !text ? (
<span className="inline-flex items-center gap-1 text-[var(--mu-text-lo)]">
<Icon name="refresh" size={11} className="animate-spin" />
思考中…
</span>
) : (
<span className="text-[var(--mu-text-lo)]">{open ? "收起" : "展开"}</span>
)}
<span
className={`inline-block transition-transform duration-200 ${open ? "rotate-90" : ""}`}
className={`ml-auto transition-transform duration-200 ease-[var(--mu-spring)] ${open ? "rotate-90" : ""}`}
>
▸
<Icon name="arrow" size={12} className="text-[var(--mu-text-lo)]" />
</span>
<span>思维链</span>
{streaming && !text ? <span className="mu-blink">思考中…</span> : null}
</button>
{open && text ? (
<pre className="max-h-40 overflow-auto whitespace-pre-wrap px-3 pb-2 text-xs leading-relaxed text-[var(--mu-text-mid)]">
<pre className="max-h-40 overflow-auto whitespace-pre-wrap px-3 pb-2.5 text-xs leading-relaxed text-[var(--mu-text-mid)]">
{text}
</pre>
) : !open && text ? (
<p className="truncate px-3 pb-2 text-xs text-[var(--mu-text-lo)]">{text}</p>
) : null}
</div>
);
}
/** Tool echo card (工具回显). */
/** Tool echo card (工具回显). A status chip + tool name + muted one-line summary
* on the left, a color-coded result tag on the right, and collapsible detail. */
export function ToolCard({ tool }: { tool: ToolUsed }): React.ReactNode {
const [open, setOpen] = useState(false);
const status = tool.ok === undefined ? "neutral" : tool.ok ? "ok" : "err";
@@ -50,25 +65,26 @@ export function ToolCard({ tool }: { tool: ToolUsed }): React.ReactNode {
<div className="mb-2 overflow-hidden rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/60">
<button
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-xs"
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs transition-colors duration-200 hover:bg-[var(--mu-surface-4)]/40"
>
<span
className={`inline-block transition-transform duration-200 ${open ? "rotate-90" : ""}`}
className={`inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[var(--mu-radius-xs)] ${statusBadge(status)}`}
>
▸
<Icon name={statusIcon(status)} size={12} className={status === "neutral" ? "animate-spin" : undefined} />
</span>
<span className="min-w-0 flex-1 text-left">
<span className="block truncate font-medium text-[var(--mu-text-mid)]">{tool.name}</span>
{tool.summary ? (
<span className="block truncate text-[var(--mu-text-lo)]">{tool.summary}</span>
) : null}
</span>
<Tag tone={status} className="shrink-0">{statusLabel(status)}</Tag>
<span className={`shrink-0 transition-transform duration-200 ease-[var(--mu-spring)] ${open ? "rotate-90" : ""}`}>
<Icon name="arrow" size={12} className="text-[var(--mu-text-lo)]" />
</span>
<Tag tone={status}>
<span className="inline-flex items-center gap-1">
<Icon name={tool.ok === false ? "close" : tool.ok === true ? "check" : "gear"} size={14} />
{tool.name}
</span>
</Tag>
{tool.summary ? (
<span className="truncate text-[var(--mu-text-lo)]">{tool.summary}</span>
) : null}
</button>
{(open || tool.detail) && tool.detail ? (
<pre className="max-h-40 overflow-auto whitespace-pre-wrap px-3 pb-2 text-xs text-[var(--mu-text-mid)]">
<pre className="max-h-40 overflow-auto whitespace-pre-wrap px-3 pb-2.5 text-xs text-[var(--mu-text-mid)]">
{tool.detail}
</pre>
) : null}
@@ -76,6 +92,30 @@ export function ToolCard({ tool }: { tool: ToolUsed }): React.ReactNode {
);
}
type ToolStatus = "ok" | "err" | "neutral";
function statusBadge(status: ToolStatus): string {
switch (status) {
case "ok": return "bg-[var(--mu-ok)]/15 text-[var(--mu-ok)]";
case "err": return "bg-[var(--mu-err)]/15 text-[var(--mu-err)]";
default: return "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)]";
}
}
function statusIcon(status: ToolStatus): IconName {
switch (status) {
case "ok": return "check";
case "err": return "close";
default: return "refresh";
}
}
function statusLabel(status: ToolStatus): string {
switch (status) {
case "ok": return "成功";
case "err": return "失败";
default: return "运行中";
}
}
/** 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
@@ -108,11 +148,14 @@ function splitParagraphs(content: string): string[] {
* with `<action>`/`<narration>` rendered as muted italic via renderContent. */
function ChatBody({ text, streaming }: { text: string; streaming?: boolean }): React.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)]">
<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)] shadow-[var(--mu-shadow-1)]">
{text ? (
<span className="whitespace-pre-wrap">{renderContent(text)}</span>
) : streaming ? (
<span className="mu-blink text-[var(--mu-text-lo)]">生成中…</span>
<span className="inline-flex items-center gap-1.5 text-[var(--mu-text-lo)]">
<Icon name="refresh" size={12} className="animate-spin" />
生成中…
</span>
) : null}
</div>
);
@@ -136,7 +179,7 @@ function AssistantParaBubble({
return (
<div className="mu-rise-in flex gap-3">
{full ? (
<img src={personaAvatar(currentPersona)} alt="" className="mt-1 h-7 w-7 shrink-0 rounded-full object-cover" />
<img src={personaAvatar(currentPersona)} alt="" className="mt-1 h-7 w-7 shrink-0 rounded-full object-cover ring-1 ring-[var(--mu-glass-border)]" />
) : (
<div className="w-7 shrink-0" aria-hidden />
)}
@@ -207,8 +250,10 @@ export function SplitChatBubble({
return <MessageBubble message={bubble} active={active} />;
}
// Group the paragraphs of one reply into a tighter column so intra-reply
// segments read as one bubble while separate replies keep the 16px list gap.
return (
<>
<div className="flex flex-col gap-2.5">
{segments.map((seg, i) =>
message.role === "user" ? (
<UserParaBubble key={i} message={message} text={seg} full={i === 0} />
@@ -222,7 +267,7 @@ export function SplitChatBubble({
/>
),
)}
</>
</div>
);
}
@@ -265,7 +310,7 @@ function AssistantBubble({
const currentPersona = useAppStore((s) => s.currentPersona);
return (
<div className="mu-rise-in flex gap-3">
<img src={personaAvatar(currentPersona)} alt="" className="mt-1 h-7 w-7 shrink-0 rounded-full object-cover" />
<img src={personaAvatar(currentPersona)} alt="" className="mt-1 h-7 w-7 shrink-0 rounded-full object-cover ring-1 ring-[var(--mu-glass-border)]" />
<div className="min-w-0 max-w-[85%]">
<ModeTag mode={mode} />
{message.mood ? (