Compare commits
2
Commits
da1d23dd23
...
811e463cea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
811e463cea | ||
|
|
789e3af74e |
No files matched your search
@@ -1,551 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { ChatMessage, Mode, PersonaRecord, ToolUsed } from "../lib/types";
|
||||
import { useAppStore } from "../store/useAppStore";
|
||||
import { useCurrentPersona } from "../store/personaSelectors";
|
||||
import { personaAvatar, resolveMessagePersona } from "../lib/persona";
|
||||
import { labelFor } from "../lib/enums";
|
||||
import { Tag } from "./primitives";
|
||||
import { Icon, type IconName } from "./Icon";
|
||||
import { WakePath } from "./status";
|
||||
import { modeBody } from "./messages";
|
||||
import { DocumentList } from "./messages/Documents";
|
||||
import { DeliveryChip, TurnTag, userBubbleClass } from "./messages/TurnMeta";
|
||||
import { UserMessageActions } from "./messages/MessageActions";
|
||||
import { CARD_RENDER, 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>
|
||||
);
|
||||
}
|
||||
|
||||
/** The bubble-side binding of `lib/persona.ts#resolveMessagePersona` (design/22
|
||||
* §7-5): which role spoke this message, and whether it differs from the role the
|
||||
* session speaks as now. */
|
||||
function useSpokenPersona(personaId?: string): {
|
||||
persona: PersonaRecord | null;
|
||||
switched: boolean;
|
||||
} {
|
||||
const personas = useAppStore((s) => s.personas);
|
||||
const active = useCurrentPersona();
|
||||
return resolveMessagePersona(personaId, personas, active);
|
||||
}
|
||||
|
||||
/** Muted "角色 · X" line for a bubble whose authoring role differs from the role
|
||||
* the session speaks as now. */
|
||||
function PersonaLabel({ name }: { name?: string }): React.ReactNode {
|
||||
if (!name) return null;
|
||||
return (
|
||||
<div className="text-[10px] leading-tight text-[var(--mu-text-lo)]">角色 · {name}</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 用户气泡下面那行小字:回合小标 + 投递态角标 + 来源。两个用户气泡(单块 / 分段)
|
||||
* 共用一处,避免「四份渲染各写一遍、日后漂移」。 */
|
||||
function UserMeta({ message }: { message: ChatMessage }): React.ReactNode {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
<TurnTag turnId={message.turnId} align="right" />
|
||||
<DeliveryChip
|
||||
delivery={message.delivery}
|
||||
steerStep={message.steerStep}
|
||||
rejectReason={message.rejectReason}
|
||||
/>
|
||||
<SourceLabel deviceId={message.deviceId} role={message.role} />
|
||||
</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} />;
|
||||
}
|
||||
|
||||
/** `<br>` 的**气泡分隔**判据(主人 2026-10-07 口径:「对消息内的 `<br>` 始终处理为多条
|
||||
* 消息隔断」)。`<br>` / `<br/>` / `</br>` 三种写法等价(HTML5 口径),属性不解析
|
||||
* (`<br class=x>` 同样是分隔符)。`(?![a-zA-Z0-9])` 是「别把它名当成 br」的护栏
|
||||
* —— `<brow>` / `<br2>` 不是 br 标签,与 `inlineMarkup.ts` 里「标签候选 = `<` + 字母」
|
||||
* 同一条口径;`[^<>]*` 与那个正则一样要求标签体内不出现尖括号(`<br` 无 `>` 不算标签)。 */
|
||||
const BR_SEPARATOR_RE = /<\/?br(?![a-zA-Z0-9])[^<>]*>/i;
|
||||
|
||||
/** 把一个已切好的段落再按 `<br>` 切开:`<br>` 与 `<p>` **同级** —— 两边的文本各自
|
||||
* 成为独立气泡,所以「气泡内的换行」这个概念在这里被消灭。
|
||||
*
|
||||
* 🔴 **空气泡一律丢弃**(主人 2026-10-07 晚「多余的空气泡确实可以丢掉」):`<p>甲<br><br>乙</p>`
|
||||
* ⇒ `甲` / `乙`;`<br>` 两侧都是空白 ⇒ 不产生多余气泡;**空的 `<p>` 块也丢** ——
|
||||
* `<p></p>` / `<p> </p>`,以及注记被剥走后只剩空壳的 `<p><action>…</action></p>`,
|
||||
* 从此与 `<br>` 碎片同一套口径(上一轮只丢了 `<br>` 碎片、空 `<p>` 仍留一个气泡)。
|
||||
* **不含 `<br>` 的段落逐字返回原样**(输入早已 trim 过)⇒ 既有 `<p>` 口径一个字节不动。 */
|
||||
function splitBubbleBreaks(segment: string): string[] {
|
||||
if (!BR_SEPARATOR_RE.test(segment)) return segment ? [segment] : [];
|
||||
return segment
|
||||
.split(BR_SEPARATOR_RE)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0);
|
||||
}
|
||||
|
||||
/** Split a chat reply's annotation-stripped body into paragraph bodies, one bubble
|
||||
* each. Two peers delimit bubbles: `<p>…</p>` (contract §4.16, one `<p>` = one
|
||||
* bubble) and `<br>` (主人 2026-10-07: `<br>` is a message separator, never an
|
||||
* in-bubble line break). A body with no `<p>` wrapper falls back to splitting on
|
||||
* `\n\n` runs; a body with none of the three 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). Every produced segment then goes through
|
||||
// `splitBubbleBreaks`, so a <br> inside a paragraph cuts it into several bubbles.
|
||||
const pRe = /<p[^>]*>([\s\S]*?)<\/p>/g;
|
||||
const pSegments = [...content.matchAll(pRe)].map((m) => m[1].trim());
|
||||
if (pSegments.length > 0) return pSegments.flatMap(splitBubbleBreaks);
|
||||
|
||||
// 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.flatMap(splitBubbleBreaks);
|
||||
}
|
||||
|
||||
/** 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>
|
||||
);
|
||||
}
|
||||
|
||||
/** 零气泡时是否还有「回复外壳」要出(见 `SplitChatBubble`):思维链 / 工具卡 / 心情是
|
||||
* 用户可见内容,流式中的「生成中…」是必要的等待反馈 —— 有其一就补一行外壳;纯注记回复
|
||||
* (这几样都没有)保持「只剩注记本身」的观感,与改动前一致。 */
|
||||
function hasBubbleChrome(message: ChatMessage): boolean {
|
||||
return Boolean(message.streaming || message.thinking || message.mood || message.tools?.length);
|
||||
}
|
||||
|
||||
/** 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.
|
||||
* Empty text with no stream ⇒ **不画外壳**(零气泡回复里的那行「注记外壳」不因为是空的
|
||||
* 就变成一个空气泡)。 */
|
||||
function ChatBody({ text, streaming }: { text: string; streaming?: boolean }): React.ReactNode {
|
||||
if (!text && !streaming) return null;
|
||||
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>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-[var(--mu-text-lo)]">
|
||||
<Icon name="refresh" size={12} className="animate-spin" />
|
||||
生成中…
|
||||
</span>
|
||||
)}
|
||||
</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 { persona, switched } = useSpokenPersona(message.personaId);
|
||||
return (
|
||||
<div className="mu-rise-in flex gap-3">
|
||||
{full ? (
|
||||
<img src={personaAvatar(persona)} 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)]">心情 · {labelFor("mood", message.mood)}</div>
|
||||
) : null}
|
||||
{full ? <SourceLabel deviceId={message.deviceId} role={message.role} /> : null}
|
||||
{full && switched ? <PersonaLabel name={persona?.name} /> : null}
|
||||
{full ? <TurnTag turnId={message.turnId} /> : 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={`${userBubbleClass(message.delivery)} max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm 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}
|
||||
{full ? <DocumentList documents={message.documents} /> : null}
|
||||
<span className="whitespace-pre-wrap">{renderContent(text)}</span>
|
||||
</div>
|
||||
{full ? <UserMeta message={message} /> : 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 by `splitParagraphs` — two peers
|
||||
* delimit a bubble: `<p>…</p>` (§4.16) and `<br>` (主人 2026-10-07: a `<br>` is a
|
||||
* **message separator**, so it must never render as an in-bubble line break).
|
||||
* First segment = full bubble, the rest compact. Any other mode — code / home /
|
||||
* work / docs / vision / voice / agents / settings — is passed straight through
|
||||
* to MessageBubble → `modeBody`: those are **单卡正文**(没有「多条气泡」的概念),
|
||||
* 所以那边不走气泡切分,`<br>` 由 `renderContent(…, {lineBreaks:true})` 渲染成**换行**
|
||||
* (主人 2026-10-07 晚「非聊天模式按照应当有的专业卡片和格式来显示」)。 */
|
||||
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, "生成中…"). 一处例外:正文**非空却一个气泡都切不出**
|
||||
// (只剩 `<br>` 分隔符,或只剩空 `<p>` 块)时**不能**把原文交下去 —— 那会把字面量
|
||||
// `<br>` / `<p>` 画进气泡,等于凭空造一个空气泡。这种情况按「空正文」交下去(外壳照旧),
|
||||
// 与空气泡丢弃的口径一致。
|
||||
const bodyExhausted = body.length > 0;
|
||||
return (
|
||||
<MessageBubble
|
||||
message={bodyExhausted ? { ...message, content: "" } : 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";
|
||||
// 零气泡(纯动作 / 纯独白回复):注记之外还有用户可见内容(思维链 / 工具卡 / 心情)
|
||||
// 或还在流式等待中时,补一行**无正文**的外壳 —— 头像、思维链、工具卡是内容,不该因为
|
||||
// 丢了空气泡就一并消失;`ChatBody` 对空正文返回 null,所以这里不会画出空气泡。
|
||||
const chromeOnly =
|
||||
segments.length === 0 && message.role === "assistant" && hasBubbleChrome(message);
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-2.5 ${message.role === "user" ? "group relative items-end" : ""}`}
|
||||
>
|
||||
<BubbleNotes items={head} align={align} />
|
||||
{chromeOnly ? (
|
||||
<AssistantParaBubble message={message} text="" full active={active} />
|
||||
) : null}
|
||||
{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 {
|
||||
// 非 chat 模式的用户气泡同样是**单卡正文**(`<br>` ⇒ 换行);chat 模式的正文在
|
||||
// `SplitChatBubble` 里就已按 `<br>` 切成多条,能落到这里的只剩「空正文」的情形,
|
||||
// 因此保持气泡口径 —— `<br>` 绝不在这里变回换行。
|
||||
const options = mode === "chat" ? undefined : CARD_RENDER;
|
||||
const body: React.ReactNode =
|
||||
mode === "code" ? (
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed">{renderContent(message.content, options)}</pre>
|
||||
) : message.content ? (
|
||||
<span className="whitespace-pre-wrap">{renderContent(message.content, options)}</span>
|
||||
) : null;
|
||||
return (
|
||||
<div className="mu-rise-in group relative flex flex-col items-end gap-0.5">
|
||||
<div
|
||||
className={`${userBubbleClass(message.delivery)} max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm 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}
|
||||
<DocumentList documents={message.documents} />
|
||||
{body}
|
||||
</div>
|
||||
<UserMeta message={message} />
|
||||
<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 { persona, switched } = useSpokenPersona(message.personaId);
|
||||
return (
|
||||
<div className="mu-rise-in flex gap-3">
|
||||
<img src={personaAvatar(persona)} 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)]">心情 · {labelFor("mood", message.mood)}</div>
|
||||
) : null}
|
||||
<SourceLabel deviceId={message.deviceId} role={message.role} />
|
||||
{switched ? <PersonaLabel name={persona?.name} /> : null}
|
||||
<TurnTag turnId={message.turnId} />
|
||||
<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];
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ChatMessage } from "../../lib/types";
|
||||
import { Icon } from "../Icon";
|
||||
import { renderContent } from "../../lib/renderContent";
|
||||
import type { ContentPart } from "../../lib/parseContent";
|
||||
|
||||
/** 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. */
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
|
||||
/** 零气泡时是否还有「回复外壳」要出(见 `SplitChatBubble`):思维链 / 工具卡 / 心情是
|
||||
* 用户可见内容,流式中的「生成中…」是必要的等待反馈 —— 有其一就补一行外壳;纯注记回复
|
||||
* (这几样都没有)保持「只剩注记本身」的观感,与改动前一致。 */
|
||||
export function hasBubbleChrome(message: ChatMessage): boolean {
|
||||
return Boolean(message.streaming || message.thinking || message.mood || message.tools?.length);
|
||||
}
|
||||
|
||||
/** 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.
|
||||
* Empty text with no stream ⇒ **不画外壳**(零气泡回复里的那行「注记外壳」不因为是空的
|
||||
* 就变成一个空气泡)。 */
|
||||
export function ChatBody({ text, streaming }: { text: string; streaming?: boolean }): React.ReactNode {
|
||||
if (!text && !streaming) return null;
|
||||
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>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1.5 text-[var(--mu-text-lo)]">
|
||||
<Icon name="refresh" size={12} className="animate-spin" />
|
||||
生成中…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import type { ChatMessage, Mode } from "../../lib/types";
|
||||
import { personaAvatar } from "../../lib/persona";
|
||||
import { labelFor } from "../../lib/enums";
|
||||
import { Icon, type IconName } from "../Icon";
|
||||
import { WakePath } from "../status";
|
||||
import { modeBody } from "../messages";
|
||||
import { DocumentList } from "../messages/Documents";
|
||||
import { TurnTag, userBubbleClass } from "../messages/TurnMeta";
|
||||
import { UserMessageActions } from "../messages/MessageActions";
|
||||
import { CARD_RENDER, renderContent } from "../../lib/renderContent";
|
||||
import { splitBubble } from "../../lib/parseContent";
|
||||
import { splitParagraphs } from "./split";
|
||||
import { BubbleNotes, ChatBody, hasBubbleChrome } from "./chrome";
|
||||
import { ThinkingBlock, ToolCard } from "./toolviz";
|
||||
import { PersonaLabel, SenderLabel, SourceLabel, useSpokenPersona, UserMeta } from "./labels";
|
||||
|
||||
export { ThinkingBlock, ToolCard };
|
||||
|
||||
/** 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} />;
|
||||
}
|
||||
|
||||
/** 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 { persona, switched } = useSpokenPersona(message.personaId);
|
||||
return (
|
||||
<div className="mu-rise-in flex gap-3">
|
||||
{full ? (
|
||||
<img src={personaAvatar(persona)} 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)]">心情 · {labelFor("mood", message.mood)}</div>
|
||||
) : null}
|
||||
{full ? <SourceLabel deviceId={message.deviceId} role={message.role} /> : null}
|
||||
{full && switched ? <PersonaLabel name={persona?.name} /> : null}
|
||||
{full ? <TurnTag turnId={message.turnId} /> : 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={`${userBubbleClass(message.delivery)} max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm 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}
|
||||
{full ? <DocumentList documents={message.documents} /> : null}
|
||||
<span className="whitespace-pre-wrap">{renderContent(text)}</span>
|
||||
</div>
|
||||
{full ? <UserMeta message={message} /> : 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 by `splitParagraphs` — two peers
|
||||
* delimit a bubble: `<p>…</p>` (§4.16) and `<br>` (主人 2026-10-07: a `<br>` is a
|
||||
* **message separator**, so it must never render as an in-bubble line break).
|
||||
* First segment = full bubble, the rest compact. Any other mode — code / home /
|
||||
* work / docs / vision / voice / agents / settings — is passed straight through
|
||||
* to MessageBubble → `modeBody`: those are **单卡正文**(没有「多条气泡」的概念),
|
||||
* 所以那边不走气泡切分,`<br>` 由 `renderContent(…, {lineBreaks:true})` 渲染成**换行**
|
||||
* (主人 2026-10-07 晚「非聊天模式按照应当有的专业卡片和格式来显示」)。 */
|
||||
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, "生成中…"). 一处例外:正文**非空却一个气泡都切不出**
|
||||
// (只剩 `<br>` 分隔符,或只剩空 `<p>` 块)时**不能**把原文交下去 —— 那会把字面量
|
||||
// `<br>` / `<p>` 画进气泡,等于凭空造一个空气泡。这种情况按「空正文」交下去(外壳照旧),
|
||||
// 与空气泡丢弃的口径一致。
|
||||
const bodyExhausted = body.length > 0;
|
||||
return (
|
||||
<MessageBubble
|
||||
message={bodyExhausted ? { ...message, content: "" } : 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";
|
||||
// 零气泡(纯动作 / 纯独白回复):注记之外还有用户可见内容(思维链 / 工具卡 / 心情)
|
||||
// 或还在流式等待中时,补一行**无正文**的外壳 —— 头像、思维链、工具卡是内容,不该因为
|
||||
// 丢了空气泡就一并消失;`ChatBody` 对空正文返回 null,所以这里不会画出空气泡。
|
||||
const chromeOnly =
|
||||
segments.length === 0 && message.role === "assistant" && hasBubbleChrome(message);
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-2.5 ${message.role === "user" ? "group relative items-end" : ""}`}
|
||||
>
|
||||
<BubbleNotes items={head} align={align} />
|
||||
{chromeOnly ? (
|
||||
<AssistantParaBubble message={message} text="" full active={active} />
|
||||
) : null}
|
||||
{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 {
|
||||
// 非 chat 模式的用户气泡同样是**单卡正文**(`<br>` ⇒ 换行);chat 模式的正文在
|
||||
// `SplitChatBubble` 里就已按 `<br>` 切成多条,能落到这里的只剩「空正文」的情形,
|
||||
// 因此保持气泡口径 —— `<br>` 绝不在这里变回换行。
|
||||
const options = mode === "chat" ? undefined : CARD_RENDER;
|
||||
const body: React.ReactNode =
|
||||
mode === "code" ? (
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed">{renderContent(message.content, options)}</pre>
|
||||
) : message.content ? (
|
||||
<span className="whitespace-pre-wrap">{renderContent(message.content, options)}</span>
|
||||
) : null;
|
||||
return (
|
||||
<div className="mu-rise-in group relative flex flex-col items-end gap-0.5">
|
||||
<div
|
||||
className={`${userBubbleClass(message.delivery)} max-w-[80%] rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm 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}
|
||||
<DocumentList documents={message.documents} />
|
||||
{body}
|
||||
</div>
|
||||
<UserMeta message={message} />
|
||||
<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 { persona, switched } = useSpokenPersona(message.personaId);
|
||||
return (
|
||||
<div className="mu-rise-in flex gap-3">
|
||||
<img src={personaAvatar(persona)} 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)]">心情 · {labelFor("mood", message.mood)}</div>
|
||||
) : null}
|
||||
<SourceLabel deviceId={message.deviceId} role={message.role} />
|
||||
{switched ? <PersonaLabel name={persona?.name} /> : null}
|
||||
<TurnTag turnId={message.turnId} />
|
||||
<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];
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ChatMessage, PersonaRecord } from "../../lib/types";
|
||||
import { useAppStore } from "../../store/useAppStore";
|
||||
import { useCurrentPersona } from "../../store/personaSelectors";
|
||||
import { resolveMessagePersona } from "../../lib/persona";
|
||||
import { DeliveryChip, TurnTag } from "../messages/TurnMeta";
|
||||
|
||||
/** 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. */
|
||||
export 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. */
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
|
||||
/** The bubble-side binding of `lib/persona.ts#resolveMessagePersona` (design/22
|
||||
* §7-5): which role spoke this message, and whether it differs from the role the
|
||||
* session speaks as now. */
|
||||
export function useSpokenPersona(personaId?: string): {
|
||||
persona: PersonaRecord | null;
|
||||
switched: boolean;
|
||||
} {
|
||||
const personas = useAppStore((s) => s.personas);
|
||||
const active = useCurrentPersona();
|
||||
return resolveMessagePersona(personaId, personas, active);
|
||||
}
|
||||
|
||||
/** Muted "角色 · X" line for a bubble whose authoring role differs from the role
|
||||
* the session speaks as now. */
|
||||
export function PersonaLabel({ name }: { name?: string }): React.ReactNode {
|
||||
if (!name) return null;
|
||||
return (
|
||||
<div className="text-[10px] leading-tight text-[var(--mu-text-lo)]">角色 · {name}</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 用户气泡下面那行小字:回合小标 + 投递态角标 + 来源。两个用户气泡(单块 / 分段)
|
||||
* 共用一处,避免「四份渲染各写一遍、日后漂移」。 */
|
||||
export function UserMeta({ message }: { message: ChatMessage }): React.ReactNode {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
<TurnTag turnId={message.turnId} align="right" />
|
||||
<DeliveryChip
|
||||
delivery={message.delivery}
|
||||
steerStep={message.steerStep}
|
||||
rejectReason={message.rejectReason}
|
||||
/>
|
||||
<SourceLabel deviceId={message.deviceId} role={message.role} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/** `<br>` 的**气泡分隔**判据(主人 2026-10-07 口径:「对消息内的 `<br>` 始终处理为多条
|
||||
* 消息隔断」)。`<br>` / `<br/>` / `</br>` 三种写法等价(HTML5 口径),属性不解析
|
||||
* (`<br class=x>` 同样是分隔符)。`(?![a-zA-Z0-9])` 是「别把它名当成 br」的护栏
|
||||
* —— `<brow>` / `<br2>` 不是 br 标签,与 `inlineMarkup.ts` 里「标签候选 = `<` + 字母」
|
||||
* 同一条口径;`[^<>]*` 与那个正则一样要求标签体内不出现尖括号(`<br` 无 `>` 不算标签)。 */
|
||||
const BR_SEPARATOR_RE = /<\/?br(?![a-zA-Z0-9])[^<>]*>/i;
|
||||
|
||||
/** 把一个已切好的段落再按 `<br>` 切开:`<br>` 与 `<p>` **同级** —— 两边的文本各自
|
||||
* 成为独立气泡,所以「气泡内的换行」这个概念在这里被消灭。
|
||||
*
|
||||
* 🔴 **空气泡一律丢弃**(主人 2026-10-07 晚「多余的空气泡确实可以丢掉」):`<p>甲<br><br>乙</p>`
|
||||
* ⇒ `甲` / `乙`;`<br>` 两侧都是空白 ⇒ 不产生多余气泡;**空的 `<p>` 块也丢** ——
|
||||
* `<p></p>` / `<p> </p>`,以及注记被剥走后只剩空壳的 `<p><action>…</action></p>`,
|
||||
* 从此与 `<br>` 碎片同一套口径(上一轮只丢了 `<br>` 碎片、空 `<p>` 仍留一个气泡)。
|
||||
* **不含 `<br>` 的段落逐字返回原样**(输入早已 trim 过)⇒ 既有 `<p>` 口径一个字节不动。 */
|
||||
export function splitBubbleBreaks(segment: string): string[] {
|
||||
if (!BR_SEPARATOR_RE.test(segment)) return segment ? [segment] : [];
|
||||
return segment
|
||||
.split(BR_SEPARATOR_RE)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0);
|
||||
}
|
||||
|
||||
/** Split a chat reply's annotation-stripped body into paragraph bodies, one bubble
|
||||
* each. Two peers delimit bubbles: `<p>…</p>` (contract §4.16, one `<p>` = one
|
||||
* bubble) and `<br>` (主人 2026-10-07: `<br>` is a message separator, never an
|
||||
* in-bubble line break). A body with no `<p>` wrapper falls back to splitting on
|
||||
* `\n\n` runs; a body with none of the three 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. */
|
||||
export 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). Every produced segment then goes through
|
||||
// `splitBubbleBreaks`, so a <br> inside a paragraph cuts it into several bubbles.
|
||||
const pRe = /<p[^>]*>([\s\S]*?)<\/p>/g;
|
||||
const pSegments = [...content.matchAll(pRe)].map((m) => m[1].trim());
|
||||
if (pSegments.length > 0) return pSegments.flatMap(splitBubbleBreaks);
|
||||
|
||||
// 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.flatMap(splitBubbleBreaks);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useState } from "react";
|
||||
import type { ToolUsed } from "../../lib/types";
|
||||
import { Tag } from "../primitives";
|
||||
import { Icon, type IconName } from "../Icon";
|
||||
|
||||
/** 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 "运行中";
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,8 @@ func DataURI(mime string, data []byte) string {
|
||||
// Resolve 加载一个 ImageRef 对应的字节。路径与 base64 载荷在
|
||||
// 本地读取;URL 用 http.Client 抓取(传入的 client,为 nil 时
|
||||
// 用默认 client)。对 base64 输入,Data 字段原样作为
|
||||
// 载荷返回,MIME 类型从 data: 前缀推断。
|
||||
// 载荷返回,MIME 类型从 data: 前缀推断;data: 载荷解码后还要过
|
||||
// 内容嗅探(见 checkInlinePayload),不是图片一律否决。
|
||||
func Resolve(ctx context.Context, ref ImageRef, client *http.Client) (mime string, data []byte, err error) {
|
||||
switch ref.Source {
|
||||
case SourcePath:
|
||||
@@ -70,23 +71,38 @@ func readFile(path string) (string, []byte, error) {
|
||||
// decodeBase64 返回 base64/data: 载荷解码后的字节及其 MIME。
|
||||
// 长度预检先于解码与 data: 解析(见 checkInlineImageLength):超限载荷在
|
||||
// 分配之前就被拦下,而不是先按约 4/3 分配再报错。
|
||||
//
|
||||
// data: URI 解码后要过内容嗅探(checkInlinePayload):前缀里的声明与真实字节
|
||||
// 对不上时否决,绝不把「挂着 image/* 声明、内容却不是图片」的载荷转成 data: URI
|
||||
// 交给上游(2026-10-07 的实测缺陷:`data:image/png;base64,` 只带前缀不带载荷,
|
||||
// 旧代码照样报 image/png,空载荷被当成图片发给上游)。
|
||||
//
|
||||
// 裸 base64 路径保持原状:它没有声明可对照,包内契约定的是「按 image/png 处理」
|
||||
// (见 Resolve 的文档),加嗅探就是改这条契约。
|
||||
func decodeBase64(src string) (string, []byte, error) {
|
||||
if err := checkInlineImageLength(src); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
mime, encoded, isDataURI := splitDataURI(src)
|
||||
if isDataURI {
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if !isDataURI {
|
||||
// 以 data: 开头却没解析出 (mime, payload) 只有一个成因:**畸形**的
|
||||
// data: URI(缺逗号分隔符,或没有 ;base64 段 —— 本包只处理 base64 载荷,
|
||||
// 与 image-crop 同口径)。放它走下面的裸 base64 解码,报出来的是
|
||||
// base64 语法错误,指不到真正的毛病,故在此明确拒绝。
|
||||
if strings.HasPrefix(src, "data:") {
|
||||
return "", nil, fmt.Errorf("vision: %w: malformed data: URI (want data:<mime>;base64,<base64 payload>)", ErrBadImage)
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(src)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("vision: %w: %v", ErrBadImage, err)
|
||||
}
|
||||
return mime, decoded, nil
|
||||
return "image/png", decoded, nil
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(src)
|
||||
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("vision: %w: %v", ErrBadImage, err)
|
||||
}
|
||||
return "image/png", decoded, nil
|
||||
return checkInlinePayload(mime, decoded)
|
||||
}
|
||||
|
||||
// fetchURL 通过 http(s) 下载一张图像;只允许 http/https 协议。
|
||||
@@ -184,8 +200,14 @@ func inlineImage(ctx context.Context, src string) (inlinedImage, error) {
|
||||
return inlinedImage{URI: DataURI(mime, data), MIME: mime, Raw: data}, nil
|
||||
}
|
||||
|
||||
// splitDataURI 解析 "data:<mime>;base64,<payload>" 前缀。src 不是
|
||||
// data: URI 时第二个返回值是 false。
|
||||
// splitDataURI 解析 "data:<mime>;base64,<payload>" 前缀。src 不是 data: URI、
|
||||
// 或虽是 data: URI 但不带 ;base64 段时返回 false。
|
||||
//
|
||||
// 只认 base64 载荷(与 image-crop 的 decodeCropDataURI 同口径):百分号编码的
|
||||
// data: URI 从来就不是本包的输入,把它当 base64 解码只会得到一段垃圾字节,
|
||||
// 还挂着 image/png 的假声明送进模型 —— 这类串现在按畸形 data: URI 拒绝。
|
||||
// 返回的 MIME 按包内统一口径归一化(去参数、转小写,见 mediaType);声明缺失
|
||||
// (`data:;base64,…`)时给 image/png,与本函数历来的默认一致。
|
||||
func splitDataURI(src string) (mime, payload string, ok bool) {
|
||||
if !strings.HasPrefix(src, "data:") {
|
||||
return "", "", false
|
||||
@@ -196,18 +218,15 @@ func splitDataURI(src string) (mime, payload string, ok bool) {
|
||||
return "", "", false
|
||||
}
|
||||
meta := rest[:comma]
|
||||
payload = rest[comma+1:]
|
||||
mime = "image/png"
|
||||
if strings.HasPrefix(meta, ";base64") {
|
||||
return mime, payload, true
|
||||
idx := strings.Index(meta, ";base64")
|
||||
if idx < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
if idx := strings.Index(meta, ";base64"); idx >= 0 {
|
||||
if t := strings.TrimSuffix(meta[:idx], ";base64"); t != "" {
|
||||
mime = t
|
||||
}
|
||||
return mime, payload, true
|
||||
mime = mediaType(meta[:idx])
|
||||
if mime == "" {
|
||||
mime = "image/png"
|
||||
}
|
||||
return mime, payload, true
|
||||
return mime, rest[comma+1:], true
|
||||
}
|
||||
|
||||
// fileExists 报告 path 是否存在于磁盘且为常规文件。
|
||||
|
||||
@@ -9,6 +9,10 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// pngSignature 是 PNG 的 8 字节魔数(http.DetectContentType 的判据)。
|
||||
// 体积口径类用例要在载荷头部带上它,才不会被内容嗅探当成文本否决。
|
||||
var pngSignature = []byte("\x89PNG\r\n\x1a\n")
|
||||
|
||||
// TestInlineImageLengthBoundary 钉住长度预检的边界与错误文案:恰好等于
|
||||
// 上限通过,多一个字符就报 ErrImageTooLarge。比较运算符一旦被挪位
|
||||
// (>= / <),或口径被换成另一个数,这里立刻红。
|
||||
@@ -90,13 +94,18 @@ func TestInlineImageSrc_RejectsOversizeDataURI(t *testing.T) {
|
||||
// (否则网关入口与解码层对「一张图能有多大」给出了两个答案),解码结果
|
||||
// 逐字节一致,绝不能误拒 —— 这正是「恰好 = 上限通过」的字节侧半边。
|
||||
//
|
||||
// 载荷头部带上 PNG 签名:本用例量的是体积口径,但 data: URI 解码后要过内容嗅探
|
||||
// (image.go 的 checkInlinePayload),纯填充字节会被嗅成 text/plain 而否决 ——
|
||||
// 那测的就是另一件事了。字节总长不变,故字符侧的口径换算不受影响。
|
||||
//
|
||||
// 判定口径是**字符数**(与网关入口一致,含 64 字符的 data: 前缀余量),
|
||||
// 因此多出几十字节的载荷仍可能落在余量内通过;字符侧的 +1 越界由
|
||||
// TestInlineImageLengthBoundary 钉住,余量本身不超过 64 字符
|
||||
// (TestMaxInlineImageCharsMatchesByteBudget)。
|
||||
func TestDecodeBase64_ExactBudgetImagePasses(t *testing.T) {
|
||||
t.Parallel()
|
||||
exact := bytes.Repeat([]byte{0x5a}, MaxInlineImageBytes)
|
||||
filler := bytes.Repeat([]byte{0x5a}, MaxInlineImageBytes-len(pngSignature))
|
||||
exact := append(append([]byte{}, pngSignature...), filler...)
|
||||
dataURI := DataURI("image/png", exact)
|
||||
if len(dataURI) > MaxInlineImageChars {
|
||||
t.Fatalf("满额图片编码后 %d 字符,超过上限 %d(口径不一致)", len(dataURI), MaxInlineImageChars)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package vision
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件管的是 **data:/base64 入参**的内容嗅探与可行动报错 —— 与 mime.go 的
|
||||
// imageMIMEFor 是同一套裁决(真实字节是否决权、声明只作参考),只是换了入口:
|
||||
// mime.go 管 HTTP 响应与文件读取,这里管「调用方把图片正文直接塞进参数」那条路。
|
||||
//
|
||||
// 起因(2026-10-07 18:32:51 实测,客户端从后端逐字节取过证据):模型交给
|
||||
// vision 工具的 image 是 `data:image/png;base64,` —— **只有前缀、没有载荷**。
|
||||
// 旧代码把它 base64 解码得到零字节,却照样报 MIME=image/png,于是那串空载荷
|
||||
// 挂着 image/png 的声明白白发给上游,换回一句「You have uploaded an unsupported
|
||||
// image」。报错指向不了真正的毛病(参数里根本没有图片字节),模型也无从自纠;
|
||||
// image-crop 在同一秒也栽在同一处,报的是 `image: unknown format`。
|
||||
// 现在这类输入在本地就被明确否决,并附上「它看起来是什么」的物证与可行动提示。
|
||||
|
||||
// supportedFormats 是视觉上游接受的图像格式(与上游白名单同口径:
|
||||
// webp / png / jpeg / gif)。写进错误文案,模型据此换格式重试。
|
||||
const supportedFormats = "webp, png, jpeg, gif"
|
||||
|
||||
// reuseHint 是「把非图片当图片传」时的可行动提示。模型复述不出会话里那张图的
|
||||
// 几万字符 base64(实测就是把前缀抄了下来),正确做法是传它的 URL 或本地路径。
|
||||
const reuseHint = "pass the image's http(s) URL or local path instead of retyping a data: URI"
|
||||
|
||||
// evidenceBytes 是报错时作为物证给出的头部字节数。
|
||||
const evidenceBytes = 16
|
||||
|
||||
// checkInlinePayload 校验 data: URI 解码后的载荷,返回应当声明的 MIME 与载荷
|
||||
// 本身。判据与路径/URL 来源完全一致(复用 imageMIMEFor):判出文本一律否决,
|
||||
// 判不出图像而声明也立不住时同样否决 —— 绝不把一段不是图片的字节挂着
|
||||
// image/* 的声明送进视觉模型。
|
||||
//
|
||||
// 返回的 MIME 取 data: 前缀里**声明**的那个,而不是嗅探结果:这条路径的既有
|
||||
// 契约是「客户端送来的 data: URI 逐字节不变地继续工作」
|
||||
// (url_test.go 的 TestOpenAICompatibleAdapter_Base64Unchanged),换掉声明
|
||||
// 就等于改客户端的线上形状;嗅探在这里只当否决权用。
|
||||
func checkInlinePayload(declared string, data []byte) (string, []byte, error) {
|
||||
if len(data) == 0 {
|
||||
return "", nil, fmt.Errorf("vision: %w: %s payload is empty — the data: URI carries no base64 bytes; hint: %s",
|
||||
ErrNotImage, declaredLabel(declared), reuseHint)
|
||||
}
|
||||
if _, err := imageMIMEFor(declared, data); err != nil {
|
||||
return "", nil, fmt.Errorf("vision: %w: %s payload is %s; supported formats: %s; leading bytes: %s; hint: %s",
|
||||
ErrNotImage, declaredLabel(declared), mediaType(sniffMIME(data)), supportedFormats, hexHead(data), reuseHint)
|
||||
}
|
||||
return declared, data, nil
|
||||
}
|
||||
|
||||
// hexHead 把载荷开头若干字节渲染成十六进制,作为「它看起来是什么」的物证。
|
||||
// 只给固定长度的头部 —— **绝不回显正文**:载荷可能很大,也可能含隐私内容
|
||||
// (同 imageMIMEFor 的口径:错误信息只含类型名,不含图像字节)。
|
||||
func hexHead(data []byte) string {
|
||||
n := min(len(data), evidenceBytes)
|
||||
parts := make([]string, 0, n)
|
||||
for _, b := range data[:n] {
|
||||
parts = append(parts, fmt.Sprintf("%02x", b))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package vision
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// jpegBytes 返回一张真实的 JPEG(1x1),与 mime_test.go 的 pngBytes 同用途:
|
||||
// 让「内容嗅探」在测试里有真实依据,而不是手写常量。
|
||||
func jpegBytes(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||||
img.Set(0, 0, color.RGBA{R: 0xcc, G: 0x33, B: 0x33, A: 0xff})
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, nil); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// htmlDataURI 构造「把网页当图片传」的入参:声明 text/html,载荷是一段 HTML。
|
||||
func htmlDataURI() string {
|
||||
return "data:text/html;base64," + base64.StdEncoding.EncodeToString(htmlBytes())
|
||||
}
|
||||
|
||||
// TestResolve_DataURIImagesDecode 钉住本缺陷的主用例:带完整 base64 载荷的
|
||||
// data: URI(jpeg / png)必须照常解出字节与声明的 MIME —— 也就是 2026-10-07
|
||||
// 那次失败里「本该成立的形状」。MIME 取前缀声明,字节逐字节不变。
|
||||
func TestResolve_DataURIImagesDecode(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
mime string
|
||||
data []byte
|
||||
}{
|
||||
{name: "jpeg", mime: "image/jpeg", data: jpegBytes(t)},
|
||||
{name: "png", mime: "image/png", data: pngBytes(t)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
src := DataURI(tc.mime, tc.data)
|
||||
ref, err := ParseImageRef(src)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseImageRef: %v", err)
|
||||
}
|
||||
mime, data, err := Resolve(context.Background(), ref, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve(%s): %v", tc.mime, err)
|
||||
}
|
||||
if mime != tc.mime {
|
||||
t.Errorf("mime = %q, want %q", mime, tc.mime)
|
||||
}
|
||||
if !bytes.Equal(data, tc.data) {
|
||||
t.Errorf("bytes = %d, want the %d decoded bytes", len(data), len(tc.data))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_EmptyDataURIPayloadRejected 是本缺陷的回归用例(2026-10-07
|
||||
// 18:32:51 实测):模型传的是 `data:image/png;base64,` —— **只有前缀、没有载荷**。
|
||||
// 旧代码解出零字节却照样报 image/png,空载荷被当成图片发给上游,换回一句
|
||||
// 「You have uploaded an unsupported image」。现在必须在本地就明确否决,
|
||||
// 并且说清病因(载荷为空)而不是甩一句 upstream 400。
|
||||
func TestResolve_EmptyDataURIPayloadRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
ref, err := ParseImageRef("data:image/png;base64,")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseImageRef: %v", err)
|
||||
}
|
||||
mime, data, err := Resolve(context.Background(), ref, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("空载荷被接受(mime=%q, %d bytes),want 明确报错", mime, len(data))
|
||||
}
|
||||
if !errors.Is(err, ErrNotImage) {
|
||||
t.Fatalf("error = %v, want ErrNotImage", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty") {
|
||||
t.Errorf("错误文案没点出「载荷为空」:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "http(s) URL") {
|
||||
t.Errorf("错误文案没给可行动提示:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_NonImageDataURIRejected 钉住「这不是图片」:声明 text/html 的
|
||||
// data: URI 必须报明确错误,写清嗅探到的类型与支持的格式,且**不得回显正文**。
|
||||
func TestResolve_NonImageDataURIRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
ref, err := ParseImageRef(htmlDataURI())
|
||||
if err != nil {
|
||||
t.Fatalf("ParseImageRef: %v", err)
|
||||
}
|
||||
if _, _, err := Resolve(context.Background(), ref, nil); err == nil {
|
||||
t.Fatal("text/html 载荷被接受,want ErrNotImage")
|
||||
} else {
|
||||
if !errors.Is(err, ErrNotImage) {
|
||||
t.Fatalf("error = %v, want ErrNotImage", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "502 Bad Gateway") || strings.Contains(msg, "<!DOCTYPE") {
|
||||
t.Fatalf("错误文案回显了正文:%v", msg)
|
||||
}
|
||||
for _, want := range []string{"text/html", "png", "jpeg", "gif", "webp"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("错误文案缺少 %q:%v", want, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_MalformedDataURI 钉住畸形 data: URI 不 panic、报可行动错误:
|
||||
// 缺逗号分隔符、缺 ;base64 段、非法 base64 三种都要落到明确的说法上。
|
||||
func TestResolve_MalformedDataURI(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
src string
|
||||
}{
|
||||
{name: "no comma", src: "data:image/jpeg;base64"},
|
||||
{name: "no base64 marker", src: "data:image/png,plaintext"},
|
||||
// 这一条专治「漏了 ;base64 段、但载荷恰好是合法 base64 字符表」:
|
||||
// 旧 splitDataURI 会把它当 base64 解出 3 个零字节,还挂着 image/png
|
||||
// 的假声明放行(百分号编码的 data: URI 从来不是本包的输入)。
|
||||
{name: "no base64 marker with base64-shaped payload", src: "data:image/png,AAAA"},
|
||||
{name: "illegal base64", src: "data:image/png;base64,!!!!"},
|
||||
{name: "empty after prefix", src: "data:image/jpeg;base64,"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ref, err := ParseImageRef(tc.src)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseImageRef: %v", err)
|
||||
}
|
||||
if _, _, err := Resolve(context.Background(), ref, nil); err == nil {
|
||||
t.Fatalf("%q 被接受,want 报错", tc.src)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_BareBase64Unchanged 钉住「裸 base64 路径一个字不改」:它没有声明
|
||||
// 可对照,包内契约定的是按 image/png 处理(见 Resolve 的文档)。合法的 png 与
|
||||
// jpeg 裸载荷都必须照原样解出、MIME 仍是 image/png。
|
||||
func TestResolve_BareBase64Unchanged(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, raw := range [][]byte{pngBytes(t), jpegBytes(t)} {
|
||||
src := base64.StdEncoding.EncodeToString(raw)
|
||||
ref, err := ParseImageRef(src)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseImageRef: %v", err)
|
||||
}
|
||||
if ref.Source != SourceBase64 {
|
||||
t.Fatalf("Source = %q, want base64", ref.Source)
|
||||
}
|
||||
mime, data, err := Resolve(context.Background(), ref, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("裸 base64 被拒:%v", err)
|
||||
}
|
||||
if mime != "image/png" {
|
||||
t.Errorf("mime = %q, want image/png(裸 base64 的既有契约)", mime)
|
||||
}
|
||||
if !bytes.Equal(data, raw) {
|
||||
t.Errorf("裸 base64 解码结果变了:%d bytes", len(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestVisionTool_DataURIReachesUpstreamIntact 是端到端的那一条(工具 → 适配器
|
||||
// → 上游桩):合法的 data:image/jpeg;base64,... 必须原样成为线上 image_url 里的
|
||||
// 合法 image 块 —— 上游收到的就是一张真 JPEG,所以不会再有 400。
|
||||
func TestVisionTool_DataURIReachesUpstreamIntact(t *testing.T) {
|
||||
t.Parallel()
|
||||
jpg := jpegBytes(t)
|
||||
src := DataURI("image/jpeg", jpg)
|
||||
upSrv, rec := newImageURLRecorderServer(t)
|
||||
tool := NewVisionTool("vision", newAdapter(upSrv.URL))
|
||||
|
||||
res, err := tool.Execute(context.Background(), map[string]any{"image": src, "prompt": "what is this"})
|
||||
if err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if m, _ := res.(map[string]any); m["description"] == nil {
|
||||
t.Fatalf("tool result = %#v, want a description", res)
|
||||
}
|
||||
calls, urls := rec.seen()
|
||||
if calls != 1 || len(urls) != 1 {
|
||||
t.Fatalf("upstream calls = %d urls = %d, want exactly one image block", calls, len(urls))
|
||||
}
|
||||
if urls[0] != src {
|
||||
t.Fatalf("upstream image_url = %.40q..., want the input data URI byte for byte", urls[0])
|
||||
}
|
||||
payload := urls[0][strings.IndexByte(urls[0], ',')+1:]
|
||||
raw, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("上游收到的载荷不是合法 base64:%v", err)
|
||||
}
|
||||
if _, err := jpeg.Decode(bytes.NewReader(raw)); err != nil {
|
||||
t.Fatalf("上游收到的载荷解不出 JPEG(就是那句 unsupported image 的成因):%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVisionTool_EmptyPayloadNeverReachesUpstream 钉住失败发生在**本地**:
|
||||
// 空载荷的 data: URI 必须在调用上游之前就被拒(旧行为是照发空图,换回 400)。
|
||||
func TestVisionTool_EmptyPayloadNeverReachesUpstream(t *testing.T) {
|
||||
t.Parallel()
|
||||
upSrv, rec := newImageURLRecorderServer(t)
|
||||
tool := NewVisionTool("vision", newAdapter(upSrv.URL))
|
||||
|
||||
_, err := tool.Execute(context.Background(), map[string]any{"image": "data:image/png;base64,"})
|
||||
if err == nil {
|
||||
t.Fatal("空载荷被接受,want 报错")
|
||||
}
|
||||
if !errors.Is(err, ErrNotImage) {
|
||||
t.Fatalf("error = %v, want ErrNotImage", err)
|
||||
}
|
||||
if calls, _ := rec.seen(); calls != 0 {
|
||||
t.Fatalf("上游被调用了 %d 次,want 0(缺陷正是「空图照发」)", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_ErrorMessageLeaksNoPayload 钉住错误文案的边界:给模型的提示只含
|
||||
// 类型级信息与固定建议,不含 base64 载荷或图片正文(载荷可能很大也可能敏感)。
|
||||
func TestResolve_ErrorMessageLeaksNoPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
payload := base64.StdEncoding.EncodeToString(htmlBytes())
|
||||
ref, err := ParseImageRef("data:image/png;base64," + payload)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseImageRef: %v", err)
|
||||
}
|
||||
mime, data, err := Resolve(context.Background(), ref, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("谎报 image/png 的 HTML 载荷被接受(mime=%q, %d bytes)", mime, len(data))
|
||||
}
|
||||
if strings.Contains(err.Error(), payload) {
|
||||
t.Fatalf("错误文案回显了 base64 载荷:%v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "text/html") {
|
||||
t.Errorf("错误文案没点出嗅探到的真实类型:%v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package vision
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -68,9 +69,14 @@ func TestParseImageRef(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolve_Base64 钉住 data: URI 那条路:载荷被解码成原始字节,MIME 取前缀
|
||||
// 里声明的那个。**载荷必须是真的图片字节** —— data: URI 带声明,解码后要过内容
|
||||
// 嗅探(image.go 的 checkInlinePayload),拿非图片字节(如 "hello")当载荷
|
||||
// 现在会被明确否决,而不是挂着 image/png 的声明送进模型。
|
||||
func TestResolve_Base64(t *testing.T) {
|
||||
t.Parallel()
|
||||
ref, err := ParseImageRef("data:image/png;base64,aGVsbG8=")
|
||||
png := pngBytes(t)
|
||||
ref, err := ParseImageRef(DataURI("image/png", png))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseImageRef error: %v", err)
|
||||
}
|
||||
@@ -81,8 +87,8 @@ func TestResolve_Base64(t *testing.T) {
|
||||
if mime != "image/png" {
|
||||
t.Errorf("mime = %q, want image/png", mime)
|
||||
}
|
||||
if string(data) != "hello" {
|
||||
t.Errorf("data = %q, want hello", data)
|
||||
if !bytes.Equal(data, png) {
|
||||
t.Errorf("data = %d bytes, want the decoded payload (%d bytes)", len(data), len(png))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,9 +104,14 @@ func (c *cropTool) Execute(ctx context.Context, args any) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sniffed, err := checkCropSourceBytes(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("io: image-crop: decode image: %w", err)
|
||||
return nil, fmt.Errorf("io: image-crop: decode image: %w: the bytes look like %s but could not be decoded "+
|
||||
"— the payload is probably truncated or corrupt", err, sniffed)
|
||||
}
|
||||
bounds := img.Bounds()
|
||||
if err := checkRegion(region, bounds); err != nil {
|
||||
@@ -232,12 +237,23 @@ func (c *cropTool) load(ctx context.Context, src string) ([]byte, error) {
|
||||
}
|
||||
|
||||
// decodeCropDataURI decodes a "data:<mime>;base64,<payload>" source.
|
||||
//
|
||||
// 空载荷是**明确拒绝**而不是交给解码器:`data:image/png;base64,` 这种「只有前缀、
|
||||
// 没有载荷」的入参实测出现过(模型复述不出几万字符的 base64,只把前缀抄了下来),
|
||||
// 旧路径把它解成零字节再报 `image: unknown format`,指不到真正的毛病。前缀本身
|
||||
// 不含图片正文,故可以安全回显(按 cropEvidenceBytes 截断,防止畸形长前缀灌进日志)。
|
||||
func decodeCropDataURI(src string) ([]byte, error) {
|
||||
comma := strings.IndexByte(src, ',')
|
||||
if comma < 0 || !strings.Contains(src[:comma], ";base64") {
|
||||
return nil, fmt.Errorf("io: image-crop: unsupported data URI (expected base64)")
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(src[comma+1:])
|
||||
payload := src[comma+1:]
|
||||
if payload == "" {
|
||||
return nil, fmt.Errorf("io: image-crop: data URI %q carries no payload (the base64 part is empty); "+
|
||||
"pass the actual image bytes, or pass its http(s) URL / local path instead of retyping a data: URI",
|
||||
truncateHead(src[:comma+1], cropEvidenceBytes))
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("io: image-crop: decode data URI: %w", err)
|
||||
}
|
||||
@@ -247,6 +263,15 @@ func decodeCropDataURI(src string) ([]byte, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// truncateHead 把串截到至多 n 个字符,用于把**有限的、受控的**上下文写进错误
|
||||
// 文案(如 data: 前缀);图片正文与 base64 载荷一律不进错误信息。
|
||||
func truncateHead(s string, n int) string {
|
||||
if len(s) > n {
|
||||
return s[:n] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// readFile reads a path through the file tool's sandbox so cropping can never
|
||||
// reach a file the file tool could not read (裁决 13.4.4:文件沙箱唯一实现处).
|
||||
func (c *cropTool) readFile(p string) ([]byte, error) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/base64"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -179,6 +180,148 @@ func TestCropTool_RejectsUnsupportedDataURI(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// gradientJPEG 与 gradientPNG 同构,供「data: URI 带真 JPEG 载荷」的用例使用。
|
||||
// JPEG 有损,故用例只钉裁切尺寸,不逐像素比对。
|
||||
func gradientJPEG(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.Set(x, y, color.RGBA{R: uint8(x * 2), G: uint8(y * 2), A: 255})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, nil); err != nil {
|
||||
t.Fatalf("jpeg.Encode: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// cropArgs 拼一组 image-crop 的参数,省得每个用例重抄区域坐标。
|
||||
func cropArgs(source string) map[string]any {
|
||||
return map[string]any{"image": source, "x": 2, "y": 2, "width": 4, "height": 3}
|
||||
}
|
||||
|
||||
// TestCropTool_DataURIRealImages 钉住主用例:带着**完整 base64 载荷**的
|
||||
// data: URI(png 与 jpeg)都必须照常裁切 —— 也就是 2026-10-07 那次失败里
|
||||
// 「本该成立的形状」。
|
||||
func TestCropTool_DataURIRealImages(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool, _ := newCropToolForTest(t)
|
||||
sources := map[string]string{
|
||||
"png": "data:image/png;base64," + base64.StdEncoding.EncodeToString(gradientPNG(t, 20, 20)),
|
||||
"jpeg": "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(gradientJPEG(t, 20, 20)),
|
||||
}
|
||||
for name, source := range sources {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
res, err := tool.Execute(context.Background(), cropArgs(source))
|
||||
if err != nil {
|
||||
t.Fatalf("Execute(%s data URI): %v", name, err)
|
||||
}
|
||||
if img := decodeCropResult(t, res); img.Bounds().Dx() != 4 || img.Bounds().Dy() != 3 {
|
||||
t.Fatalf("crop size = %dx%d, want 4x3", img.Bounds().Dx(), img.Bounds().Dy())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCropTool_RejectsEmptyDataURIPayload 是本缺陷的回归用例(2026-10-07
|
||||
// 18:32:51 实测):模型传的是 `data:image/png;base64,` —— **只有前缀、没有载荷**。
|
||||
// 旧路径把它解成零字节再报 `image: unknown format`,指不到真正的毛病。
|
||||
// 现在必须在本地说清病因,并且**不许**再出现那句 unknown format。
|
||||
func TestCropTool_RejectsEmptyDataURIPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool, _ := newCropToolForTest(t)
|
||||
_, err := tool.Execute(context.Background(), cropArgs("data:image/png;base64,"))
|
||||
if err == nil {
|
||||
t.Fatal("空载荷的 data: URI 被接受,want 报错")
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "unknown format") {
|
||||
t.Fatalf("错误文案退回旧形状(unknown format):%v", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "no payload") {
|
||||
t.Errorf("错误文案没点出「载荷为空」:%v", msg)
|
||||
}
|
||||
if !strings.Contains(msg, "http(s) URL") {
|
||||
t.Errorf("错误文案没给可行动提示:%v", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCropTool_RejectsNonImageAsNotAnImage 钉住「这不是图片」:把 HTML 当图片
|
||||
// 传(无论走 data: URI 还是 URL)都要报明确错误 —— 说清嗅探到的类型、
|
||||
// 支持的格式与可行动提示,且**不得回显正文**。
|
||||
func TestCropTool_RejectsNonImageAsNotAnImage(t *testing.T) {
|
||||
t.Parallel()
|
||||
html := []byte("<!DOCTYPE html><html><body>502 Bad Gateway</body></html>")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(html)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
tool, _ := newCropToolForTest(t, WithAllowedHosts(serverHost(srv)))
|
||||
sources := map[string]string{
|
||||
"data uri": "data:text/html;base64," + base64.StdEncoding.EncodeToString(html),
|
||||
"url": srv.URL + "/page.html",
|
||||
}
|
||||
for name, source := range sources {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := tool.Execute(context.Background(), cropArgs(source))
|
||||
if err == nil {
|
||||
t.Fatal("HTML 被当成图片接受,want 报错")
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "502 Bad Gateway") || strings.Contains(msg, "<!DOCTYPE") {
|
||||
t.Fatalf("错误文案回显了正文:%v", msg)
|
||||
}
|
||||
for _, want := range []string{"not an image", "text/html", "png", "jpeg", "gif"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("错误文案缺少 %q:%v", want, msg)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCropTool_MalformedDataURI 钉住畸形入参不 panic、报可行动错误。
|
||||
func TestCropTool_MalformedDataURI(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool, _ := newCropToolForTest(t)
|
||||
cases := map[string]string{
|
||||
"no comma": "data:image/jpeg;base64",
|
||||
"no base64": "data:image/png,plaintext",
|
||||
"illegal base64": "data:image/png;base64,!!!!",
|
||||
"empty payload": "data:image/jpeg;base64,",
|
||||
}
|
||||
for name, source := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := tool.Execute(context.Background(), cropArgs(source)); err == nil {
|
||||
t.Fatalf("%q 被接受,want 报错", source)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCropTool_BareBase64StaysAPath 钉住既有分类不变:裸 base64 **不是**本工具
|
||||
// 支持的输入(cropSourceKindOf 只认 data: / http(s) / 路径),它照旧走文件路径那条
|
||||
// 分支并被沙箱拒绝 —— 这次改动不碰它。
|
||||
func TestCropTool_BareBase64StaysAPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
tool, _ := newCropToolForTest(t)
|
||||
bare := base64.StdEncoding.EncodeToString(gradientPNG(t, 8, 8))
|
||||
_, err := tool.Execute(context.Background(), cropArgs(bare))
|
||||
if err == nil {
|
||||
t.Fatal("裸 base64 被当成图片接受,want 走路径分支并被拒")
|
||||
}
|
||||
if strings.Contains(err.Error(), "not an image") {
|
||||
t.Fatalf("裸 base64 的失败形状变了(应仍是路径/沙箱报错):%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCropTool_RegisteredInSet pins that the tool ships with the default set and
|
||||
// advertises a usable schema (the agent only sees it if it is registered).
|
||||
func TestCropTool_RegisteredInSet(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package io
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 本文件是 image-crop 的**入参内容嗅探 + 可行动报错**。此前 Execute 把源字节
|
||||
// 直接丢给 image.Decode,失败时只报 `decode image: image: unknown format` ——
|
||||
// 既不说是哪种内容,也不说本工具支持什么,模型无从自纠。
|
||||
//
|
||||
// 2026-10-07 18:32:51 的实测就是这个形状:模型传给 image-crop 的 image 是
|
||||
// `data:image/png;base64,`(只有前缀、没有载荷),解码得到零字节 ⇒ 报
|
||||
// unknown format;同一秒 vision 工具拿同一个入参,把空载荷当图片发给上游,
|
||||
// 换回一句「You have uploaded an unsupported image」。两条报错都指不到真正的
|
||||
// 毛病:参数里根本没有图片字节。现在这类输入在踩到解码器之前就被明确否决。
|
||||
|
||||
const (
|
||||
// cropSniffBytes 是字节嗅探读取的前缀长度(http.DetectContentType 只看前 512 字节)。
|
||||
cropSniffBytes = 512
|
||||
// cropEvidenceBytes 是报错时作证给出的头部字节数。**绝不回显正文** ——
|
||||
// 载荷可能很大,也可能含隐私内容,只给固定长度的头部十六进制。
|
||||
cropEvidenceBytes = 16
|
||||
// cropDecodableFormats 是本工具能本地解码的格式:标准库注册了 png / jpeg / gif
|
||||
// 三个解码器(crop.go 的 import)。webp / bmp 没有标准库解码器,加解码器要引
|
||||
// 新依赖,故如实报告「本地解不了」而不是含糊的 unknown format。
|
||||
cropDecodableFormats = "png, jpeg, gif"
|
||||
// cropUpstreamFormats 是视觉上游接受的格式(与上游白名单同口径),
|
||||
// 「本地解不了」时给出这条替代路径。
|
||||
cropUpstreamFormats = "webp, png, jpeg, gif"
|
||||
// cropReuseHint 是「把非图片当图片传」时的可行动提示。
|
||||
cropReuseHint = "to reuse an image from earlier in the conversation, pass its http(s) URL or local path instead of a data: URI whose base64 payload you cannot reproduce"
|
||||
)
|
||||
|
||||
// checkCropSourceBytes 在解码之前嗅探源字节,返回嗅探到的媒体类型(供解码
|
||||
// 失败时作证)。它不是 image.Decode 判断的复述,而是**换一种失败方式**:
|
||||
// 不可能解出图的输入在这里得到「这不是图片 / 这个格式本地解不了」的明确报错。
|
||||
func checkCropSourceBytes(data []byte) (string, error) {
|
||||
if len(data) == 0 {
|
||||
return "", fmt.Errorf("io: image-crop: source has no bytes, so it is not an image (empty payload); "+
|
||||
"supported formats: %s; hint: %s", cropDecodableFormats, cropReuseHint)
|
||||
}
|
||||
sniffed := cropSniffMIME(data)
|
||||
switch {
|
||||
case cropDecodableMIME(sniffed):
|
||||
return sniffed, nil
|
||||
case strings.HasPrefix(sniffed, "image/"):
|
||||
// 嗅探到图像签名,但本地没有它的解码器(webp / bmp / avif…)。
|
||||
return sniffed, fmt.Errorf("io: image-crop: cannot decode %s locally: this tool decodes %s "+
|
||||
"and the vision upstream accepts %s; convert the image to one of those formats first",
|
||||
sniffed, cropDecodableFormats, cropUpstreamFormats)
|
||||
case sniffed != "" && isTextContentType(sniffed):
|
||||
// 复用的文本判据(util.go 的 isTextContentType):HTML/PROXY 错误页、
|
||||
// JSON、SVG、纯文本都落在这里 —— 它们最常来自「把网页地址当图片直链」。
|
||||
return sniffed, fmt.Errorf("io: image-crop: source is not an image: the bytes look like %s; "+
|
||||
"supported formats: %s; leading bytes: %s; hint: %s",
|
||||
sniffed, cropDecodableFormats, hexHead(data), cropReuseHint)
|
||||
default:
|
||||
return sniffed, fmt.Errorf("io: image-crop: source is not an image: unrecognised content (%s); "+
|
||||
"supported formats: %s; leading bytes: %s; hint: %s",
|
||||
sniffed, cropDecodableFormats, hexHead(data), cropReuseHint)
|
||||
}
|
||||
}
|
||||
|
||||
// cropSniffMIME 按真实字节嗅探媒体类型,归一化成无参数的小写形式。它是对
|
||||
// 声明的否决权:扩展名、Content-Type 与 data: 前缀都可能骗人,字节不会。
|
||||
// 判不出格式时 http.DetectContentType 返回 application/octet-stream。
|
||||
func cropSniffMIME(data []byte) string {
|
||||
if len(data) > cropSniffBytes {
|
||||
data = data[:cropSniffBytes]
|
||||
}
|
||||
mt := http.DetectContentType(data)
|
||||
return strings.ToLower(strings.TrimSpace(strings.Split(mt, ";")[0]))
|
||||
}
|
||||
|
||||
// cropDecodableMIME 报告 mime 是否是本工具能本地解码的格式。
|
||||
func cropDecodableMIME(mime string) bool {
|
||||
switch mime {
|
||||
case "image/png", "image/jpeg", "image/gif":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// hexHead 把载荷开头 cropEvidenceBytes 个字节渲染成十六进制,作为「它到底像
|
||||
// 什么」的物证。只给固定的头部长度:错误信息里不得出现图片正文或 base64 载荷。
|
||||
func hexHead(data []byte) string {
|
||||
if len(data) > cropEvidenceBytes {
|
||||
data = data[:cropEvidenceBytes]
|
||||
}
|
||||
parts := make([]string, 0, len(data))
|
||||
for _, b := range data {
|
||||
parts = append(parts, fmt.Sprintf("%02x", b))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
Reference in new issue
Block a user