- D前端: lib/api/messages.ts(patchMessage/deleteMessage,兼容裸DTO与{message}) + MessageActions.tsx(仅user消息; 单击/右键/450ms长按开Miuix玻璃菜单; 编辑弹窗预填+字数→乐观updateMessageById→patchMessage→失败回滚; 删除确认→deleteMessage→removeMessageById; 乐观气泡禁用) + chat.tsx挂载 + Icon加edit/trash/more
- 日志前端: types(AdminLogLine time:string|number) + adminSystem.adminObservabilityLogs + Observability.tsx 后端分级tail(level筛选chip注明'该级及以上'+等宽行HH:mm:ss.SSS·level着色·source·msg+30s刷新) 下半保留WS回显
- Docs/api-client §6a/§4.2 修正: PATCH /messages/{id} 实际返回 {data:<DTO>} 非 {data:{message}}(前端已双向兼容,避免误导Android)
- 前端 build 绿(167 modules); SSR冒烟11 checks
- 遗留: 未实机登录; 菜单absolute理论上边缘可裁切(可改portal)
442 lines
18 KiB
TypeScript
442 lines
18 KiB
TypeScript
import { useState } from "react";
|
||
import type { ChatMessage, Mode, ToolUsed } from "../lib/types";
|
||
import { useAppStore } from "../store/useAppStore";
|
||
import { personaAvatar } from "../lib/persona";
|
||
import { Tag } from "./primitives";
|
||
import { Icon, type IconName } from "./Icon";
|
||
import { WakePath } from "./status";
|
||
import { modeBody } from "./messages";
|
||
import { UserMessageActions } from "./messages/MessageActions";
|
||
import { renderContent } from "../lib/renderContent";
|
||
import { splitBubble, type ContentPart } from "../lib/parseContent";
|
||
|
||
/** 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,
|
||
}: {
|
||
text?: string;
|
||
streaming?: boolean;
|
||
}): React.ReactNode | null {
|
||
const [open, setOpen] = useState(false);
|
||
if (!text && !streaming) return null;
|
||
|
||
return (
|
||
<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-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={`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>
|
||
</button>
|
||
{open && text ? (
|
||
<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 (工具回显). 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";
|
||
return (
|
||
<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.5 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)] ${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>
|
||
</button>
|
||
{(open || tool.detail) && tool.detail ? (
|
||
<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}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 "运行中";
|
||
}
|
||
}
|
||
|
||
/** Small muted source label for a persisted message (design/16 §4.5): shows the
|
||
* concrete client the message came from, e.g. "来自 web:chrome:a1b2c3". Rendered
|
||
* only when a deviceId is present, so legacy/unlabelled messages stay clean. The
|
||
* Web client defaults to this label; other clients decide themselves. */
|
||
function SourceLabel({ deviceId, role }: { deviceId?: string; role?: string }): React.ReactNode {
|
||
if (!deviceId) return null;
|
||
// A user message is received FROM a client; an assistant reply goes TO the
|
||
// client that prompted it. Semantic direction differs — "来自" vs "发送至".
|
||
const dir = role === "assistant" ? "发送至" : "来自";
|
||
return (
|
||
<div className="text-[10px] leading-tight text-[var(--mu-text-lo)]">
|
||
{dir} {deviceId}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Small sender label for a third-party message (design/16 §4.6): shows the
|
||
* sender's display name, e.g. "第三方 · 张三". Blank for the owner's private chat
|
||
* (no senderName), so owner bubbles stay unlabelled. */
|
||
function SenderLabel({ name }: { name?: string }): React.ReactNode {
|
||
if (!name) return null;
|
||
return (
|
||
<div className="text-[10px] leading-tight text-[var(--mu-text-lo)]">
|
||
第三方 · {name}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 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,
|
||
active = false,
|
||
}: {
|
||
message: ChatMessage;
|
||
active?: boolean;
|
||
}): React.ReactNode {
|
||
const mode = message.mode ?? "chat";
|
||
if (message.role === "user") {
|
||
return <UserBubble message={message} mode={mode} />;
|
||
}
|
||
return <AssistantBubble message={message} mode={mode} active={active} />;
|
||
}
|
||
|
||
/** Split a chat reply's annotation-stripped body into `<p>`-delimited paragraph
|
||
* bodies. Each complete `<p>…</p>` block becomes one bubble segment; a body that
|
||
* carries no `<p>` wrapper falls back to splitting on `\n\n` runs, and a body
|
||
* with neither yields an empty list so the caller handles the empty reply.
|
||
* Non-greedy + multi-line so a tag may span newlines; a still-streaming,
|
||
* unclosed `<p>` simply does not match and stays out until it closes. */
|
||
function splitParagraphs(content: string): string[] {
|
||
// Preferred: the agent wraps each paragraph in <p>…</p> (design/07 §2). When it
|
||
// emits plain \n\n runs instead (the model often does), split on those too so
|
||
// each logical paragraph becomes its own bubble. `content` here is the
|
||
// annotation-stripped body from `splitBubble`, so no <action>/<narration> block
|
||
// can be cut across; never split on a single \n (that is an in-line break, kept
|
||
// inside the paragraph).
|
||
const pRe = /<p[^>]*>([\s\S]*?)<\/p>/g;
|
||
const pSegments = [...content.matchAll(pRe)].map((m) => m[1].trim());
|
||
if (pSegments.length > 0) return pSegments;
|
||
|
||
// Keep non-<p> remainder as its own paragraph bodies.
|
||
const segments: string[] = [];
|
||
for (const part of content.split(/\n\s*\n/)) {
|
||
if (!part.trim()) continue;
|
||
segments.push(part.trim());
|
||
}
|
||
return segments;
|
||
}
|
||
|
||
/** Small muted attachment note for a single `<action>` / `<narration>` block.
|
||
* Rendered as a grey italic micro-line OUTSIDE the bubble (anchored to the
|
||
* message's head/tail by `splitBubble`) — never inside the bubble body.
|
||
* `align` mirrors the bubble column: `left` indents under the assistant avatar
|
||
* column, `right` lines the note up with a right-aligned user bubble. */
|
||
function BubbleNotes({
|
||
items,
|
||
align = "left",
|
||
}: {
|
||
items: ContentPart[];
|
||
align?: "left" | "right";
|
||
}): React.ReactNode {
|
||
if (items.length === 0) return null;
|
||
const container =
|
||
align === "right"
|
||
? "flex flex-col items-end gap-0.5"
|
||
: "flex flex-col items-start gap-0.5 pl-10";
|
||
return (
|
||
<div className={container}>
|
||
{items.map((item, i) => (
|
||
<div key={i} className="text-[10px] italic leading-tight text-[var(--mu-text-lo)]">
|
||
*{item.type === "action" ? "动作" : "旁白"}:{item.text}*
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Chat-mode body: a Miuix surface shell around a whitespace-preserving span.
|
||
* `text` is already the annotation-stripped body paragraph from `splitBubble`,
|
||
* so it renders as plain text only — action/narration notes live outside. */
|
||
function ChatBody({ text, streaming }: { text: string; streaming?: boolean }): React.ReactNode {
|
||
return (
|
||
<div className="liquid-glass-soft rounded-[var(--mu-radius-lg)] 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="inline-flex items-center gap-1.5 text-[var(--mu-text-lo)]">
|
||
<Icon name="refresh" size={12} className="animate-spin" />
|
||
生成中…
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** One assistant paragraph bubble. `full` renders the complete bubble (avatar /
|
||
* mood / 思维链 / 工具卡 / wake path); compact segments render just the aligned
|
||
* body, so the chrome is not repeated paragraph after paragraph. */
|
||
function AssistantParaBubble({
|
||
message,
|
||
text,
|
||
full,
|
||
active = false,
|
||
}: {
|
||
message: ChatMessage;
|
||
text: string;
|
||
full: boolean;
|
||
active?: boolean;
|
||
}): React.ReactNode {
|
||
const currentPersona = useAppStore((s) => s.currentPersona);
|
||
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 ring-1 ring-[var(--mu-glass-border)]" />
|
||
) : (
|
||
<div className="w-7 shrink-0" aria-hidden />
|
||
)}
|
||
<div className="min-w-0 max-w-[85%]">
|
||
{full ? <ModeTag mode={message.mode ?? "chat"} /> : null}
|
||
{full && message.mood ? (
|
||
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {message.mood}</div>
|
||
) : null}
|
||
{full ? <SourceLabel deviceId={message.deviceId} role={message.role} /> : null}
|
||
{full ? <WakePath active={active} /> : null}
|
||
{full ? <ThinkingBlock text={message.thinking} streaming={message.streaming} /> : null}
|
||
{full ? message.tools?.map((t, i) => <ToolCard key={i} tool={t} />) : null}
|
||
<ChatBody text={text} streaming={message.streaming} />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** One user paragraph bubble. Only the first segment carries the optional image. */
|
||
function UserParaBubble({
|
||
message,
|
||
text,
|
||
full,
|
||
}: {
|
||
message: ChatMessage;
|
||
text: string;
|
||
full: boolean;
|
||
}): React.ReactNode {
|
||
return (
|
||
<div className="mu-rise-in flex flex-col items-end gap-0.5">
|
||
<div className="liquid-glass-accent max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-white shadow-[var(--mu-shadow-1)]">
|
||
{full && message.image ? (
|
||
<img
|
||
src={message.image}
|
||
alt="附件"
|
||
className="mb-1.5 block max-h-56 rounded-[var(--mu-radius-sm)] object-cover"
|
||
/>
|
||
) : null}
|
||
<span className="whitespace-pre-wrap">{renderContent(text)}</span>
|
||
</div>
|
||
{full ? <SourceLabel deviceId={message.deviceId} role={message.role} /> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Chat-only entry: a `chat`-mode reply's `<action>`/`<narration>` blocks are
|
||
* peeled off by `splitBubble` into head/tail attachment notes, and the remaining
|
||
* plain-text body is split into stacked bubbles (first = full bubble, remainder
|
||
* = compact). Any other mode — code / home / work / docs / vision / voice /
|
||
* agents / settings — is passed straight through to MessageBubble so its body
|
||
* renders untouched: no `<p>` splitting, and its content (which may itself
|
||
* contain `<p>` / `<action>` / `<narration>` as literal text or code) is never
|
||
* re-parsed. */
|
||
export function SplitChatBubble({
|
||
message,
|
||
active = false,
|
||
}: {
|
||
message: ChatMessage;
|
||
active?: boolean;
|
||
}): React.ReactNode {
|
||
const mode = message.mode ?? "chat";
|
||
if (mode !== "chat") return <MessageBubble message={message} active={active} />;
|
||
|
||
const { body, head, tail } = splitBubble(message.content ?? "");
|
||
const segments = splitParagraphs(body);
|
||
|
||
if (segments.length === 0 && head.length === 0 && tail.length === 0) {
|
||
// No visible content: hand off so the empty / still-streaming reply keeps its
|
||
// usual chrome (avatar, mood, "生成中…").
|
||
return <MessageBubble message={message} 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.
|
||
// The annotation notes (head/tail) attach to the whole group — above the first
|
||
// bubble and below the last — never inside a bubble's body.
|
||
const align = message.role === "user" ? "right" : "left";
|
||
return (
|
||
<div
|
||
className={`flex flex-col gap-2.5 ${message.role === "user" ? "group relative items-end" : ""}`}
|
||
>
|
||
<BubbleNotes items={head} align={align} />
|
||
{segments.map((seg, i) =>
|
||
message.role === "user" ? (
|
||
<UserParaBubble key={i} message={message} text={seg} full={i === 0} />
|
||
) : (
|
||
<AssistantParaBubble
|
||
key={i}
|
||
message={message}
|
||
text={seg}
|
||
full={i === 0}
|
||
active={active}
|
||
/>
|
||
),
|
||
)}
|
||
<BubbleNotes items={tail} align={align} />
|
||
{message.role === "user" ? <UserMessageActions message={message} /> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 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 {
|
||
const body: React.ReactNode =
|
||
mode === "code" ? (
|
||
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed">{renderContent(message.content)}</pre>
|
||
) : message.content ? (
|
||
<span className="whitespace-pre-wrap">{renderContent(message.content)}</span>
|
||
) : null;
|
||
return (
|
||
<div className="mu-rise-in group relative flex flex-col items-end gap-0.5">
|
||
<div className="liquid-glass-accent max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-white shadow-[var(--mu-shadow-1)]">
|
||
{message.image ? (
|
||
<img
|
||
src={message.image}
|
||
alt="附件"
|
||
className="mb-1.5 block max-h-56 rounded-[var(--mu-radius-sm)] object-cover"
|
||
/>
|
||
) : null}
|
||
{body}
|
||
</div>
|
||
<SourceLabel deviceId={message.deviceId} role={message.role} />
|
||
<SenderLabel name={message.senderName} />
|
||
<UserMessageActions message={message} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 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,
|
||
active = false,
|
||
}: {
|
||
message: ChatMessage;
|
||
mode: Mode;
|
||
active?: boolean;
|
||
}): React.ReactNode {
|
||
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 ring-1 ring-[var(--mu-glass-border)]" />
|
||
<div className="min-w-0 max-w-[85%]">
|
||
<ModeTag mode={mode} />
|
||
{message.mood ? (
|
||
<div className="mb-1 text-xs text-[var(--mu-text-lo)]">心情 · {message.mood}</div>
|
||
) : null}
|
||
<SourceLabel deviceId={message.deviceId} role={message.role} />
|
||
<WakePath active={active} />
|
||
<ThinkingBlock text={message.thinking} streaming={message.streaming} />
|
||
{message.tools?.map((t, i) => <ToolCard key={i} tool={t} />)}
|
||
{modeBody(mode, message)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Small chip showing the message's behavior mode. */
|
||
function ModeTag({ mode }: { mode: Mode }): React.ReactNode {
|
||
if (mode === "chat") return null;
|
||
return (
|
||
<div className="mb-1 inline-flex items-center gap-1 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-4)] px-1.5 py-0.5 text-[10px] font-medium text-[var(--mu-text-mid)]">
|
||
<Icon name={modeIcon(mode)} size={11} />
|
||
{modeLabel(mode)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function modeLabel(mode: Mode): string {
|
||
const map: Record<Mode, string> = {
|
||
chat: "对话", code: "代码", home: "家居", work: "工作",
|
||
docs: "知识库", voice: "语音", vision: "视觉", agents: "子代理", settings: "设置",
|
||
};
|
||
return map[mode];
|
||
}
|
||
function modeIcon(mode: Mode): IconName {
|
||
const map: Record<Mode, IconName> = {
|
||
chat: "chat", code: "terminal", home: "home", work: "folder",
|
||
docs: "docs", voice: "mic", vision: "vision", agents: "agent", settings: "settings",
|
||
};
|
||
return map[mode];
|
||
}
|