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:
1 parent
7455d09d05
commit
4ad1e465da
3 files changed
+139
-49
No files matched your search
@@ -8,7 +8,9 @@ import { WakePath } from "./status";
|
|||||||
import { modeBody } from "./messages";
|
import { modeBody } from "./messages";
|
||||||
import { renderContent } from "../lib/renderContent";
|
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({
|
export function ThinkingBlock({
|
||||||
text,
|
text,
|
||||||
streaming,
|
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">
|
<div className="mb-2 overflow-hidden rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/60">
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen((v) => !v)}
|
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
|
<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>思维链</span>
|
|
||||||
{streaming && !text ? <span className="mu-blink">思考中…</span> : null}
|
|
||||||
</button>
|
</button>
|
||||||
{open && text ? (
|
{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}
|
{text}
|
||||||
</pre>
|
</pre>
|
||||||
|
) : !open && text ? (
|
||||||
|
<p className="truncate px-3 pb-2 text-xs text-[var(--mu-text-lo)]">{text}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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 {
|
export function ToolCard({ tool }: { tool: ToolUsed }): React.ReactNode {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const status = tool.ok === undefined ? "neutral" : tool.ok ? "ok" : "err";
|
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">
|
<div className="mb-2 overflow-hidden rounded-[var(--mu-radius-sm)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]/60">
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen((v) => !v)}
|
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
|
<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>
|
</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>
|
</button>
|
||||||
{(open || tool.detail) && tool.detail ? (
|
{(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}
|
{tool.detail}
|
||||||
</pre>
|
</pre>
|
||||||
) : null}
|
) : 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
|
/** 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,
|
* 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
|
* 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. */
|
* with `<action>`/`<narration>` rendered as muted italic via renderContent. */
|
||||||
function ChatBody({ text, streaming }: { text: string; streaming?: boolean }): React.ReactNode {
|
function ChatBody({ text, streaming }: { text: string; streaming?: boolean }): React.ReactNode {
|
||||||
return (
|
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 ? (
|
{text ? (
|
||||||
<span className="whitespace-pre-wrap">{renderContent(text)}</span>
|
<span className="whitespace-pre-wrap">{renderContent(text)}</span>
|
||||||
) : streaming ? (
|
) : 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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -136,7 +179,7 @@ function AssistantParaBubble({
|
|||||||
return (
|
return (
|
||||||
<div className="mu-rise-in flex gap-3">
|
<div className="mu-rise-in flex gap-3">
|
||||||
{full ? (
|
{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 />
|
<div className="w-7 shrink-0" aria-hidden />
|
||||||
)}
|
)}
|
||||||
@@ -207,8 +250,10 @@ export function SplitChatBubble({
|
|||||||
return <MessageBubble message={bubble} active={active} />;
|
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 (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-2.5">
|
||||||
{segments.map((seg, i) =>
|
{segments.map((seg, i) =>
|
||||||
message.role === "user" ? (
|
message.role === "user" ? (
|
||||||
<UserParaBubble key={i} message={message} text={seg} full={i === 0} />
|
<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);
|
const currentPersona = useAppStore((s) => s.currentPersona);
|
||||||
return (
|
return (
|
||||||
<div className="mu-rise-in flex gap-3">
|
<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%]">
|
<div className="min-w-0 max-w-[85%]">
|
||||||
<ModeTag mode={mode} />
|
<ModeTag mode={mode} />
|
||||||
{message.mood ? (
|
{message.mood ? (
|
||||||
|
|||||||
@@ -8,3 +8,16 @@ const DEFAULT_AVATAR = "/images/User_Avatar/Default_Avatar.png";
|
|||||||
export function personaAvatar(p: PersonaRecord | null | undefined): string {
|
export function personaAvatar(p: PersonaRecord | null | undefined): string {
|
||||||
return p?.avatar || DEFAULT_AVATAR;
|
return p?.avatar || DEFAULT_AVATAR;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Orientation for a persona's chat wallpaper. */
|
||||||
|
export type Orientation = "landscape" | "portrait";
|
||||||
|
|
||||||
|
/** Resolve the persona's own chat wallpaper for the given orientation. Returns
|
||||||
|
* undefined when the persona has none (or is null) so callers keep the plain
|
||||||
|
* Miuix surface. Never another role's image. */
|
||||||
|
export function personaChatBackground(
|
||||||
|
p: PersonaRecord | null | undefined,
|
||||||
|
orientation: Orientation
|
||||||
|
): string | undefined {
|
||||||
|
return p?.chatBackground?.[orientation];
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useAppStore } from "../store/useAppStore";
|
import { useAppStore } from "../store/useAppStore";
|
||||||
import { useModeStore } from "../store/useModeStore";
|
import { useModeStore } from "../store/useModeStore";
|
||||||
import { useSendChat } from "../lib/useChat";
|
import { useSendChat } from "../lib/useChat";
|
||||||
@@ -8,6 +8,7 @@ import { ChatInput } from "../components/ChatInput";
|
|||||||
import { EmptyState } from "../components/primitives";
|
import { EmptyState } from "../components/primitives";
|
||||||
import { Icon } from "../components/Icon";
|
import { Icon } from "../components/Icon";
|
||||||
import { SessionStatusHint } from "../components/status";
|
import { SessionStatusHint } from "../components/status";
|
||||||
|
import { personaChatBackground, type Orientation } from "../lib/persona";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The single shared page (design/07 §1). All behavior modes live in ONE message
|
* The single shared page (design/07 §1). All behavior modes live in ONE message
|
||||||
@@ -17,9 +18,27 @@ import { SessionStatusHint } from "../components/status";
|
|||||||
*/
|
*/
|
||||||
export function ChatShell(): React.ReactNode {
|
export function ChatShell(): React.ReactNode {
|
||||||
const messages = useAppStore((s) => s.messages);
|
const messages = useAppStore((s) => s.messages);
|
||||||
|
const currentPersona = useAppStore((s) => s.currentPersona);
|
||||||
const { send, sending } = useSendChat();
|
const { send, sending } = useSendChat();
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Chat wallpaper follows the current orientation (landscape when wide, else
|
||||||
|
// portrait) and re-evaluates on window resize.
|
||||||
|
const [orientation, setOrientation] = useState<Orientation>(() =>
|
||||||
|
window.innerWidth > window.innerHeight ? "landscape" : "portrait"
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onResize = () =>
|
||||||
|
setOrientation(window.innerWidth > window.innerHeight ? "landscape" : "portrait");
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
return () => window.removeEventListener("resize", onResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// The persona's own wallpaper for the current orientation; undefined keeps the
|
||||||
|
// plain Miuix surface (never another role's image).
|
||||||
|
const chatBackground = personaChatBackground(currentPersona, orientation);
|
||||||
|
|
||||||
// The latest assistant message is the one being streamed right now; only it
|
// The latest assistant message is the one being streamed right now; only it
|
||||||
// carries the live 唤醒路径 node sequence (WakePath), so the capacity state
|
// carries the live 唤醒路径 node sequence (WakePath), so the capacity state
|
||||||
// reflects the current turn rather than a collapsed historical message.
|
// reflects the current turn rather than a collapsed historical message.
|
||||||
@@ -36,29 +55,42 @@ export function ChatShell(): React.ReactNode {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="relative min-h-0 flex-1">
|
||||||
<div className="mx-auto flex max-w-2xl flex-col gap-4 p-4">
|
{chatBackground ? (
|
||||||
<SessionStatusHint />
|
<div className="absolute inset-0 overflow-hidden" aria-hidden>
|
||||||
{messages.length === 0 ? (
|
<img
|
||||||
<EmptyState
|
src={chatBackground}
|
||||||
icon={<Icon name="chat" size={24} />}
|
alt=""
|
||||||
title="开始与昔涟对话"
|
draggable={false}
|
||||||
hint="模式只改变消息框的呈现方式,会话上下文与记忆保持不变"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
<div className="absolute inset-0 bg-[var(--mu-surface-2)]/40" />
|
||||||
messages.map((m) => (
|
</div>
|
||||||
<SplitChatBubble
|
) : null}
|
||||||
key={m.id}
|
<div className="relative h-full overflow-y-auto">
|
||||||
message={m}
|
<div className="mx-auto flex max-w-2xl flex-col gap-4 p-4">
|
||||||
active={
|
<SessionStatusHint />
|
||||||
m.id === lastAssistantId &&
|
{messages.length === 0 ? (
|
||||||
m.role === "assistant" &&
|
<EmptyState
|
||||||
(m.streaming ?? false)
|
icon={<Icon name="chat" size={24} />}
|
||||||
}
|
title="开始与昔涟对话"
|
||||||
|
hint="模式只改变消息框的呈现方式,会话上下文与记忆保持不变"
|
||||||
/>
|
/>
|
||||||
))
|
) : (
|
||||||
)}
|
messages.map((m) => (
|
||||||
<div ref={bottomRef} />
|
<SplitChatBubble
|
||||||
|
key={m.id}
|
||||||
|
message={m}
|
||||||
|
active={
|
||||||
|
m.id === lastAssistantId &&
|
||||||
|
m.role === "assistant" &&
|
||||||
|
(m.streaming ?? false)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChatInput
|
<ChatInput
|
||||||
|
|||||||
Reference in new issue
Block a user