Files
Cyrene_v2/frontend/web/src/components/chat.tsx
T
AskaEth 1ac126fc9e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(web): D 消息编辑/删除前端入口 + 日志聚合前端(Observability后端tail) + 修api-client契约
- 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)
2026-10-03 15:23:27 +08:00

442 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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];
}